From 7020b6dbcd599978f903dc88abc8d45c04e34b67 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 12 Feb 2026 22:02:51 -0500 Subject: [PATCH 1/9] feat(chat): add AI chat interface using local CLI agents Add `nylas chat` command that launches a web-based AI chat interface for interacting with email, calendar, and contacts through locally installed AI agents (Claude, Codex, Ollama). Key features: - Auto-detects installed AI agents via PATH lookup - Text-based tool protocol (TOOL_CALL/TOOL_RESULT) for agent-API bridge - SSE streaming for real-time chat responses - Persistent conversation history as JSON files on disk - Context window management with automatic compaction - Agent switching via dropdown without restart - Modern dark/light theme web UI --- CLAUDE.md | 5 +- cmd/nylas/main.go | 2 + docs/COMMANDS.md | 58 +++ internal/chat/agent.go | 182 ++++++++++ internal/chat/agent_test.go | 257 +++++++++++++ internal/chat/chat.go | 118 ++++++ internal/chat/context.go | 161 +++++++++ internal/chat/context_test.go | 426 ++++++++++++++++++++++ internal/chat/executor.go | 387 ++++++++++++++++++++ internal/chat/handlers.go | 273 ++++++++++++++ internal/chat/handlers_conv.go | 88 +++++ internal/chat/memory.go | 244 +++++++++++++ internal/chat/memory_test.go | 513 ++++++++++++++++++++++++++ internal/chat/prompt.go | 46 +++ internal/chat/server.go | 127 +++++++ internal/chat/session.go | 28 ++ internal/chat/static/css/chat.css | 519 +++++++++++++++++++++++++++ internal/chat/static/js/api.js | 76 ++++ internal/chat/static/js/chat.js | 210 +++++++++++ internal/chat/static/js/markdown.js | 51 +++ internal/chat/static/js/sidebar.js | 133 +++++++ internal/chat/templates/index.gohtml | 65 ++++ internal/chat/tools.go | 166 +++++++++ internal/chat/tools_test.go | 354 ++++++++++++++++++ 24 files changed, 4488 insertions(+), 1 deletion(-) create mode 100644 internal/chat/agent.go create mode 100644 internal/chat/agent_test.go create mode 100644 internal/chat/chat.go create mode 100644 internal/chat/context.go create mode 100644 internal/chat/context_test.go create mode 100644 internal/chat/executor.go create mode 100644 internal/chat/handlers.go create mode 100644 internal/chat/handlers_conv.go create mode 100644 internal/chat/memory.go create mode 100644 internal/chat/memory_test.go create mode 100644 internal/chat/prompt.go create mode 100644 internal/chat/server.go create mode 100644 internal/chat/session.go create mode 100644 internal/chat/static/css/chat.css create mode 100644 internal/chat/static/js/api.js create mode 100644 internal/chat/static/js/chat.js create mode 100644 internal/chat/static/js/markdown.js create mode 100644 internal/chat/static/js/sidebar.js create mode 100644 internal/chat/templates/index.gohtml create mode 100644 internal/chat/tools.go create mode 100644 internal/chat/tools_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 96e3125..fc93789 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ make ci # Runs: fmt → vet → lint → test-unit → test-race → secu - **API**: Nylas v3 ONLY (never use v1/v2) - **Timezone Support**: Offline utilities + calendar integration ✅ - **Email Signing**: GPG/PGP email signing (RFC 3156 PGP/MIME) ✅ +- **AI Chat**: Web-based chat interface using locally installed AI agents ✅ - **Credential Storage**: System keyring (see below) - **Web UI**: Air - browser-based interface (localhost:7365) @@ -122,7 +123,7 @@ Credentials from `nylas auth config` are stored in the system keyring under serv **Core files:** `cmd/nylas/main.go`, `internal/ports/nylas.go`, `internal/adapters/nylas/client.go` -**Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/` +**Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`, Chat at `internal/chat/` **New packages (2024-2026):** - `internal/ports/output.go` - OutputWriter interface for pluggable formatting @@ -130,6 +131,7 @@ Credentials from `nylas auth config` are stored in the system keyring under serv - `internal/httputil/` - HTTP response helpers (WriteJSON, LimitedBody, DecodeJSON) - `internal/adapters/gpg/` - GPG/PGP email signing service (2026) - `internal/adapters/mime/` - RFC 3156 PGP/MIME message builder (2026) +- `internal/chat/` - AI chat interface with local agent support (2026) **Full inventory:** `docs/ARCHITECTURE.md` @@ -195,6 +197,7 @@ Credentials from `nylas auth config` are stored in the system keyring under serv | `make ci` | Quick quality checks (no integration) | | `make build` | Build binary | | `nylas air` | Start Air web UI (localhost:7365) | +| `nylas chat` | Start AI chat interface (localhost:7367) | **Available targets:** Run `make help` or `make` to see all available commands diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index cd19c5c..46edb52 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -6,6 +6,7 @@ import ( "os" "github.com/nylas/cli/internal/air" + "github.com/nylas/cli/internal/chat" "github.com/nylas/cli/internal/cli" "github.com/nylas/cli/internal/cli/admin" "github.com/nylas/cli/internal/cli/ai" @@ -54,6 +55,7 @@ func main() { rootCmd.AddCommand(cli.NewTUICmd()) rootCmd.AddCommand(ui.NewUICmd()) rootCmd.AddCommand(air.NewAirCmd()) + rootCmd.AddCommand(chat.NewChatCmd()) rootCmd.AddCommand(update.NewUpdateCmd()) if err := cli.Execute(); err != nil { diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0ed072d..ca41811 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -430,6 +430,64 @@ make test-air-integration # Run Air integration tests only --- +## Chat (`nylas chat`) - AI Chat Interface + +Launch **Nylas Chat** - a web-based AI chat interface that can access your email, calendar, and contacts: + +```bash +nylas chat # Start with auto-detected agent (default port 7367) +nylas chat --agent claude # Use specific agent (claude, codex, ollama) +nylas chat --agent ollama --model llama2 # Use Ollama with specific model +nylas chat --port 8080 # Custom port +nylas chat --no-browser # Don't auto-open browser +``` + +**Features:** +- **Local AI agents:** Uses Claude, Codex, or Ollama installed on your system +- **Email & Calendar access:** AI can read emails, check calendar, manage contacts +- **Conversation history:** Persistent chat sessions stored locally +- **Agent switching:** Change agents without restarting +- **Web interface:** Clean, modern chat UI + +**Supported Agents:** +| Agent | Description | Auto-detected | +|-------|-------------|---------------| +| Claude | Anthropic's Claude (via `claude` CLI) | ✅ | +| Codex | OpenAI Codex | ✅ | +| Ollama | Local LLM runner (customizable models) | ✅ | + +**Agent Detection:** +The CLI automatically detects installed agents on your system. Use `--agent` to override the default selection. + +**Conversation Storage:** +- Location: `~/.config/nylas/chat/conversations/` +- Format: JSON files per conversation +- Persistent across sessions + +**Security:** +- Runs on localhost only (not accessible externally) +- All data stored locally on your machine +- Agent communication happens through local processes + +**URL:** `http://localhost:7367` (default) + +**Examples:** +```bash +# Quick start with best available agent +nylas chat + +# Force use of Claude +nylas chat --agent claude + +# Use Ollama with Mistral model +nylas chat --agent ollama --model mistral + +# Run on different port +nylas chat --port 9000 --no-browser +``` + +--- + ## MCP (Model Context Protocol) Enable AI assistants (Claude Desktop, Cursor, Windsurf, VS Code) to interact with your email and calendar. diff --git a/internal/chat/agent.go b/internal/chat/agent.go new file mode 100644 index 0000000..59bbaad --- /dev/null +++ b/internal/chat/agent.go @@ -0,0 +1,182 @@ +// Package chat provides an AI chat interface using locally installed CLI agents. +package chat + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +// AgentType represents a supported AI agent. +type AgentType string + +const ( + AgentClaude AgentType = "claude" + AgentCodex AgentType = "codex" + AgentOllama AgentType = "ollama" +) + +// Agent represents a detected AI agent on the system. +type Agent struct { + Type AgentType `json:"type"` + Path string `json:"path"` + Model string `json:"model,omitempty"` // for ollama: model name + Version string `json:"version,omitempty"` // detected version +} + +// DetectAgents scans the system for installed AI agents. +// It checks for claude, codex, and ollama in $PATH. +func DetectAgents() []Agent { + var agents []Agent + + checks := []struct { + name AgentType + binary string + versionArgs []string + }{ + {AgentClaude, "claude", []string{"--version"}}, + {AgentCodex, "codex", []string{"--version"}}, + {AgentOllama, "ollama", []string{"--version"}}, + } + + for _, check := range checks { + path, err := exec.LookPath(check.binary) + if err != nil { + continue + } + + agent := Agent{ + Type: check.name, + Path: path, + } + + // Try to get version + if len(check.versionArgs) > 0 { + out, err := exec.Command(path, check.versionArgs...).Output() + if err == nil { + agent.Version = strings.TrimSpace(string(out)) + } + } + + // Default model for ollama + if check.name == AgentOllama { + agent.Model = "mistral" + } + + agents = append(agents, agent) + } + + return agents +} + +// FindAgent returns the first agent matching the given type, or nil. +func FindAgent(agents []Agent, agentType AgentType) *Agent { + for i := range agents { + if agents[i].Type == agentType { + return &agents[i] + } + } + return nil +} + +// Run executes the agent with the given prompt and returns the response. +// Each agent type has a different invocation pattern: +// - claude: claude -p --output-format text "prompt" +// - codex: codex exec "prompt" +// - ollama: echo "prompt" | ollama run +func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { + switch a.Type { + case AgentClaude: + return a.runClaude(ctx, prompt) + case AgentCodex: + return a.runCodex(ctx, prompt) + case AgentOllama: + return a.runOllama(ctx, prompt) + default: + return "", fmt.Errorf("unsupported agent type: %s", a.Type) + } +} + +// cleanEnv returns the current environment with nesting-detection vars removed +// so agent subprocesses don't refuse to start inside our server process. +func cleanEnv() []string { + skip := map[string]bool{ + "CLAUDECODE": true, + "CLAUDE_CODE": true, + "CODEX_SANDBOX": true, + "CODEX_ENV": true, + "INSIDE_CODEX": true, + } + + var env []string + for _, e := range os.Environ() { + key, _, _ := strings.Cut(e, "=") + if !skip[key] { + env = append(env, e) + } + } + return env +} + +func (a *Agent) runClaude(ctx context.Context, prompt string) (string, error) { + cmd := exec.CommandContext(ctx, a.Path, "-p", "--output-format", "text", prompt) + cmd.Env = cleanEnv() + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("claude error: %w: %s", err, stderr.String()) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func (a *Agent) runCodex(ctx context.Context, prompt string) (string, error) { + cmd := exec.CommandContext(ctx, a.Path, "exec", prompt) + cmd.Env = cleanEnv() + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("codex error: %w: %s", err, stderr.String()) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func (a *Agent) runOllama(ctx context.Context, prompt string) (string, error) { + model := a.Model + if model == "" { + model = "mistral" + } + + cmd := exec.CommandContext(ctx, a.Path, "run", model) + cmd.Env = cleanEnv() + cmd.Stdin = strings.NewReader(prompt) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("ollama error: %w: %s", err, stderr.String()) + } + + return strings.TrimSpace(stdout.String()), nil +} + +// String returns a human-readable description of the agent. +func (a *Agent) String() string { + s := string(a.Type) + if a.Model != "" { + s += " (" + a.Model + ")" + } + if a.Version != "" { + s += " " + a.Version + } + return s +} diff --git a/internal/chat/agent_test.go b/internal/chat/agent_test.go new file mode 100644 index 0000000..74ce3d5 --- /dev/null +++ b/internal/chat/agent_test.go @@ -0,0 +1,257 @@ +package chat + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindAgent(t *testing.T) { + agents := []Agent{ + {Type: AgentClaude, Path: "/usr/bin/claude", Version: "1.0.0"}, + {Type: AgentOllama, Path: "/usr/local/bin/ollama", Model: "mistral"}, + {Type: AgentCodex, Path: "/opt/codex", Version: "2.1.0"}, + } + + tests := []struct { + name string + agentType AgentType + wantFound bool + validate func(t *testing.T, agent *Agent) + }{ + { + name: "finds claude agent", + agentType: AgentClaude, + wantFound: true, + validate: func(t *testing.T, agent *Agent) { + assert.Equal(t, AgentClaude, agent.Type) + assert.Equal(t, "/usr/bin/claude", agent.Path) + assert.Equal(t, "1.0.0", agent.Version) + }, + }, + { + name: "finds ollama agent", + agentType: AgentOllama, + wantFound: true, + validate: func(t *testing.T, agent *Agent) { + assert.Equal(t, AgentOllama, agent.Type) + assert.Equal(t, "/usr/local/bin/ollama", agent.Path) + assert.Equal(t, "mistral", agent.Model) + }, + }, + { + name: "finds codex agent", + agentType: AgentCodex, + wantFound: true, + validate: func(t *testing.T, agent *Agent) { + assert.Equal(t, AgentCodex, agent.Type) + assert.Equal(t, "/opt/codex", agent.Path) + assert.Equal(t, "2.1.0", agent.Version) + }, + }, + { + name: "returns nil for non-existent agent", + agentType: "nonexistent", + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := FindAgent(agents, tt.agentType) + + if tt.wantFound { + require.NotNil(t, agent, "agent should be found") + if tt.validate != nil { + tt.validate(t, agent) + } + } else { + assert.Nil(t, agent, "agent should not be found") + } + }) + } +} + +func TestFindAgent_EmptyList(t *testing.T) { + agents := []Agent{} + agent := FindAgent(agents, AgentClaude) + assert.Nil(t, agent, "should return nil for empty agent list") +} + +func TestFindAgent_FirstMatch(t *testing.T) { + // Test that it returns the first matching agent + agents := []Agent{ + {Type: AgentClaude, Path: "/usr/bin/claude", Version: "1.0"}, + {Type: AgentClaude, Path: "/usr/local/bin/claude", Version: "2.0"}, + {Type: AgentOllama, Path: "/usr/bin/ollama"}, + } + + agent := FindAgent(agents, AgentClaude) + require.NotNil(t, agent) + assert.Equal(t, "/usr/bin/claude", agent.Path, "should return first matching agent") + assert.Equal(t, "1.0", agent.Version) +} + +func TestAgent_String(t *testing.T) { + tests := []struct { + name string + agent Agent + expected string + }{ + { + name: "claude with version", + agent: Agent{ + Type: AgentClaude, + Path: "/usr/bin/claude", + Version: "1.0.0", + }, + expected: "claude 1.0.0", + }, + { + name: "ollama with model and version", + agent: Agent{ + Type: AgentOllama, + Path: "/usr/bin/ollama", + Model: "mistral", + Version: "0.1.0", + }, + expected: "ollama (mistral) 0.1.0", + }, + { + name: "ollama with model only", + agent: Agent{ + Type: AgentOllama, + Path: "/usr/bin/ollama", + Model: "llama2", + }, + expected: "ollama (llama2)", + }, + { + name: "codex without version or model", + agent: Agent{ + Type: AgentCodex, + Path: "/opt/codex", + }, + expected: "codex", + }, + { + name: "agent with version but no model", + agent: Agent{ + Type: AgentClaude, + Path: "/usr/bin/claude", + Version: "2.5.1", + }, + expected: "claude 2.5.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.agent.String() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestAgent_String_AllAgentTypes(t *testing.T) { + agentTypes := []AgentType{AgentClaude, AgentCodex, AgentOllama} + + for _, agentType := range agentTypes { + t.Run(string(agentType), func(t *testing.T) { + agent := Agent{Type: agentType, Path: "/usr/bin/test"} + str := agent.String() + assert.Contains(t, str, string(agentType), "string representation should contain agent type") + }) + } +} + +func TestAgentType_Constants(t *testing.T) { + t.Run("agent type values", func(t *testing.T) { + assert.Equal(t, AgentType("claude"), AgentClaude) + assert.Equal(t, AgentType("codex"), AgentCodex) + assert.Equal(t, AgentType("ollama"), AgentOllama) + }) + + t.Run("agent types are unique", func(t *testing.T) { + types := map[AgentType]bool{ + AgentClaude: true, + AgentCodex: true, + AgentOllama: true, + } + assert.Equal(t, 3, len(types), "all agent types should be unique") + }) +} + +func TestDetectAgents_Structure(t *testing.T) { + // This test validates the structure without requiring actual binaries + // We can't test the actual detection without mocking exec.LookPath + t.Run("returns slice of agents", func(t *testing.T) { + agents := DetectAgents() + assert.NotNil(t, agents, "should return non-nil slice") + // May be empty if no agents are installed + }) + + t.Run("detected agents have required fields", func(t *testing.T) { + agents := DetectAgents() + for _, agent := range agents { + assert.NotEmpty(t, agent.Type, "agent should have a type") + assert.NotEmpty(t, agent.Path, "agent should have a path") + // Version and Model are optional + } + }) + + t.Run("ollama agents have default model", func(t *testing.T) { + agents := DetectAgents() + for _, agent := range agents { + if agent.Type == AgentOllama { + assert.Equal(t, "mistral", agent.Model, "ollama should have default model 'mistral'") + } + } + }) +} + +func TestAgent_Fields(t *testing.T) { + t.Run("agent with all fields", func(t *testing.T) { + agent := Agent{ + Type: AgentClaude, + Path: "/usr/bin/claude", + Model: "claude-3", + Version: "3.0.0", + } + + assert.Equal(t, AgentClaude, agent.Type) + assert.Equal(t, "/usr/bin/claude", agent.Path) + assert.Equal(t, "claude-3", agent.Model) + assert.Equal(t, "3.0.0", agent.Version) + }) + + t.Run("agent with minimal fields", func(t *testing.T) { + agent := Agent{ + Type: AgentCodex, + Path: "/opt/codex", + } + + assert.Equal(t, AgentCodex, agent.Type) + assert.Equal(t, "/opt/codex", agent.Path) + assert.Empty(t, agent.Model) + assert.Empty(t, agent.Version) + }) +} + +func TestFindAgent_PointerSafety(t *testing.T) { + agents := []Agent{ + {Type: AgentClaude, Path: "/usr/bin/claude"}, + } + + agent1 := FindAgent(agents, AgentClaude) + agent2 := FindAgent(agents, AgentClaude) + + // Both should point to the same agent in the slice + require.NotNil(t, agent1) + require.NotNil(t, agent2) + + // Modifying via pointer should affect the original + agent1.Version = "modified" + assert.Equal(t, "modified", agents[0].Version, "pointer should reference original agent") +} diff --git a/internal/chat/chat.go b/internal/chat/chat.go new file mode 100644 index 0000000..5c54c63 --- /dev/null +++ b/internal/chat/chat.go @@ -0,0 +1,118 @@ +package chat + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + browserpkg "github.com/nylas/cli/internal/adapters/browser" + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/cli/common" +) + +// NewChatCmd creates the chat command. +func NewChatCmd() *cobra.Command { + var ( + port int + noBrowser bool + agentName string + model string + ) + + cmd := &cobra.Command{ + Use: "chat", + Short: "Chat with AI using your email and calendar", + Long: `Launch an AI chat interface that can access your Nylas email, calendar, and contacts. + +Uses locally installed AI agents (Claude, Codex, or Ollama) to answer questions +and perform actions on your behalf through a web-based chat interface.`, + Example: ` # Launch chat (auto-detects best agent) + nylas chat + + # Use a specific agent + nylas chat --agent claude + + # Use Ollama with a specific model + nylas chat --agent ollama --model llama2 + + # Launch on custom port without browser + nylas chat --port 8080 --no-browser`, + RunE: func(cmd *cobra.Command, args []string) error { + // Detect available agents + agents := DetectAgents() + if len(agents) == 0 { + return fmt.Errorf("no AI agents found; install claude, codex, or ollama") + } + + // Select agent + var agent *Agent + if agentName != "" { + agent = FindAgent(agents, AgentType(agentName)) + if agent == nil { + return fmt.Errorf("agent %q not found; available: %v", agentName, agentNames(agents)) + } + } else { + agent = &agents[0] // use first detected + } + + // Override model for ollama + if model != "" && agent.Type == AgentOllama { + agent.Model = model + } + + // Set up Nylas client + nylasClient, err := common.GetNylasClient() + if err != nil { + return err + } + + grantID, err := common.GetGrantID(args) + if err != nil { + return err + } + + // Set up conversation storage + chatDir := filepath.Join(config.DefaultConfigDir(), "chat", "conversations") + memory, err := NewMemoryStore(chatDir) + if err != nil { + return fmt.Errorf("initialize chat storage: %w", err) + } + + addr := fmt.Sprintf("localhost:%d", port) + url := fmt.Sprintf("http://%s", addr) + + fmt.Printf("Starting Nylas Chat at %s\n", url) + fmt.Printf("Agent: %s\n", agent) + fmt.Println("Press Ctrl+C to stop") + fmt.Println() + + if !noBrowser { + b := browserpkg.NewDefaultBrowser() + if err := b.Open(url); err != nil { + fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err) + fmt.Printf("Open %s manually\n", url) + } + } + + server := NewServer(addr, agent, agents, nylasClient, grantID, memory) + return server.Start() + }, + } + + cmd.Flags().IntVarP(&port, "port", "p", 7367, "Port to run the server on") + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically") + cmd.Flags().StringVar(&agentName, "agent", "", "AI agent to use (claude, codex, ollama)") + cmd.Flags().StringVar(&model, "model", "", "Model name for ollama agent") + + return cmd +} + +func agentNames(agents []Agent) []string { + names := make([]string, len(agents)) + for i, a := range agents { + names[i] = string(a.Type) + } + return names +} diff --git a/internal/chat/context.go b/internal/chat/context.go new file mode 100644 index 0000000..aaa062c --- /dev/null +++ b/internal/chat/context.go @@ -0,0 +1,161 @@ +package chat + +import ( + "context" + "strings" +) + +const ( + // compactionThreshold is the number of user+assistant messages before compaction. + compactionThreshold = 30 + + // compactionWindow is the number of recent messages to keep after compaction. + compactionWindow = 15 +) + +// ContextBuilder constructs prompts with conversation context and manages compaction. +type ContextBuilder struct { + agent *Agent + memory *MemoryStore + grantID string +} + +// NewContextBuilder creates a new ContextBuilder. +func NewContextBuilder(agent *Agent, memory *MemoryStore, grantID string) *ContextBuilder { + return &ContextBuilder{ + agent: agent, + memory: memory, + grantID: grantID, + } +} + +// BuildPrompt constructs the full prompt for the agent including: +// 1. System prompt (identity + tools + instructions) +// 2. Conversation summary (if compacted) +// 3. Recent messages (within context window) +// 4. Latest user message +func (c *ContextBuilder) BuildPrompt(conv *Conversation, newMessage string) string { + var sb strings.Builder + + // System prompt + sb.WriteString(BuildSystemPrompt(c.grantID, c.agent.Type)) + sb.WriteString("\n---\n\n") + + // Include conversation summary if available + if conv.Summary != "" { + sb.WriteString("## Previous Conversation Summary\n\n") + sb.WriteString(conv.Summary) + sb.WriteString("\n\n---\n\n") + } + + // Include recent messages + sb.WriteString("## Conversation\n\n") + for _, msg := range conv.Messages { + switch msg.Role { + case "user": + sb.WriteString("User: ") + sb.WriteString(msg.Content) + sb.WriteString("\n\n") + case "assistant": + sb.WriteString("Assistant: ") + sb.WriteString(msg.Content) + sb.WriteString("\n\n") + case "tool_call": + sb.WriteString(toolCallPrefix + " ") + sb.WriteString(msg.Content) + sb.WriteString("\n\n") + case "tool_result": + sb.WriteString(toolResultPrefix + " ") + sb.WriteString(msg.Content) + sb.WriteString("\n\n") + } + } + + // Add new user message + sb.WriteString("User: ") + sb.WriteString(newMessage) + sb.WriteString("\n\nAssistant: ") + + return sb.String() +} + +// NeedsCompaction checks if the conversation should be compacted. +// Returns true when there are more than compactionThreshold user+assistant messages. +func (c *ContextBuilder) NeedsCompaction(conv *Conversation) bool { + count := 0 + for _, msg := range conv.Messages { + if msg.Role == "user" || msg.Role == "assistant" { + count++ + } + } + return count > compactionThreshold +} + +// Compact summarizes older messages and trims the conversation. +// It keeps the most recent compactionWindow messages and summarizes the rest. +func (c *ContextBuilder) Compact(ctx context.Context, conv *Conversation) error { + if !c.NeedsCompaction(conv) { + return nil + } + + // Find the split point: keep last compactionWindow user+assistant messages + splitIdx := c.findSplitIndex(conv) + if splitIdx <= 0 { + return nil + } + + // Build the older messages into text for summarization + var older strings.Builder + for _, msg := range conv.Messages[:splitIdx] { + if msg.Role == "user" || msg.Role == "assistant" { + older.WriteString(msg.Role + ": " + msg.Content + "\n") + } + } + + // Ask the agent to summarize + prompt := "Summarize this conversation so far in 3-4 sentences. " + + "Preserve key facts, names, email IDs, dates, and any commitments made.\n\n" + + older.String() + + summary, err := c.agent.Run(ctx, prompt) + if err != nil { + return err + } + + // Merge with existing summary if present + if conv.Summary != "" { + summary = conv.Summary + "\n\n" + summary + } + + // Update memory store with summary and trim messages + return c.memory.UpdateSummary(conv.ID, summary, splitIdx) +} + +// findSplitIndex finds the index to split at, keeping the last compactionWindow +// user+assistant messages intact. +func (c *ContextBuilder) findSplitIndex(conv *Conversation) int { + count := 0 + for _, msg := range conv.Messages { + if msg.Role == "user" || msg.Role == "assistant" { + count++ + } + } + + keep := compactionWindow + if count <= keep { + return 0 + } + + // Walk backwards to find where to split + seen := 0 + for i := len(conv.Messages) - 1; i >= 0; i-- { + if conv.Messages[i].Role == "user" || conv.Messages[i].Role == "assistant" { + seen++ + } + if seen >= keep { + return i + } + } + + return 0 +} diff --git a/internal/chat/context_test.go b/internal/chat/context_test.go new file mode 100644 index 0000000..8dd590b --- /dev/null +++ b/internal/chat/context_test.go @@ -0,0 +1,426 @@ +package chat + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +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) + + tests := []struct { + name string + setupConv func(t *testing.T) *Conversation + newMessage string + validate func(t *testing.T, prompt string) + }{ + { + name: "empty conversation", + setupConv: func(t *testing.T) *Conversation { + return &Conversation{ + ID: "conv_test", + Messages: []Message{}, + } + }, + newMessage: "Hello", + validate: func(t *testing.T, prompt string) { + assert.Contains(t, prompt, "You are a helpful email and calendar assistant") + assert.Contains(t, prompt, "Available tools:") + assert.Contains(t, prompt, "User: Hello") + assert.Contains(t, prompt, "Assistant:") + assert.NotContains(t, prompt, "Previous Conversation Summary") + }, + }, + { + name: "conversation with messages", + setupConv: func(t *testing.T) *Conversation { + return &Conversation{ + ID: "conv_test", + Messages: []Message{ + {Role: "user", Content: "What's the weather?", Timestamp: time.Now()}, + {Role: "assistant", Content: "I can help with that.", Timestamp: time.Now()}, + }, + } + }, + newMessage: "Thanks", + validate: func(t *testing.T, prompt string) { + assert.Contains(t, prompt, "User: What's the weather?") + assert.Contains(t, prompt, "Assistant: I can help with that.") + assert.Contains(t, prompt, "User: Thanks") + assert.Contains(t, prompt, "Assistant:") + }, + }, + { + name: "conversation with summary", + setupConv: func(t *testing.T) *Conversation { + return &Conversation{ + ID: "conv_test", + Summary: "The user asked about emails and I helped them search for budget-related messages.", + Messages: []Message{ + {Role: "user", Content: "Show me recent emails", Timestamp: time.Now()}, + }, + } + }, + newMessage: "What about calendar?", + validate: func(t *testing.T, prompt string) { + assert.Contains(t, prompt, "Previous Conversation Summary") + assert.Contains(t, prompt, "budget-related messages") + assert.Contains(t, prompt, "User: Show me recent emails") + assert.Contains(t, prompt, "User: What about calendar?") + }, + }, + { + name: "conversation with tool calls and results", + setupConv: func(t *testing.T) *Conversation { + return &Conversation{ + ID: "conv_test", + Messages: []Message{ + {Role: "user", Content: "List my emails", Timestamp: time.Now()}, + {Role: "tool_call", Content: `{"name":"list_emails","args":{}}`, Name: "list_emails", Timestamp: time.Now()}, + {Role: "tool_result", Content: `{"name":"list_emails","data":[]}`, Name: "list_emails", Timestamp: time.Now()}, + {Role: "assistant", Content: "You have no emails.", Timestamp: time.Now()}, + }, + } + }, + newMessage: "Thanks", + validate: func(t *testing.T, prompt string) { + assert.Contains(t, prompt, "TOOL_CALL:") + assert.Contains(t, prompt, "TOOL_RESULT:") + assert.Contains(t, prompt, "list_emails") + assert.Contains(t, prompt, "You have no emails.") + }, + }, + { + name: "includes grant ID in system prompt", + setupConv: func(t *testing.T) *Conversation { + return &Conversation{ID: "conv_test", Messages: []Message{}} + }, + newMessage: "Test", + validate: func(t *testing.T, prompt string) { + assert.Contains(t, prompt, grantID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conv := tt.setupConv(t) + prompt := builder.BuildPrompt(conv, tt.newMessage) + + assert.NotEmpty(t, prompt) + if tt.validate != nil { + tt.validate(t, prompt) + } + }) + } +} + +func TestContextBuilder_NeedsCompaction(t *testing.T) { + agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"} + store := setupMemoryStore(t) + builder := NewContextBuilder(agent, store, "test-grant") + + tests := []struct { + name string + setupConv func(t *testing.T) *Conversation + wantCompact bool + description string + }{ + { + name: "empty conversation", + setupConv: func(t *testing.T) *Conversation { + return &Conversation{ + ID: "conv_test", + Messages: []Message{}, + } + }, + wantCompact: false, + description: "no messages should not need compaction", + }, + { + name: "below threshold", + setupConv: func(t *testing.T) *Conversation { + messages := make([]Message, 20) // 10 user + 10 assistant = 20 total + for i := 0; i < 20; i++ { + role := "user" + if i%2 == 1 { + role = "assistant" + } + messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()} + } + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantCompact: false, + description: "20 user+assistant messages should not need compaction (threshold is 30)", + }, + { + name: "exactly at threshold", + setupConv: func(t *testing.T) *Conversation { + messages := make([]Message, compactionThreshold) + for i := 0; i < compactionThreshold; i++ { + role := "user" + if i%2 == 1 { + role = "assistant" + } + messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()} + } + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantCompact: false, + description: "exactly at threshold should not need compaction", + }, + { + name: "above threshold", + setupConv: func(t *testing.T) *Conversation { + messages := make([]Message, compactionThreshold+2) + for i := 0; i < compactionThreshold+2; i++ { + role := "user" + if i%2 == 1 { + role = "assistant" + } + messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()} + } + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantCompact: true, + description: "above threshold should need compaction", + }, + { + name: "many messages with tool calls", + setupConv: func(t *testing.T) *Conversation { + messages := make([]Message, 100) + userAssistantCount := 0 + for i := 0; i < 100; i++ { + switch i % 4 { + case 0: + messages[i] = Message{Role: "user", Content: "Message", Timestamp: time.Now()} + userAssistantCount++ + case 1: + messages[i] = Message{Role: "tool_call", Content: "{}", Timestamp: time.Now()} + case 2: + messages[i] = Message{Role: "tool_result", Content: "{}", Timestamp: time.Now()} + case 3: + messages[i] = Message{Role: "assistant", Content: "Message", Timestamp: time.Now()} + userAssistantCount++ + } + } + // With this pattern, we have 50 user+assistant messages + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantCompact: true, + description: "50 user+assistant messages (with tool messages) should need compaction", + }, + { + name: "only tool messages", + setupConv: func(t *testing.T) *Conversation { + messages := make([]Message, 50) + for i := 0; i < 50; i++ { + role := "tool_call" + if i%2 == 1 { + role = "tool_result" + } + messages[i] = Message{Role: role, Content: "{}", Timestamp: time.Now()} + } + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantCompact: false, + description: "only tool messages should not trigger compaction", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conv := tt.setupConv(t) + result := builder.NeedsCompaction(conv) + assert.Equal(t, tt.wantCompact, result, tt.description) + }) + } +} + +func TestContextBuilder_findSplitIndex(t *testing.T) { + agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"} + store := setupMemoryStore(t) + builder := NewContextBuilder(agent, store, "test-grant") + + tests := []struct { + name string + setupConv func(t *testing.T) *Conversation + wantIndex int + description string + }{ + { + name: "empty conversation", + setupConv: func(t *testing.T) *Conversation { + return &Conversation{ID: "conv_test", Messages: []Message{}} + }, + wantIndex: 0, + description: "empty conversation should return 0", + }, + { + name: "fewer messages than window", + setupConv: func(t *testing.T) *Conversation { + messages := make([]Message, 10) + for i := 0; i < 10; i++ { + role := "user" + if i%2 == 1 { + role = "assistant" + } + messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()} + } + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantIndex: 0, + description: "fewer than compactionWindow messages should return 0", + }, + { + name: "exactly compactionWindow messages", + setupConv: func(t *testing.T) *Conversation { + messages := make([]Message, compactionWindow) + for i := 0; i < compactionWindow; i++ { + role := "user" + if i%2 == 1 { + role = "assistant" + } + messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()} + } + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantIndex: 0, + description: "exactly compactionWindow messages should return 0", + }, + { + name: "more than compactionWindow messages", + setupConv: func(t *testing.T) *Conversation { + // Create 40 user+assistant messages + messages := make([]Message, 40) + for i := 0; i < 40; i++ { + role := "user" + if i%2 == 1 { + role = "assistant" + } + messages[i] = Message{Role: role, Content: "Message " + string(rune('A'+i)), Timestamp: time.Now()} + } + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantIndex: 40 - compactionWindow, // Should keep last 15 messages + description: "should split to keep last compactionWindow messages", + }, + { + name: "messages with tool calls interspersed", + setupConv: func(t *testing.T) *Conversation { + // Create pattern: user, tool_call, tool_result, assistant (repeat) + messages := make([]Message, 60) + for i := 0; i < 60; i++ { + switch i % 4 { + case 0: + messages[i] = Message{Role: "user", Content: "U" + string(rune('A'+i/4)), Timestamp: time.Now()} + case 1: + messages[i] = Message{Role: "tool_call", Content: "{}", Timestamp: time.Now()} + case 2: + messages[i] = Message{Role: "tool_result", Content: "{}", Timestamp: time.Now()} + case 3: + messages[i] = Message{Role: "assistant", Content: "A" + string(rune('A'+i/4)), Timestamp: time.Now()} + } + } + // We have 30 user+assistant messages (15 pairs) + // Want to keep last compactionWindow (15), so split at index of first message to keep + return &Conversation{ID: "conv_test", Messages: messages} + }, + wantIndex: 0, // 30 messages total, keep last 15, so split at first of last 15 + description: "should find correct split with tool messages", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conv := tt.setupConv(t) + index := builder.findSplitIndex(conv) + + switch tt.name { + case "more than compactionWindow messages": + assert.Equal(t, 25, index, tt.description) + kept := conv.Messages[index:] + count := 0 + for _, msg := range kept { + if msg.Role == "user" || msg.Role == "assistant" { + count++ + } + } + assert.Equal(t, compactionWindow, count, "should keep exactly compactionWindow messages") + case "messages with tool calls interspersed": + kept := conv.Messages[index:] + count := 0 + for _, msg := range kept { + if msg.Role == "user" || msg.Role == "assistant" { + count++ + } + } + assert.GreaterOrEqual(t, count, compactionWindow-1, "should keep at least compactionWindow-1 messages") + default: + assert.Equal(t, tt.wantIndex, index, tt.description) + } + }) + } +} + +func TestContextBuilder_BuildPrompt_Structure(t *testing.T) { + agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"} + store := setupMemoryStore(t) + builder := NewContextBuilder(agent, store, "test-grant") + + conv := &Conversation{ + ID: "conv_test", + Summary: "Previous discussion about emails", + Messages: []Message{ + {Role: "user", Content: "Hello", Timestamp: time.Now()}, + {Role: "assistant", Content: "Hi!", Timestamp: time.Now()}, + }, + } + + prompt := builder.BuildPrompt(conv, "New message") + + // Split prompt into sections + sections := strings.Split(prompt, "---") + + t.Run("has correct number of sections", func(t *testing.T) { + assert.GreaterOrEqual(t, len(sections), 2, "should have at least 2 sections separated by ---") + }) + + t.Run("system prompt comes first", func(t *testing.T) { + assert.Contains(t, sections[0], "You are a helpful email and calendar assistant") + assert.Contains(t, sections[0], "Available tools:") + }) + + t.Run("summary comes after system prompt", func(t *testing.T) { + promptStr := prompt + summaryIdx := strings.Index(promptStr, "Previous Conversation Summary") + systemIdx := strings.Index(promptStr, "You are a helpful AI assistant") + assert.Greater(t, summaryIdx, systemIdx, "summary should come after system prompt") + }) + + t.Run("summary comes before conversation", func(t *testing.T) { + promptStr := prompt + // Look for the "## Conversation" header that comes after summary, not the one in system prompt + summaryIdx := strings.Index(promptStr, "Previous Conversation Summary") + // Find "## Conversation" that appears after the summary + conversationIdx := strings.Index(promptStr[summaryIdx:], "## Conversation") + if conversationIdx != -1 { + conversationIdx += summaryIdx // Adjust to absolute position + } + + assert.NotEqual(t, -1, summaryIdx, "summary should be present") + assert.NotEqual(t, -1, conversationIdx, "conversation section should be present after summary") + assert.Greater(t, conversationIdx, summaryIdx, "conversation should come after summary") + }) + + t.Run("ends with new message and Assistant:", func(t *testing.T) { + assert.True(t, strings.HasSuffix(prompt, "User: New message\n\nAssistant: "), + "prompt should end with new message and 'Assistant:'") + }) +} diff --git a/internal/chat/executor.go b/internal/chat/executor.go new file mode 100644 index 0000000..c28882f --- /dev/null +++ b/internal/chat/executor.go @@ -0,0 +1,387 @@ +package chat + +import ( + "context" + "fmt" + "time" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// ToolExecutor dispatches tool calls to the Nylas API. +type ToolExecutor struct { + client ports.NylasClient + grantID string +} + +// NewToolExecutor creates a new ToolExecutor. +func NewToolExecutor(client ports.NylasClient, grantID string) *ToolExecutor { + return &ToolExecutor{client: client, grantID: grantID} +} + +// Execute runs a tool call and returns the result. +func (e *ToolExecutor) Execute(ctx context.Context, call ToolCall) ToolResult { + switch call.Name { + case "list_emails": + return e.listEmails(ctx, call.Args) + case "read_email": + return e.readEmail(ctx, call.Args) + case "search_emails": + return e.searchEmails(ctx, call.Args) + case "send_email": + return e.sendEmail(ctx, call.Args) + case "list_events": + return e.listEvents(ctx, call.Args) + case "create_event": + return e.createEvent(ctx, call.Args) + case "list_contacts": + return e.listContacts(ctx, call.Args) + case "list_folders": + return e.listFolders(ctx) + default: + return ToolResult{Name: call.Name, Error: fmt.Sprintf("unknown tool: %s", call.Name)} + } +} + +func (e *ToolExecutor) listEmails(ctx context.Context, args map[string]any) ToolResult { + params := &domain.MessageQueryParams{Limit: 10} + + if v, ok := args["limit"]; ok { + if f, ok := v.(float64); ok { + params.Limit = int(f) + } + } + if v, ok := args["subject"]; ok { + if s, ok := v.(string); ok { + params.Subject = s + } + } + if v, ok := args["from"]; ok { + if s, ok := v.(string); ok { + params.From = s + } + } + if v, ok := args["unread"]; ok { + if b, ok := v.(bool); ok { + params.Unread = &b + } + } + + messages, err := e.client.GetMessagesWithParams(ctx, e.grantID, params) + if err != nil { + return ToolResult{Name: "list_emails", Error: err.Error()} + } + + // Return simplified message list + type emailSummary struct { + ID string `json:"id"` + Subject string `json:"subject"` + From string `json:"from"` + Date string `json:"date"` + Unread bool `json:"unread"` + Snippet string `json:"snippet"` + } + + var results []emailSummary + for _, m := range messages { + from := "" + if len(m.From) > 0 { + from = m.From[0].Email + if m.From[0].Name != "" { + from = m.From[0].Name + " <" + m.From[0].Email + ">" + } + } + results = append(results, emailSummary{ + ID: m.ID, + Subject: m.Subject, + From: from, + Date: m.Date.Format(time.RFC3339), + Unread: m.Unread, + Snippet: m.Snippet, + }) + } + + return ToolResult{Name: "list_emails", Data: results} +} + +func (e *ToolExecutor) readEmail(ctx context.Context, args map[string]any) ToolResult { + id, ok := args["id"].(string) + if !ok || id == "" { + return ToolResult{Name: "read_email", Error: "id parameter is required"} + } + + msg, err := e.client.GetMessage(ctx, e.grantID, id) + if err != nil { + return ToolResult{Name: "read_email", Error: err.Error()} + } + + type emailDetail struct { + ID string `json:"id"` + Subject string `json:"subject"` + From string `json:"from"` + To []string `json:"to"` + Date string `json:"date"` + Body string `json:"body"` + } + + from := "" + if len(msg.From) > 0 { + from = msg.From[0].Email + if msg.From[0].Name != "" { + from = msg.From[0].Name + " <" + msg.From[0].Email + ">" + } + } + + var to []string + for _, t := range msg.To { + to = append(to, t.Email) + } + + body := msg.Body + if len(body) > 5000 { + body = body[:5000] + "\n... [truncated]" + } + + return ToolResult{Name: "read_email", Data: emailDetail{ + ID: msg.ID, + Subject: msg.Subject, + From: from, + To: to, + Date: msg.Date.Format(time.RFC3339), + Body: body, + }} +} + +func (e *ToolExecutor) searchEmails(ctx context.Context, args map[string]any) ToolResult { + query, ok := args["query"].(string) + if !ok || query == "" { + return ToolResult{Name: "search_emails", Error: "query parameter is required"} + } + + params := &domain.MessageQueryParams{ + Limit: 10, + SearchQuery: query, + } + + if v, ok := args["limit"]; ok { + if f, ok := v.(float64); ok { + params.Limit = int(f) + } + } + + messages, err := e.client.GetMessagesWithParams(ctx, e.grantID, params) + if err != nil { + return ToolResult{Name: "search_emails", Error: err.Error()} + } + + type emailSummary struct { + ID string `json:"id"` + Subject string `json:"subject"` + From string `json:"from"` + Date string `json:"date"` + Snippet string `json:"snippet"` + } + + var results []emailSummary + for _, m := range messages { + from := "" + if len(m.From) > 0 { + from = m.From[0].Email + } + results = append(results, emailSummary{ + ID: m.ID, + Subject: m.Subject, + From: from, + Date: m.Date.Format(time.RFC3339), + Snippet: m.Snippet, + }) + } + + return ToolResult{Name: "search_emails", Data: results} +} + +func (e *ToolExecutor) sendEmail(ctx context.Context, args map[string]any) ToolResult { + to, _ := args["to"].(string) + subject, _ := args["subject"].(string) + body, _ := args["body"].(string) + + if to == "" || subject == "" || body == "" { + return ToolResult{Name: "send_email", Error: "to, subject, and body are required"} + } + + req := &domain.SendMessageRequest{ + To: []domain.EmailParticipant{{Email: to}}, + Subject: subject, + Body: body, + } + + msg, err := e.client.SendMessage(ctx, e.grantID, req) + if err != nil { + return ToolResult{Name: "send_email", Error: err.Error()} + } + + return ToolResult{Name: "send_email", Data: map[string]string{ + "id": msg.ID, + "status": "sent", + }} +} + +func (e *ToolExecutor) listEvents(ctx context.Context, args map[string]any) ToolResult { + calendarID := "primary" + if v, ok := args["calendar_id"].(string); ok && v != "" { + calendarID = v + } + + params := &domain.EventQueryParams{Limit: 10} + if v, ok := args["limit"]; ok { + if f, ok := v.(float64); ok { + params.Limit = int(f) + } + } + + events, err := e.client.GetEvents(ctx, e.grantID, calendarID, params) + if err != nil { + return ToolResult{Name: "list_events", Error: err.Error()} + } + + type eventSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Start string `json:"start"` + End string `json:"end"` + } + + var results []eventSummary + for _, ev := range events { + start := "" + end := "" + if ev.When.StartTime > 0 { + start = time.Unix(ev.When.StartTime, 0).Format(time.RFC3339) + } + if ev.When.EndTime > 0 { + end = time.Unix(ev.When.EndTime, 0).Format(time.RFC3339) + } + results = append(results, eventSummary{ + ID: ev.ID, + Title: ev.Title, + Start: start, + End: end, + }) + } + + return ToolResult{Name: "list_events", Data: results} +} + +func (e *ToolExecutor) createEvent(ctx context.Context, args map[string]any) ToolResult { + title, _ := args["title"].(string) + startStr, _ := args["start_time"].(string) + endStr, _ := args["end_time"].(string) + + if title == "" || startStr == "" || endStr == "" { + return ToolResult{Name: "create_event", Error: "title, start_time, and end_time are required"} + } + + startTime, err := time.Parse(time.RFC3339, startStr) + if err != nil { + return ToolResult{Name: "create_event", Error: fmt.Sprintf("invalid start_time: %v", err)} + } + endTime, err := time.Parse(time.RFC3339, endStr) + if err != nil { + return ToolResult{Name: "create_event", Error: fmt.Sprintf("invalid end_time: %v", err)} + } + + calendarID := "primary" + if v, ok := args["calendar_id"].(string); ok && v != "" { + calendarID = v + } + + desc, _ := args["description"].(string) + + req := &domain.CreateEventRequest{ + Title: title, + When: domain.EventWhen{ + StartTime: startTime.Unix(), + EndTime: endTime.Unix(), + }, + Description: desc, + } + + event, err := e.client.CreateEvent(ctx, e.grantID, calendarID, req) + if err != nil { + return ToolResult{Name: "create_event", Error: err.Error()} + } + + return ToolResult{Name: "create_event", Data: map[string]string{ + "id": event.ID, + "title": event.Title, + }} +} + +func (e *ToolExecutor) listContacts(ctx context.Context, args map[string]any) ToolResult { + params := &domain.ContactQueryParams{Limit: 10} + + if v, ok := args["limit"]; ok { + if f, ok := v.(float64); ok { + params.Limit = int(f) + } + } + if v, ok := args["query"].(string); ok && v != "" { + params.Email = v + } + + contacts, err := e.client.GetContacts(ctx, e.grantID, params) + if err != nil { + return ToolResult{Name: "list_contacts", Error: err.Error()} + } + + type contactSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + + var results []contactSummary + for _, c := range contacts { + email := "" + if len(c.Emails) > 0 { + email = c.Emails[0].Email + } + name := "" + if c.GivenName != "" { + name = c.GivenName + if c.Surname != "" { + name += " " + c.Surname + } + } + results = append(results, contactSummary{ + ID: c.ID, + Name: name, + Email: email, + }) + } + + return ToolResult{Name: "list_contacts", Data: results} +} + +func (e *ToolExecutor) listFolders(ctx context.Context) ToolResult { + folders, err := e.client.GetFolders(ctx, e.grantID) + if err != nil { + return ToolResult{Name: "list_folders", Error: err.Error()} + } + + type folderSummary struct { + ID string `json:"id"` + Name string `json:"name"` + } + + var results []folderSummary + for _, f := range folders { + results = append(results, folderSummary{ + ID: f.ID, + Name: f.Name, + }) + } + + return ToolResult{Name: "list_folders", Data: results} +} diff --git a/internal/chat/handlers.go b/internal/chat/handlers.go new file mode 100644 index 0000000..b0085b2 --- /dev/null +++ b/internal/chat/handlers.go @@ -0,0 +1,273 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/nylas/cli/internal/httputil" +) + +// chatRequest is the request body for POST /api/chat. +type chatRequest struct { + Message string `json:"message"` + ConversationID string `json:"conversation_id"` + Agent string `json:"agent,omitempty"` // optional: switch agent for this message +} + +// maxToolIterations is the maximum number of tool call rounds per message. +const maxToolIterations = 5 + +// handleChat processes a chat message via SSE streaming. +// POST /api/chat +func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req chatRequest + if err := httputil.DecodeJSON(w, r, &req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Message == "" { + http.Error(w, "message is required", http.StatusBadRequest) + return + } + + // Switch agent if requested + if req.Agent != "" { + if !s.SetAgent(AgentType(req.Agent)) { + http.Error(w, "unknown agent: "+req.Agent, http.StatusBadRequest) + return + } + } + + agent := s.ActiveAgent() + + // Load or create conversation + var conv *Conversation + var err error + + if req.ConversationID != "" { + conv, err = s.memory.Get(req.ConversationID) + if err != nil { + http.Error(w, "Conversation not found", http.StatusNotFound) + return + } + } else { + conv, err = s.memory.Create(string(agent.Type)) + if err != nil { + http.Error(w, "Failed to create conversation", http.StatusInternalServerError) + return + } + } + + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + // Save user message + _ = s.memory.AddMessage(conv.ID, Message{Role: "user", Content: req.Message}) + + // Reload conversation with the new message + conv, _ = s.memory.Get(conv.ID) + + // Check if compaction needed + if s.context.NeedsCompaction(conv) { + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + _ = s.context.Compact(ctx, conv) + cancel() + conv, _ = s.memory.Get(conv.ID) // reload after compaction + } + + // Send thinking event + sendSSE(w, flusher, "thinking", map[string]string{"agent": string(agent.Type)}) + + // Build prompt and run agent loop + ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) + defer cancel() + + prompt := s.context.BuildPrompt(conv, req.Message) + var finalResponse string + + for i := range maxToolIterations { + _ = i + + response, err := agent.Run(ctx, prompt) + if err != nil { + sendSSE(w, flusher, "error", map[string]string{"error": err.Error()}) + return + } + + // Parse tool calls from response + toolCalls, textResponse := ParseToolCalls(response) + + if len(toolCalls) == 0 { + // No tool calls - this is the final response + finalResponse = textResponse + break + } + + // Execute tool calls and collect results + for _, call := range toolCalls { + sendSSE(w, flusher, "tool_call", map[string]any{ + "name": call.Name, + "args": call.Args, + }) + + result := s.executor.Execute(ctx, call) + + sendSSE(w, flusher, "tool_result", map[string]any{ + "name": result.Name, + "data": result.Data, + "error": result.Error, + }) + + // Save tool interactions + callJSON, _ := json.Marshal(call) + _ = s.memory.AddMessage(conv.ID, Message{ + Role: "tool_call", + Name: call.Name, + Content: string(callJSON), + }) + + resultJSON, _ := json.Marshal(result) + _ = s.memory.AddMessage(conv.ID, Message{ + Role: "tool_result", + Name: result.Name, + Content: string(resultJSON), + }) + + // Append tool result to prompt (no trailing "Assistant:" yet) + prompt += "\n" + FormatToolResult(result) + } + + // Add single "Assistant:" after all tool results for next iteration + prompt += "\n\nNow provide your final answer to the user based on the tool results above.\n\nAssistant: " + } + + if finalResponse == "" { + finalResponse = "I wasn't able to generate a response. Please try again." + } + + // Save assistant response + _ = s.memory.AddMessage(conv.ID, Message{Role: "assistant", Content: finalResponse}) + + // Send the message + sendSSE(w, flusher, "message", map[string]string{"content": finalResponse}) + + // Auto-generate title after first exchange + if conv.Title == "New conversation" { + go s.generateTitle(conv.ID, req.Message, finalResponse) + } + + // Send done event + sendSSE(w, flusher, "done", map[string]string{ + "conversation_id": conv.ID, + "title": conv.Title, + }) +} + +// generateTitle asks the agent to generate a short title for the conversation. +func (s *Server) generateTitle(convID, userMsg, assistantMsg string) { + agent := s.ActiveAgent() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + prompt := fmt.Sprintf( + "Generate a very short title (3-6 words) for this conversation. "+ + "Reply with ONLY the title, nothing else.\n\nUser: %s\nAssistant: %s", + userMsg, assistantMsg, + ) + + title, err := agent.Run(ctx, prompt) + if err != nil || title == "" { + return + } + + // Clean up the title + if len(title) > 60 { + title = title[:60] + } + + _ = s.memory.UpdateTitle(convID, title) +} + +// handleConfig returns or updates agent configuration. +// GET /api/config — returns current agent and available agents +// PUT /api/config — switches the active agent +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.getConfig(w) + case http.MethodPut: + s.switchAgent(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) getConfig(w http.ResponseWriter) { + agent := s.ActiveAgent() + + available := make([]string, len(s.agents)) + for i, a := range s.agents { + available[i] = string(a.Type) + } + + httputil.WriteJSON(w, http.StatusOK, map[string]any{ + "agent": string(agent.Type), + "available": available, + }) +} + +func (s *Server) switchAgent(w http.ResponseWriter, r *http.Request) { + var req struct { + Agent string `json:"agent"` + } + if err := httputil.DecodeJSON(w, r, &req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Agent == "" { + http.Error(w, "agent is required", http.StatusBadRequest) + return + } + + if !s.SetAgent(AgentType(req.Agent)) { + http.Error(w, "unknown agent: "+req.Agent, http.StatusBadRequest) + return + } + + s.getConfig(w) +} + +// handleHealth returns server health status. +// GET /api/health +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// sendSSE writes a Server-Sent Event to the response. +func sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data any) { + jsonData, err := json.Marshal(data) + if err != nil { + return + } + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, jsonData) + flusher.Flush() +} diff --git a/internal/chat/handlers_conv.go b/internal/chat/handlers_conv.go new file mode 100644 index 0000000..1ab6053 --- /dev/null +++ b/internal/chat/handlers_conv.go @@ -0,0 +1,88 @@ +package chat + +import ( + "net/http" + "strings" + + "github.com/nylas/cli/internal/httputil" +) + +// handleConversations handles listing and creating conversations. +// GET /api/conversations — list all conversations +// POST /api/conversations — create new conversation +func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.listConversations(w, r) + case http.MethodPost: + s.createConversation(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleConversationByID handles operations on a specific conversation. +// GET /api/conversations/{id} — get conversation +// DELETE /api/conversations/{id} — delete conversation +func (s *Server) handleConversationByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/conversations/") + if id == "" { + http.Error(w, "conversation ID required", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + s.getConversation(w, id) + case http.MethodDelete: + s.deleteConversation(w, id) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) listConversations(w http.ResponseWriter, _ *http.Request) { + summaries, err := s.memory.List() + if err != nil { + http.Error(w, "Failed to list conversations", http.StatusInternalServerError) + return + } + + if summaries == nil { + summaries = []ConversationSummary{} + } + + httputil.WriteJSON(w, http.StatusOK, summaries) +} + +func (s *Server) createConversation(w http.ResponseWriter, _ *http.Request) { + conv, err := s.memory.Create(string(s.agent.Type)) + if err != nil { + http.Error(w, "Failed to create conversation", http.StatusInternalServerError) + return + } + + httputil.WriteJSON(w, http.StatusCreated, map[string]string{ + "id": conv.ID, + "title": conv.Title, + }) +} + +func (s *Server) getConversation(w http.ResponseWriter, id string) { + conv, err := s.memory.Get(id) + if err != nil { + http.Error(w, "Conversation not found", http.StatusNotFound) + return + } + + httputil.WriteJSON(w, http.StatusOK, conv) +} + +func (s *Server) deleteConversation(w http.ResponseWriter, id string) { + if err := s.memory.Delete(id); err != nil { + http.Error(w, "Failed to delete conversation", http.StatusNotFound) + return + } + + httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/internal/chat/memory.go b/internal/chat/memory.go new file mode 100644 index 0000000..c1fb0e5 --- /dev/null +++ b/internal/chat/memory.go @@ -0,0 +1,244 @@ +package chat + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Conversation represents a chat conversation with full message history. +type Conversation struct { + ID string `json:"id"` + Title string `json:"title"` + Agent string `json:"agent"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []Message `json:"messages"` + Summary string `json:"summary,omitempty"` + MsgCount int `json:"message_count"` + CompactedAt time.Time `json:"compacted_at,omitempty"` +} + +// Message represents a single message in a conversation. +type Message struct { + Role string `json:"role"` // user, assistant, tool_call, tool_result + Content string `json:"content"` + Name string `json:"name,omitempty"` // tool name for tool_call/tool_result + Timestamp time.Time `json:"timestamp"` +} + +// ConversationSummary is a lightweight view for listing conversations. +type ConversationSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Agent string `json:"agent"` + UpdatedAt time.Time `json:"updated_at"` + Preview string `json:"preview"` + MsgCount int `json:"message_count"` +} + +// MemoryStore manages conversation persistence as JSON files on disk. +type MemoryStore struct { + basePath string + mu sync.RWMutex +} + +// NewMemoryStore creates a new MemoryStore at the given base path. +func NewMemoryStore(basePath string) (*MemoryStore, error) { + if err := os.MkdirAll(basePath, 0750); err != nil { + return nil, fmt.Errorf("create memory directory: %w", err) + } + return &MemoryStore{basePath: basePath}, nil +} + +// List returns summaries of all conversations, sorted by most recent first. +func (m *MemoryStore) List() ([]ConversationSummary, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + entries, err := os.ReadDir(m.basePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read conversations dir: %w", err) + } + + var summaries []ConversationSummary + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + conv, err := m.readFile(filepath.Join(m.basePath, entry.Name())) + if err != nil { + continue // skip corrupt files + } + + preview := "" + for i := len(conv.Messages) - 1; i >= 0; i-- { + if conv.Messages[i].Role == "assistant" || conv.Messages[i].Role == "user" { + preview = conv.Messages[i].Content + if len(preview) > 100 { + preview = preview[:100] + "..." + } + break + } + } + + summaries = append(summaries, ConversationSummary{ + ID: conv.ID, + Title: conv.Title, + Agent: conv.Agent, + UpdatedAt: conv.UpdatedAt, + Preview: preview, + MsgCount: conv.MsgCount, + }) + } + + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt) + }) + + return summaries, nil +} + +// Get retrieves a conversation by ID. +func (m *MemoryStore) Get(id string) (*Conversation, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.readFile(m.filePath(id)) +} + +// Create creates a new conversation for the given agent. +func (m *MemoryStore) Create(agent string) (*Conversation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id, err := generateID() + if err != nil { + return nil, fmt.Errorf("generate conversation ID: %w", err) + } + + conv := &Conversation{ + ID: id, + Title: "New conversation", + Agent: agent, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + Messages: []Message{}, + } + + if err := m.writeFile(conv); err != nil { + return nil, err + } + return conv, nil +} + +// Delete removes a conversation by ID. +func (m *MemoryStore) Delete(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + path := m.filePath(id) + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("conversation not found: %s", id) + } + return fmt.Errorf("delete conversation: %w", err) + } + return nil +} + +// AddMessage appends a message to a conversation. +func (m *MemoryStore) AddMessage(id string, msg Message) error { + m.mu.Lock() + defer m.mu.Unlock() + + conv, err := m.readFile(m.filePath(id)) + if err != nil { + return err + } + + msg.Timestamp = time.Now().UTC() + conv.Messages = append(conv.Messages, msg) + conv.MsgCount = len(conv.Messages) + conv.UpdatedAt = time.Now().UTC() + + return m.writeFile(conv) +} + +// UpdateTitle updates the title of a conversation. +func (m *MemoryStore) UpdateTitle(id string, title string) error { + m.mu.Lock() + defer m.mu.Unlock() + + conv, err := m.readFile(m.filePath(id)) + if err != nil { + return err + } + + conv.Title = title + conv.UpdatedAt = time.Now().UTC() + return m.writeFile(conv) +} + +// UpdateSummary updates the summary and trims compacted messages. +func (m *MemoryStore) UpdateSummary(id string, summary string, keepFrom int) error { + m.mu.Lock() + defer m.mu.Unlock() + + conv, err := m.readFile(m.filePath(id)) + if err != nil { + return err + } + + conv.Summary = summary + conv.CompactedAt = time.Now().UTC() + if keepFrom > 0 && keepFrom < len(conv.Messages) { + conv.Messages = conv.Messages[keepFrom:] + } + conv.UpdatedAt = time.Now().UTC() + + return m.writeFile(conv) +} + +func (m *MemoryStore) filePath(id string) string { + return filepath.Join(m.basePath, id+".json") +} + +func (m *MemoryStore) readFile(path string) (*Conversation, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read conversation: %w", err) + } + + var conv Conversation + if err := json.Unmarshal(data, &conv); err != nil { + return nil, fmt.Errorf("parse conversation: %w", err) + } + return &conv, nil +} + +func (m *MemoryStore) writeFile(conv *Conversation) error { + data, err := json.MarshalIndent(conv, "", " ") + if err != nil { + return fmt.Errorf("marshal conversation: %w", err) + } + return os.WriteFile(m.filePath(conv.ID), data, 0600) +} + +func generateID() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "conv_" + hex.EncodeToString(b), nil +} diff --git a/internal/chat/memory_test.go b/internal/chat/memory_test.go new file mode 100644 index 0000000..407fa67 --- /dev/null +++ b/internal/chat/memory_test.go @@ -0,0 +1,513 @@ +package chat + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMemoryStore(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + wantError bool + }{ + { + name: "creates directory if not exists", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "conversations") + }, + wantError: false, + }, + { + name: "works with existing directory", + setup: func(t *testing.T) string { + dir := filepath.Join(t.TempDir(), "existing") + require.NoError(t, os.MkdirAll(dir, 0750)) + return dir + }, + wantError: false, + }, + { + name: "creates nested directories", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "level1", "level2", "conversations") + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + store, err := NewMemoryStore(path) + + if tt.wantError { + assert.Error(t, err) + assert.Nil(t, store) + } else { + require.NoError(t, err) + require.NotNil(t, store) + assert.Equal(t, path, store.basePath) + + // Verify directory was created + info, err := os.Stat(path) + require.NoError(t, err) + assert.True(t, info.IsDir()) + } + }) + } +} + +func TestMemoryStore_Create(t *testing.T) { + store := setupMemoryStore(t) + + tests := []struct { + name string + agent string + wantError bool + }{ + { + name: "creates conversation with claude agent", + agent: "claude", + wantError: false, + }, + { + name: "creates conversation with ollama agent", + agent: "ollama", + wantError: false, + }, + { + name: "creates conversation with empty agent", + agent: "", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conv, err := store.Create(tt.agent) + + if tt.wantError { + assert.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, conv) + + // Verify fields + assert.NotEmpty(t, conv.ID, "ID should be generated") + assert.True(t, len(conv.ID) > len("conv_"), "ID should have conv_ prefix and random suffix") + assert.Equal(t, "New conversation", conv.Title) + assert.Equal(t, tt.agent, conv.Agent) + assert.False(t, conv.CreatedAt.IsZero(), "CreatedAt should be set") + assert.False(t, conv.UpdatedAt.IsZero(), "UpdatedAt should be set") + assert.Empty(t, conv.Messages, "Messages should be empty") + assert.Equal(t, 0, conv.MsgCount) + assert.Empty(t, conv.Summary) + + // Verify file was created + filePath := filepath.Join(store.basePath, conv.ID+".json") + _, err := os.Stat(filePath) + require.NoError(t, err, "conversation file should exist") + } + }) + } +} + +func TestMemoryStore_Get(t *testing.T) { + store := setupMemoryStore(t) + + // Create a conversation + created, err := store.Create("claude") + require.NoError(t, err) + + tests := []struct { + name string + id string + wantError bool + }{ + { + name: "retrieves existing conversation", + id: created.ID, + wantError: false, + }, + { + name: "returns error for non-existent conversation", + id: "conv_nonexistent", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conv, err := store.Get(tt.id) + + if tt.wantError { + assert.Error(t, err) + assert.Nil(t, conv) + } else { + require.NoError(t, err) + require.NotNil(t, conv) + assert.Equal(t, created.ID, conv.ID) + assert.Equal(t, created.Title, conv.Title) + assert.Equal(t, created.Agent, conv.Agent) + } + }) + } +} + +func TestMemoryStore_List(t *testing.T) { + store := setupMemoryStore(t) + + t.Run("returns empty list when no conversations", func(t *testing.T) { + summaries, err := store.List() + require.NoError(t, err) + assert.Empty(t, summaries) + }) + + t.Run("lists conversations sorted by updated_at", func(t *testing.T) { + // Create multiple conversations with different update times + conv1, err := store.Create("claude") + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + conv2, err := store.Create("ollama") + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + conv3, err := store.Create("codex") + require.NoError(t, err) + + // Update conv1 to make it most recent + time.Sleep(10 * time.Millisecond) + err = store.AddMessage(conv1.ID, Message{ + Role: "user", + Content: "Latest message", + }) + require.NoError(t, err) + + // List and verify order (most recent first) + summaries, err := store.List() + require.NoError(t, err) + require.Len(t, summaries, 3) + + // conv1 should be first (most recently updated) + assert.Equal(t, conv1.ID, summaries[0].ID) + assert.Equal(t, "claude", summaries[0].Agent) + + // conv3 should be second + assert.Equal(t, conv3.ID, summaries[1].ID) + + // conv2 should be last + assert.Equal(t, conv2.ID, summaries[2].ID) + }) + + t.Run("includes preview from last user or assistant message", func(t *testing.T) { + store := setupMemoryStore(t) + conv, err := store.Create("claude") + require.NoError(t, err) + + // Add messages + err = store.AddMessage(conv.ID, Message{Role: "user", Content: "Hello"}) + require.NoError(t, err) + err = store.AddMessage(conv.ID, Message{Role: "assistant", Content: "Hi there! How can I help you today?"}) + require.NoError(t, err) + + summaries, err := store.List() + require.NoError(t, err) + require.Len(t, summaries, 1) + + assert.Equal(t, "Hi there! How can I help you today?", summaries[0].Preview) + assert.Equal(t, 2, summaries[0].MsgCount) + }) + + t.Run("truncates long preview to 100 characters", func(t *testing.T) { + store := setupMemoryStore(t) + conv, err := store.Create("claude") + require.NoError(t, err) + + longMessage := "This is a very long message that should be truncated because it exceeds the maximum preview length of one hundred characters" + err = store.AddMessage(conv.ID, Message{Role: "user", Content: longMessage}) + require.NoError(t, err) + + summaries, err := store.List() + require.NoError(t, err) + require.Len(t, summaries, 1) + + assert.Equal(t, 103, len(summaries[0].Preview)) // 100 chars + "..." + assert.True(t, len(summaries[0].Preview) <= 103) + assert.Contains(t, summaries[0].Preview, "...") + }) +} + +func TestMemoryStore_Delete(t *testing.T) { + store := setupMemoryStore(t) + + // Create a conversation + conv, err := store.Create("claude") + require.NoError(t, err) + + t.Run("deletes existing conversation", func(t *testing.T) { + err := store.Delete(conv.ID) + require.NoError(t, err) + + // Verify file was deleted + filePath := filepath.Join(store.basePath, conv.ID+".json") + _, err = os.Stat(filePath) + assert.True(t, os.IsNotExist(err), "file should not exist after deletion") + + // Verify Get returns error + _, err = store.Get(conv.ID) + assert.Error(t, err) + }) + + t.Run("returns error for non-existent conversation", func(t *testing.T) { + err := store.Delete("conv_nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "conversation not found") + }) +} + +func TestMemoryStore_AddMessage(t *testing.T) { + store := setupMemoryStore(t) + conv, err := store.Create("claude") + require.NoError(t, err) + + tests := []struct { + name string + message Message + }{ + { + name: "adds user message", + message: Message{ + Role: "user", + Content: "Hello, assistant!", + }, + }, + { + name: "adds assistant message", + message: Message{ + Role: "assistant", + Content: "Hello! How can I help?", + }, + }, + { + name: "adds tool_call message", + message: Message{ + Role: "tool_call", + Content: `{"name":"list_emails","args":{}}`, + Name: "list_emails", + }, + }, + { + name: "adds tool_result message", + message: Message{ + Role: "tool_result", + Content: `{"name":"list_emails","data":[]}`, + Name: "list_emails", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeUpdate := conv.UpdatedAt + time.Sleep(10 * time.Millisecond) // Ensure time difference + + err := store.AddMessage(conv.ID, tt.message) + require.NoError(t, err) + + // Retrieve and verify + updated, err := store.Get(conv.ID) + require.NoError(t, err) + + assert.Greater(t, len(updated.Messages), 0) + lastMsg := updated.Messages[len(updated.Messages)-1] + assert.Equal(t, tt.message.Role, lastMsg.Role) + assert.Equal(t, tt.message.Content, lastMsg.Content) + assert.Equal(t, tt.message.Name, lastMsg.Name) + assert.False(t, lastMsg.Timestamp.IsZero(), "timestamp should be set") + assert.Equal(t, len(updated.Messages), updated.MsgCount) + assert.True(t, updated.UpdatedAt.After(beforeUpdate), "UpdatedAt should be updated") + }) + } + + t.Run("returns error for non-existent conversation", func(t *testing.T) { + err := store.AddMessage("conv_nonexistent", Message{ + Role: "user", + Content: "Test", + }) + assert.Error(t, err) + }) +} + +func TestMemoryStore_UpdateTitle(t *testing.T) { + store := setupMemoryStore(t) + conv, err := store.Create("claude") + require.NoError(t, err) + + tests := []struct { + name string + newTitle string + }{ + { + name: "updates title", + newTitle: "Email Discussion", + }, + { + name: "updates to empty title", + newTitle: "", + }, + { + name: "updates to long title", + newTitle: "A very long title that should still be stored correctly without any truncation issues", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeUpdate := conv.UpdatedAt + time.Sleep(10 * time.Millisecond) + + err := store.UpdateTitle(conv.ID, tt.newTitle) + require.NoError(t, err) + + // Verify update + updated, err := store.Get(conv.ID) + require.NoError(t, err) + assert.Equal(t, tt.newTitle, updated.Title) + assert.True(t, updated.UpdatedAt.After(beforeUpdate), "UpdatedAt should be updated") + }) + } + + t.Run("returns error for non-existent conversation", func(t *testing.T) { + err := store.UpdateTitle("conv_nonexistent", "New Title") + assert.Error(t, err) + }) +} + +func TestMemoryStore_UpdateSummary(t *testing.T) { + store := setupMemoryStore(t) + conv, err := store.Create("claude") + require.NoError(t, err) + + // Add some messages + for i := 0; i < 10; i++ { + err := store.AddMessage(conv.ID, Message{ + Role: "user", + Content: "Message " + string(rune('A'+i)), + }) + require.NoError(t, err) + } + + tests := []struct { + name string + summary string + keepFrom int + }{ + { + name: "updates summary without trimming", + summary: "First summary", + keepFrom: 0, + }, + { + name: "updates summary and trims messages", + summary: "Summary after trimming", + keepFrom: 5, + }, + { + name: "updates summary and keeps all messages", + summary: "Keep all", + keepFrom: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := setupMemoryStore(t) + conv, err := store.Create("claude") + require.NoError(t, err) + + // Add messages + for i := 0; i < 10; i++ { + err := store.AddMessage(conv.ID, Message{ + Role: "user", + Content: "Message " + string(rune('A'+i)), + }) + require.NoError(t, err) + } + + beforeUpdate, err := store.Get(conv.ID) + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + err = store.UpdateSummary(conv.ID, tt.summary, tt.keepFrom) + require.NoError(t, err) + + // Verify update + updated, err := store.Get(conv.ID) + require.NoError(t, err) + assert.Equal(t, tt.summary, updated.Summary) + assert.False(t, updated.CompactedAt.IsZero(), "CompactedAt should be set") + assert.True(t, updated.UpdatedAt.After(beforeUpdate.UpdatedAt), "UpdatedAt should be updated") + + // Verify message trimming + if tt.keepFrom > 0 && tt.keepFrom < 10 { + assert.Equal(t, 10-tt.keepFrom, len(updated.Messages), "messages should be trimmed") + // First remaining message should be at keepFrom index + assert.Equal(t, "Message "+string(rune('A'+tt.keepFrom)), updated.Messages[0].Content) + } else if tt.keepFrom <= 0 { + assert.Equal(t, 10, len(updated.Messages), "all messages should be kept") + } + }) + } + + t.Run("returns error for non-existent conversation", func(t *testing.T) { + err := store.UpdateSummary("conv_nonexistent", "Summary", 0) + assert.Error(t, err) + }) +} + +func TestMemoryStore_ConcurrentAccess(t *testing.T) { + store := setupMemoryStore(t) + conv, err := store.Create("claude") + require.NoError(t, err) + + // Test concurrent message additions + t.Run("concurrent message additions", func(t *testing.T) { + done := make(chan bool) + for i := 0; i < 5; i++ { + go func(index int) { + err := store.AddMessage(conv.ID, Message{ + Role: "user", + Content: "Concurrent message", + }) + assert.NoError(t, err) + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 5; i++ { + <-done + } + + // Verify all messages were added + updated, err := store.Get(conv.ID) + require.NoError(t, err) + assert.Equal(t, 5, len(updated.Messages)) + }) +} + +// Helper function to set up a memory store for testing +func setupMemoryStore(t *testing.T) *MemoryStore { + t.Helper() + dir := t.TempDir() + store, err := NewMemoryStore(dir) + require.NoError(t, err) + return store +} diff --git a/internal/chat/prompt.go b/internal/chat/prompt.go new file mode 100644 index 0000000..576c4a8 --- /dev/null +++ b/internal/chat/prompt.go @@ -0,0 +1,46 @@ +package chat + +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 { + 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") + + 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") + 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") + sb.WriteString("1. When you output a TOOL_CALL, output ONLY the TOOL_CALL line and nothing else.\n") + sb.WriteString(" Do NOT include any other text before or after the TOOL_CALL line.\n") + sb.WriteString("2. After you receive tool results (TOOL_RESULT), use the data to answer the user.\n") + sb.WriteString(" Summarize and format the results clearly — do NOT make another tool call\n") + sb.WriteString(" unless you need different or additional data.\n") + 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("\n") + + // Context instructions + sb.WriteString("## Conversation Context\n\n") + sb.WriteString("You have access to a conversation history. If a summary of earlier messages\n") + sb.WriteString("is provided, use it to maintain continuity. Reference previous topics naturally.\n\n") + + // Formatting instructions + sb.WriteString("## Response Format\n\n") + 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") + sb.WriteString("- Keep responses concise but informative\n") + sb.WriteString("- If an error occurs, explain it clearly and suggest alternatives\n") + + return sb.String() +} diff --git a/internal/chat/server.go b/internal/chat/server.go new file mode 100644 index 0000000..6f022a8 --- /dev/null +++ b/internal/chat/server.go @@ -0,0 +1,127 @@ +package chat + +import ( + "embed" + "html/template" + "io/fs" + "net/http" + "sync" + "time" + + "github.com/nylas/cli/internal/ports" +) + +//go:embed static/css/*.css static/js/*.js +var staticFiles embed.FS + +//go:embed templates/*.gohtml +var templateFiles embed.FS + +// Server is the chat web UI HTTP server. +type Server struct { + addr string + agent *Agent + agents []Agent + agentMu sync.RWMutex // protects agent switching + nylas ports.NylasClient + grantID string + memory *MemoryStore + executor *ToolExecutor + context *ContextBuilder + session *ActiveSession + tmpl *template.Template +} + +// ActiveAgent returns the current agent (thread-safe). +func (s *Server) ActiveAgent() *Agent { + s.agentMu.RLock() + defer s.agentMu.RUnlock() + return s.agent +} + +// SetAgent switches the active agent by type. Returns false if not found. +func (s *Server) SetAgent(agentType AgentType) bool { + agent := FindAgent(s.agents, agentType) + if agent == nil { + return false + } + s.agentMu.Lock() + s.agent = agent + s.context = NewContextBuilder(agent, s.memory, s.grantID) + 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) + + tmpl, _ := template.New("").ParseFS(templateFiles, "templates/*.gohtml") + + return &Server{ + addr: addr, + agent: agent, + agents: agents, + nylas: nylas, + grantID: grantID, + memory: memory, + executor: executor, + context: ctx, + session: NewActiveSession(), + tmpl: tmpl, + } +} + +// Start starts the HTTP server and blocks until interrupted. +func (s *Server) Start() error { + mux := http.NewServeMux() + + // API routes + mux.HandleFunc("/api/chat", s.handleChat) + mux.HandleFunc("/api/conversations", s.handleConversations) + mux.HandleFunc("/api/conversations/", s.handleConversationByID) + mux.HandleFunc("/api/config", s.handleConfig) + mux.HandleFunc("/api/health", s.handleHealth) + + // Static files + staticFS, _ := fs.Sub(staticFiles, "static") + fileServer := http.FileServer(http.FS(staticFS)) + + noCacheHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + fileServer.ServeHTTP(w, r) + }) + + mux.Handle("/css/", noCacheHandler) + mux.Handle("/js/", noCacheHandler) + + // Index page + mux.HandleFunc("/", s.handleIndex) + + server := &http.Server{ + Addr: s.addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 120 * time.Second, // long for SSE streaming + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + return server.ListenAndServe() +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + if s.tmpl == nil { + http.Error(w, "templates not loaded", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = s.tmpl.ExecuteTemplate(w, "index.gohtml", nil) +} diff --git a/internal/chat/session.go b/internal/chat/session.go new file mode 100644 index 0000000..a2f8de3 --- /dev/null +++ b/internal/chat/session.go @@ -0,0 +1,28 @@ +package chat + +import "sync" + +// ActiveSession tracks the current conversation for a browser session. +type ActiveSession struct { + conversationID string + mu sync.RWMutex +} + +// NewActiveSession creates a new ActiveSession. +func NewActiveSession() *ActiveSession { + return &ActiveSession{} +} + +// Get returns the current conversation ID. +func (s *ActiveSession) Get() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.conversationID +} + +// Set updates the current conversation ID. +func (s *ActiveSession) Set(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.conversationID = id +} diff --git a/internal/chat/static/css/chat.css b/internal/chat/static/css/chat.css new file mode 100644 index 0000000..2a2ae69 --- /dev/null +++ b/internal/chat/static/css/chat.css @@ -0,0 +1,519 @@ +/* Nylas Chat — Modern UI */ +:root { + --bg-primary: #212121; + --bg-secondary: #171717; + --bg-sidebar: #171717; + --bg-input: #2f2f2f; + --bg-hover: #2f2f2f; + --bg-message-user: #303030; + --bg-message-assistant: transparent; + --bg-tool: rgba(255,255,255,0.04); + --text-primary: #ececec; + --text-secondary: #a0a0a0; + --text-muted: #666; + --border-color: rgba(255,255,255,0.08); + --accent: #8b5cf6; + --accent-hover: #a78bfa; + --accent-subtle: rgba(139,92,246,0.12); + --danger: #f87171; + --success: #34d399; + --radius: 12px; + --radius-sm: 8px; + --sidebar-width: 260px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --transition: 150ms ease; +} + +@media (prefers-color-scheme: light) { + :root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-sidebar: #f9fafb; + --bg-input: #ffffff; + --bg-hover: #f3f4f6; + --bg-message-user: #f3f4f6; + --bg-message-assistant: transparent; + --bg-tool: rgba(0,0,0,0.03); + --text-primary: #111827; + --text-secondary: #6b7280; + --text-muted: #9ca3af; + --border-color: rgba(0,0,0,0.08); + --accent: #7c3aed; + --accent-hover: #6d28d9; + --accent-subtle: rgba(124,58,237,0.08); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + } +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.app { + display: flex; + height: 100vh; +} + +/* Sidebar */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); +} + +.sidebar-header h2 { + font-size: 14px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.btn-new { + width: 30px; + height: 30px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); +} + +.btn-new:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.conversation-list { + flex: 1; + overflow-y: auto; + padding: 6px 8px; +} + +.conv-item { + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + margin-bottom: 1px; + display: flex; + justify-content: space-between; + align-items: flex-start; + transition: background var(--transition); +} + +.conv-item:hover { background: var(--bg-hover); } +.conv-item.active { background: var(--accent-subtle); } + +.conv-item-content { flex: 1; min-width: 0; } + +.conv-title { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.conv-preview { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; + line-height: 1.3; +} + +.conv-delete { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 13px; + padding: 2px 4px; + opacity: 0; + flex-shrink: 0; + transition: opacity var(--transition); + border-radius: 4px; +} + +.conv-item:hover .conv-delete { opacity: 0.6; } +.conv-delete:hover { opacity: 1 !important; color: var(--danger); } + +.sidebar-footer { + padding: 12px 14px; + border-top: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-footer.single-agent .agent-select { pointer-events: none; opacity: 0.5; } + +.agent-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; +} + +.agent-select { + flex: 1; + min-width: 0; + background: var(--bg-input); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 13px; + font-family: inherit; + cursor: pointer; + outline: none; + transition: border-color var(--transition); + appearance: auto; +} + +.agent-select:focus, +.agent-select:hover { border-color: var(--accent); } + +/* Main Chat Area */ +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-primary); +} + +.chat-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + border-bottom: 1px solid var(--border-color); +} + +.chat-header h3 { + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); +} + +.btn-toggle-sidebar { + display: none; + background: none; + border: none; + color: var(--text-secondary); + font-size: 18px; + cursor: pointer; + padding: 4px; + border-radius: 6px; + transition: background var(--transition); +} + +.btn-toggle-sidebar:hover { background: var(--bg-hover); } + +.messages { + flex: 1; + overflow-y: auto; + padding: 24px; + scroll-behavior: smooth; +} + +/* Welcome Screen */ +.welcome { + text-align: center; + padding: 80px 20px 40px; +} + +.welcome h2 { + margin-bottom: 8px; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.welcome p { + color: var(--text-secondary); + margin-bottom: 32px; + font-size: 15px; +} + +.suggestions { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +.suggestion { + padding: 10px 18px; + background: var(--bg-input); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: 100px; + cursor: pointer; + font-size: 13px; + font-family: inherit; + transition: all var(--transition); +} + +.suggestion:hover { + border-color: var(--accent); + background: var(--accent-subtle); +} + +/* Messages */ +.message { + display: flex; + margin-bottom: 20px; + max-width: 720px; + margin-left: auto; + margin-right: auto; + width: 100%; +} + +.message.user { justify-content: flex-end; } + +.message-content { + padding: 12px 16px; + border-radius: 18px; + font-size: 14px; + line-height: 1.6; + word-break: break-word; + max-width: 85%; +} + +.message.user .message-content { + background: var(--bg-message-user); + border-radius: 18px 18px 4px 18px; +} + +.message.assistant .message-content { + background: var(--bg-message-assistant); + padding-left: 0; + padding-right: 0; + max-width: 100%; +} + +.message-content p { margin-bottom: 10px; } +.message-content p:last-child { margin-bottom: 0; } + +.message-content code { + background: rgba(139,92,246,0.1); + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +.message-content pre { + background: rgba(0,0,0,0.4); + padding: 14px 16px; + border-radius: var(--radius-sm); + overflow-x: auto; + margin: 10px 0; + font-size: 13px; + line-height: 1.5; +} + +@media (prefers-color-scheme: light) { + .message-content pre { background: #f3f4f6; } +} + +.message-content pre code { + background: none; + padding: 0; + font-size: inherit; +} + +.message-content ul, .message-content ol { + padding-left: 20px; + margin: 6px 0; +} + +.message-content li { margin-bottom: 4px; } + +.message-content a { + color: var(--accent); + text-decoration: none; +} + +.message-content a:hover { text-decoration: underline; } + +/* Tool Call Indicators */ +.tool-indicator { + font-size: 12px; + color: var(--text-secondary); + padding: 8px 14px; + background: var(--bg-tool); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + margin-bottom: 10px; + max-width: 720px; + margin-left: auto; + margin-right: auto; + cursor: pointer; + transition: background var(--transition); +} + +.tool-indicator:hover { background: var(--bg-hover); } +.tool-indicator .tool-name { font-weight: 600; color: var(--accent); } + +.tool-details { + display: none; + font-size: 12px; + padding: 10px; + background: rgba(0,0,0,0.15); + border-radius: 6px; + margin-top: 6px; + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; + font-family: 'SF Mono', 'Fira Code', monospace; + line-height: 1.5; +} + +@media (prefers-color-scheme: light) { + .tool-details { background: rgba(0,0,0,0.04); } +} + +.tool-indicator.expanded .tool-details { display: block; } + +/* Thinking Indicator */ +.thinking { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-muted); + font-size: 13px; + padding: 12px 0; + max-width: 720px; + margin: 0 auto; +} + +.thinking-dots span { + display: inline-block; + width: 5px; + height: 5px; + background: var(--accent); + border-radius: 50%; + animation: pulse 1.4s infinite ease-in-out; +} + +.thinking-dots span:nth-child(2) { animation-delay: 0.2s; } +.thinking-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes pulse { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1); } +} + +/* Input Area */ +.input-area { + padding: 16px 24px 24px; + background: var(--bg-primary); +} + +.input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 6px 6px 6px 16px; + max-width: 720px; + margin: 0 auto; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.input-wrapper:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-subtle); +} + +#chat-input { + flex: 1; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 14px; + resize: none; + outline: none; + max-height: 120px; + padding: 8px 0; + font-family: inherit; + line-height: 1.5; +} + +#chat-input::placeholder { color: var(--text-muted); } + +.btn-send { + width: 34px; + height: 34px; + background: var(--accent); + border: none; + color: #fff; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background var(--transition), transform var(--transition); +} + +.btn-send:hover { + background: var(--accent-hover); + transform: scale(1.04); +} + +.btn-send:disabled { opacity: 0.3; cursor: not-allowed; transform: none; } + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.3); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.5); } + +/* Responsive */ +@media (max-width: 768px) { + .sidebar { + position: fixed; + left: -260px; + z-index: 10; + height: 100%; + transition: left 0.2s ease; + } + .sidebar.open { left: 0; } + .btn-toggle-sidebar { display: block; } + .message { max-width: 95%; } + .input-wrapper { max-width: 100%; } +} diff --git a/internal/chat/static/js/api.js b/internal/chat/static/js/api.js new file mode 100644 index 0000000..8246383 --- /dev/null +++ b/internal/chat/static/js/api.js @@ -0,0 +1,76 @@ +// api.js — Chat API client +const ChatAPI = { + async sendMessage(convId, message, onEvent) { + const body = { message }; + if (convId) body.conversation_id = convId; + + const resp = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) throw new Error(await resp.text()); + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + let currentEvent = ''; + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7); + } else if (line.startsWith('data: ') && currentEvent) { + try { + const data = JSON.parse(line.slice(6)); + onEvent(currentEvent, data); + } catch { /* skip malformed */ } + currentEvent = ''; + } + } + } + }, + + async listConversations() { + const resp = await fetch('/api/conversations'); + return resp.json(); + }, + + async createConversation() { + const resp = await fetch('/api/conversations', { method: 'POST' }); + return resp.json(); + }, + + async getConversation(id) { + const resp = await fetch('/api/conversations/' + id); + return resp.json(); + }, + + async deleteConversation(id) { + const resp = await fetch('/api/conversations/' + id, { method: 'DELETE' }); + return resp.json(); + }, + + async getConfig() { + const resp = await fetch('/api/config'); + return resp.json(); + }, + + async switchAgent(agent) { + const resp = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent }), + }); + if (!resp.ok) throw new Error(await resp.text()); + return resp.json(); + }, +}; diff --git a/internal/chat/static/js/chat.js b/internal/chat/static/js/chat.js new file mode 100644 index 0000000..573c31c --- /dev/null +++ b/internal/chat/static/js/chat.js @@ -0,0 +1,210 @@ +// chat.js — Main chat UI +const Chat = { + conversationId: null, + sending: false, + + init() { + const form = document.getElementById('chat-form'); + const input = document.getElementById('chat-input'); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + this.send(); + }); + + // Auto-resize textarea + input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 120) + 'px'; + }); + + // Enter to send, Shift+Enter for newline + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.send(); + } + }); + + // Suggestion buttons + document.querySelectorAll('.suggestion').forEach(btn => { + btn.addEventListener('click', () => { + input.value = btn.dataset.msg; + this.send(); + }); + }); + }, + + async send() { + const input = document.getElementById('chat-input'); + const message = input.value.trim(); + if (!message || this.sending) return; + + this.sending = true; + this.hideWelcome(); + this.appendMessage('user', message); + input.value = ''; + input.style.height = 'auto'; + document.getElementById('btn-send').disabled = true; + + const thinkingEl = this.showThinking(); + + try { + await ChatAPI.sendMessage(this.conversationId, message, (event, data) => { + switch (event) { + case 'thinking': + // Already showing indicator + break; + case 'tool_call': + this.appendToolCall(data); + break; + case 'tool_result': + this.appendToolResult(data); + break; + case 'message': + thinkingEl.remove(); + this.appendMessage('assistant', data.content); + break; + case 'error': + thinkingEl.remove(); + this.appendMessage('assistant', 'Error: ' + data.error); + break; + case 'done': + if (data.conversation_id) { + this.conversationId = data.conversation_id; + } + if (data.title && data.title !== 'New conversation') { + document.getElementById('chat-title').textContent = data.title; + } + Sidebar.activeId = this.conversationId; + Sidebar.refresh(); + break; + } + }); + } catch (err) { + thinkingEl.remove(); + this.appendMessage('assistant', 'Connection error: ' + err.message); + } + + this.sending = false; + document.getElementById('btn-send').disabled = false; + input.focus(); + }, + + loadConversation(conv) { + this.conversationId = conv.id; + const messages = document.getElementById('messages'); + messages.innerHTML = ''; + this.hideWelcome(); + + document.getElementById('chat-title').textContent = conv.title || 'New conversation'; + + for (const msg of conv.messages) { + if (msg.role === 'user' || msg.role === 'assistant') { + this.appendMessage(msg.role, msg.content); + } else if (msg.role === 'tool_call') { + try { + const call = JSON.parse(msg.content); + this.appendToolCall(call); + } catch { /* skip */ } + } else if (msg.role === 'tool_result') { + try { + const result = JSON.parse(msg.content); + this.appendToolResult(result); + } catch { /* skip */ } + } + } + + this.scrollToBottom(); + }, + + startNew(id) { + this.conversationId = id; + const messages = document.getElementById('messages'); + messages.innerHTML = ''; + document.getElementById('chat-title').textContent = 'New conversation'; + this.showWelcome(); + }, + + appendMessage(role, content) { + const messages = document.getElementById('messages'); + const div = document.createElement('div'); + div.className = 'message ' + role; + + const bubble = document.createElement('div'); + bubble.className = 'message-content'; + + if (role === 'assistant') { + bubble.innerHTML = Markdown.render(content); + } else { + bubble.textContent = content; + } + + div.appendChild(bubble); + messages.appendChild(div); + this.scrollToBottom(); + }, + + appendToolCall(data) { + const messages = document.getElementById('messages'); + const div = document.createElement('div'); + div.className = 'tool-indicator'; + div.innerHTML = '🔧 Calling ' + + this.escapeHtml(data.name) + '...' + + '
' + this.escapeHtml(JSON.stringify(data.args, null, 2)) + '
'; + div.addEventListener('click', () => div.classList.toggle('expanded')); + messages.appendChild(div); + this.scrollToBottom(); + }, + + appendToolResult(data) { + const messages = document.getElementById('messages'); + const div = document.createElement('div'); + div.className = 'tool-indicator'; + const label = data.error ? '❌ Error' : '✅ Result'; + const detail = data.error || JSON.stringify(data.data, null, 2); + div.innerHTML = label + ': ' + + this.escapeHtml(data.name) + '' + + '
' + this.escapeHtml(detail) + '
'; + div.addEventListener('click', () => div.classList.toggle('expanded')); + messages.appendChild(div); + this.scrollToBottom(); + }, + + showThinking() { + const messages = document.getElementById('messages'); + const div = document.createElement('div'); + div.className = 'thinking'; + div.innerHTML = '
Thinking...'; + messages.appendChild(div); + this.scrollToBottom(); + return div; + }, + + hideWelcome() { + const welcome = document.getElementById('welcome'); + if (welcome) welcome.style.display = 'none'; + }, + + showWelcome() { + const welcome = document.getElementById('welcome'); + if (welcome) welcome.style.display = ''; + }, + + scrollToBottom() { + const messages = document.getElementById('messages'); + messages.scrollTop = messages.scrollHeight; + }, + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}; + +// Initialize on DOM load +document.addEventListener('DOMContentLoaded', () => { + Chat.init(); + Sidebar.init(); +}); diff --git a/internal/chat/static/js/markdown.js b/internal/chat/static/js/markdown.js new file mode 100644 index 0000000..475720b --- /dev/null +++ b/internal/chat/static/js/markdown.js @@ -0,0 +1,51 @@ +// markdown.js — Lightweight markdown renderer +const Markdown = { + render(text) { + if (!text) return ''; + let html = this.escape(text); + + // Code blocks + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
'); + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + + // Italic + html = html.replace(/\*(.+?)\*/g, '$1'); + + // Headers + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + html = html.replace(/^# (.+)$/gm, '

$1

'); + + // Unordered lists + html = html.replace(/^[\s]*[-*] (.+)$/gm, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    '); + + // Ordered lists + html = html.replace(/^\d+\. (.+)$/gm, '
  • $1
  • '); + + // Links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Paragraphs + html = html.replace(/\n\n/g, '

    '); + html = '

    ' + html + '

    '; + html = html.replace(/

    \s*<(h[234]|ul|ol|pre|li)/g, '<$1'); + html = html.replace(/<\/(h[234]|ul|ol|pre|li)>\s*<\/p>/g, ''); + + // Line breaks + html = html.replace(/\n/g, '
    '); + + return html; + }, + + escape(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}; diff --git a/internal/chat/static/js/sidebar.js b/internal/chat/static/js/sidebar.js new file mode 100644 index 0000000..7079e07 --- /dev/null +++ b/internal/chat/static/js/sidebar.js @@ -0,0 +1,133 @@ +// sidebar.js — Conversation list management +const Sidebar = { + activeId: null, + currentAgent: null, + + async init() { + await this.refresh(); + document.getElementById('btn-new-chat').addEventListener('click', () => this.newChat()); + document.getElementById('btn-toggle-sidebar').addEventListener('click', () => this.toggle()); + + // Load agent info and populate dropdown + try { + const config = await ChatAPI.getConfig(); + this.currentAgent = config.agent; + this.populateAgentDropdown(config.agent, config.available); + } catch { /* ignore */ } + + // Agent switch handler + document.getElementById('agent-select').addEventListener('change', (e) => { + this.switchAgent(e.target.value); + }); + }, + + populateAgentDropdown(active, available) { + const select = document.getElementById('agent-select'); + select.innerHTML = ''; + + for (const agent of available) { + const option = document.createElement('option'); + option.value = agent; + option.textContent = agent.charAt(0).toUpperCase() + agent.slice(1); + if (agent === active) option.selected = true; + select.appendChild(option); + } + + // Hide dropdown if only one agent + select.parentElement.classList.toggle('single-agent', available.length <= 1); + }, + + async switchAgent(agent) { + if (agent === this.currentAgent) return; + try { + const config = await ChatAPI.switchAgent(agent); + this.currentAgent = config.agent; + this.populateAgentDropdown(config.agent, config.available); + } catch (err) { + // Revert dropdown on failure + const select = document.getElementById('agent-select'); + select.value = this.currentAgent; + } + }, + + async refresh() { + const conversations = await ChatAPI.listConversations(); + const list = document.getElementById('conversation-list'); + list.innerHTML = ''; + + for (const conv of conversations) { + const el = document.createElement('div'); + el.className = 'conv-item' + (conv.id === this.activeId ? ' active' : ''); + el.dataset.id = conv.id; + + const content = document.createElement('div'); + content.className = 'conv-item-content'; + + const title = document.createElement('div'); + title.className = 'conv-title'; + title.textContent = conv.title || 'New conversation'; + content.appendChild(title); + + if (conv.preview) { + const preview = document.createElement('div'); + preview.className = 'conv-preview'; + preview.textContent = conv.preview; + content.appendChild(preview); + } + + el.appendChild(content); + + const delBtn = document.createElement('button'); + delBtn.className = 'conv-delete'; + delBtn.textContent = '✕'; + delBtn.title = 'Delete conversation'; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.deleteConversation(conv.id); + }); + el.appendChild(delBtn); + + el.addEventListener('click', () => this.loadConversation(conv.id)); + list.appendChild(el); + } + }, + + async loadConversation(id) { + this.activeId = id; + const conv = await ChatAPI.getConversation(id); + Chat.loadConversation(conv); + this.highlightActive(); + this.closeMobile(); + }, + + async newChat() { + const conv = await ChatAPI.createConversation(); + this.activeId = conv.id; + Chat.startNew(conv.id); + await this.refresh(); + this.closeMobile(); + }, + + async deleteConversation(id) { + await ChatAPI.deleteConversation(id); + if (this.activeId === id) { + this.activeId = null; + Chat.startNew(null); + } + await this.refresh(); + }, + + highlightActive() { + document.querySelectorAll('.conv-item').forEach(el => { + el.classList.toggle('active', el.dataset.id === this.activeId); + }); + }, + + toggle() { + document.getElementById('sidebar').classList.toggle('open'); + }, + + closeMobile() { + document.getElementById('sidebar').classList.remove('open'); + } +}; diff --git a/internal/chat/templates/index.gohtml b/internal/chat/templates/index.gohtml new file mode 100644 index 0000000..5caafb2 --- /dev/null +++ b/internal/chat/templates/index.gohtml @@ -0,0 +1,65 @@ + + + + + + Nylas Chat + + + +

    + + +
    +
    + +

    New conversation

    +
    + +
    +
    +

    Welcome to Nylas Chat

    +

    Ask me about your emails, calendar events, or contacts.

    +
    + + + +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    + + + + + + + diff --git a/internal/chat/tools.go b/internal/chat/tools.go new file mode 100644 index 0000000..b2cc186 --- /dev/null +++ b/internal/chat/tools.go @@ -0,0 +1,166 @@ +package chat + +import ( + "encoding/json" + "strings" +) + +// Tool represents a tool available to the AI agent. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters []ToolParameter `json:"parameters"` +} + +// ToolParameter describes a parameter for a tool. +type ToolParameter struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required"` +} + +// ToolCall represents a parsed tool call from agent output. +type ToolCall struct { + Name string `json:"name"` + Args map[string]any `json:"args"` +} + +// ToolResult represents the result of executing a tool call. +type ToolResult struct { + Name string `json:"name"` + Data any `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// toolCallPrefix is the marker agents use to invoke tools. +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 { + return []Tool{ + { + Name: "list_emails", + Description: "List recent emails from the user's inbox", + Parameters: []ToolParameter{ + {Name: "limit", Type: "number", Description: "Max emails to return (default 10)", Required: false}, + {Name: "subject", Type: "string", Description: "Filter by subject keyword", Required: false}, + {Name: "from", Type: "string", Description: "Filter by sender email", Required: false}, + {Name: "unread", Type: "boolean", Description: "Only show unread emails", Required: false}, + }, + }, + { + Name: "read_email", + Description: "Read a specific email by ID to see full body content", + Parameters: []ToolParameter{ + {Name: "id", Type: "string", Description: "The email/message ID", Required: true}, + }, + }, + { + Name: "search_emails", + Description: "Search emails by query string", + Parameters: []ToolParameter{ + {Name: "query", Type: "string", Description: "Search query (e.g. 'from:sarah budget')", Required: true}, + {Name: "limit", Type: "number", Description: "Max results (default 10)", Required: false}, + }, + }, + { + Name: "send_email", + Description: "Send a new email", + Parameters: []ToolParameter{ + {Name: "to", Type: "string", Description: "Recipient email address", Required: true}, + {Name: "subject", Type: "string", Description: "Email subject line", Required: true}, + {Name: "body", Type: "string", Description: "Email body (plain text)", Required: true}, + }, + }, + { + Name: "list_events", + Description: "List upcoming calendar events", + Parameters: []ToolParameter{ + {Name: "limit", Type: "number", Description: "Max events to return (default 10)", Required: false}, + {Name: "calendar_id", Type: "string", Description: "Calendar ID (default: primary)", Required: false}, + }, + }, + { + Name: "create_event", + Description: "Create a new calendar event", + Parameters: []ToolParameter{ + {Name: "title", Type: "string", Description: "Event title", Required: true}, + {Name: "start_time", Type: "string", Description: "Start time (RFC3339, e.g. 2026-02-12T14:00:00Z)", Required: true}, + {Name: "end_time", Type: "string", Description: "End time (RFC3339)", Required: true}, + {Name: "calendar_id", Type: "string", Description: "Calendar ID (default: primary)", Required: false}, + {Name: "description", Type: "string", Description: "Event description", Required: false}, + }, + }, + { + Name: "list_contacts", + Description: "List contacts from the address book", + Parameters: []ToolParameter{ + {Name: "limit", Type: "number", Description: "Max contacts to return (default 10)", Required: false}, + {Name: "query", Type: "string", Description: "Search by name or email", Required: false}, + }, + }, + { + Name: "list_folders", + Description: "List email folders/labels", + Parameters: []ToolParameter{}, + }, + } +} + +// ParseToolCalls extracts TOOL_CALL: lines from agent output. +// Returns parsed tool calls and the remaining text (non-tool-call content). +func ParseToolCalls(output string) ([]ToolCall, string) { + var calls []ToolCall + var textParts []string + + lines := strings.Split(output, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, toolCallPrefix) { + jsonStr := strings.TrimSpace(strings.TrimPrefix(trimmed, toolCallPrefix)) + var call ToolCall + if err := json.Unmarshal([]byte(jsonStr), &call); err == nil { + calls = append(calls, call) + continue + } + } + textParts = append(textParts, line) + } + + return calls, strings.TrimSpace(strings.Join(textParts, "\n")) +} + +// FormatToolResult formats a tool result for injection back into the prompt. +func FormatToolResult(result ToolResult) string { + data, err := json.Marshal(result) + if err != nil { + return toolResultPrefix + " " + `{"error":"failed to marshal result"}` + } + return toolResultPrefix + " " + string(data) +} + +// FormatToolsForPrompt generates the tool description section for the system prompt. +func FormatToolsForPrompt(tools []Tool) string { + var sb strings.Builder + sb.WriteString("Available tools:\n\n") + + for _, t := range tools { + sb.WriteString("- **" + t.Name + "**: " + t.Description + "\n") + if len(t.Parameters) > 0 { + sb.WriteString(" Parameters:\n") + for _, p := range t.Parameters { + req := "" + if p.Required { + req = " (required)" + } + sb.WriteString(" - " + p.Name + " (" + p.Type + "): " + p.Description + req + "\n") + } + } + } + + return sb.String() +} diff --git a/internal/chat/tools_test.go b/internal/chat/tools_test.go new file mode 100644 index 0000000..9ec4349 --- /dev/null +++ b/internal/chat/tools_test.go @@ -0,0 +1,354 @@ +package chat + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseToolCalls(t *testing.T) { + tests := []struct { + name string + output string + expectedCalls int + expectedText string + validateResult func(t *testing.T, calls []ToolCall, text string) + }{ + { + name: "no tool calls", + output: "Just a regular response\nWith multiple lines", + expectedCalls: 0, + expectedText: "Just a regular response\nWith multiple lines", + }, + { + name: "single tool call", + output: `Let me search for emails. +TOOL_CALL: {"name":"search_emails","args":{"query":"budget","limit":5}} +I'll search for budget-related emails.`, + expectedCalls: 1, + expectedText: "Let me search for emails.\nI'll search for budget-related emails.", + validateResult: func(t *testing.T, calls []ToolCall, text string) { + require.Len(t, calls, 1) + assert.Equal(t, "search_emails", calls[0].Name) + assert.Equal(t, "budget", calls[0].Args["query"]) + assert.Equal(t, float64(5), calls[0].Args["limit"]) + }, + }, + { + name: "multiple tool calls", + output: `I'll list your emails first. +TOOL_CALL: {"name":"list_emails","args":{"limit":10}} +Then I'll check your calendar. +TOOL_CALL: {"name":"list_events","args":{"limit":5}} +All done!`, + expectedCalls: 2, + expectedText: "I'll list your emails first.\nThen I'll check your calendar.\nAll done!", + validateResult: func(t *testing.T, calls []ToolCall, text string) { + require.Len(t, calls, 2) + assert.Equal(t, "list_emails", calls[0].Name) + assert.Equal(t, "list_events", calls[1].Name) + }, + }, + { + name: "malformed JSON - kept as text", + output: "TOOL_CALL: {invalid json}\nValid text here", + expectedCalls: 0, + expectedText: "TOOL_CALL: {invalid json}\nValid text here", + }, + { + name: "tool call with leading whitespace", + output: " TOOL_CALL: {\"name\":\"list_folders\",\"args\":{}}\nNext line", + expectedCalls: 1, + expectedText: "Next line", + validateResult: func(t *testing.T, calls []ToolCall, text string) { + require.Len(t, calls, 1) + assert.Equal(t, "list_folders", calls[0].Name) + }, + }, + { + name: "empty output", + output: "", + expectedCalls: 0, + expectedText: "", + }, + { + name: "only tool calls, no text", + output: "TOOL_CALL: {\"name\":\"list_contacts\",\"args\":{\"limit\":20}}", + expectedCalls: 1, + expectedText: "", + validateResult: func(t *testing.T, calls []ToolCall, text string) { + require.Len(t, calls, 1) + assert.Equal(t, "list_contacts", calls[0].Name) + }, + }, + { + name: "tool call with complex args", + output: `Sending email now. +TOOL_CALL: {"name":"send_email","args":{"to":"test@example.com","subject":"Test","body":"Hello\nWorld"}} +Email sent!`, + expectedCalls: 1, + expectedText: "Sending email now.\nEmail sent!", + validateResult: func(t *testing.T, calls []ToolCall, text string) { + require.Len(t, calls, 1) + assert.Equal(t, "send_email", calls[0].Name) + assert.Equal(t, "test@example.com", calls[0].Args["to"]) + assert.Equal(t, "Test", calls[0].Args["subject"]) + assert.Equal(t, "Hello\nWorld", calls[0].Args["body"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls, text := ParseToolCalls(tt.output) + assert.Len(t, calls, tt.expectedCalls, "unexpected number of tool calls") + assert.Equal(t, tt.expectedText, text, "unexpected remaining text") + + if tt.validateResult != nil { + tt.validateResult(t, calls, text) + } + }) + } +} + +func TestFormatToolResult(t *testing.T) { + tests := []struct { + name string + result ToolResult + expectedPrefix string + validateJSON func(t *testing.T, output string) + }{ + { + name: "success with data", + result: ToolResult{ + Name: "list_emails", + Data: map[string]any{ + "emails": []string{"email1", "email2"}, + "count": 2, + }, + }, + expectedPrefix: "TOOL_RESULT: ", + validateJSON: func(t *testing.T, output string) { + assert.Contains(t, output, `"name":"list_emails"`) + assert.Contains(t, output, `"data"`) + assert.NotContains(t, output, `"error"`) + }, + }, + { + name: "error result", + result: ToolResult{ + Name: "send_email", + Error: "failed to send: network timeout", + }, + expectedPrefix: "TOOL_RESULT: ", + validateJSON: func(t *testing.T, output string) { + assert.Contains(t, output, `"name":"send_email"`) + assert.Contains(t, output, `"error":"failed to send: network timeout"`) + }, + }, + { + name: "both data and error", + result: ToolResult{ + Name: "read_email", + Data: map[string]string{"partial": "data"}, + Error: "incomplete read", + }, + expectedPrefix: "TOOL_RESULT: ", + validateJSON: func(t *testing.T, output string) { + assert.Contains(t, output, `"name":"read_email"`) + assert.Contains(t, output, `"data"`) + assert.Contains(t, output, `"error"`) + }, + }, + { + name: "empty data", + result: ToolResult{ + Name: "list_folders", + }, + expectedPrefix: "TOOL_RESULT: ", + validateJSON: func(t *testing.T, output string) { + assert.Contains(t, output, `"name":"list_folders"`) + }, + }, + { + name: "data with special characters", + result: ToolResult{ + Name: "search_emails", + Data: map[string]string{ + "subject": "Test \"quoted\" & ", + }, + }, + expectedPrefix: "TOOL_RESULT: ", + validateJSON: func(t *testing.T, output string) { + assert.Contains(t, output, `"name":"search_emails"`) + assert.Contains(t, output, `\u003cspecial\u003e`) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := FormatToolResult(tt.result) + assert.True(t, strings.HasPrefix(output, tt.expectedPrefix), "output should start with TOOL_RESULT:") + + if tt.validateJSON != nil { + tt.validateJSON(t, output) + } + }) + } +} + +func TestAvailableTools(t *testing.T) { + tools := AvailableTools() + + t.Run("returns correct tool count", func(t *testing.T) { + // Expecting: list_emails, read_email, search_emails, send_email, + // list_events, create_event, list_contacts, list_folders + assert.Equal(t, 8, len(tools), "expected 8 tools") + }) + + t.Run("contains expected tool names", func(t *testing.T) { + expectedTools := []string{ + "list_emails", "read_email", "search_emails", "send_email", + "list_events", "create_event", "list_contacts", "list_folders", + } + + toolNames := make(map[string]bool) + for _, tool := range tools { + toolNames[tool.Name] = true + } + + for _, expected := range expectedTools { + assert.True(t, toolNames[expected], "tool %s should be available", expected) + } + }) + + t.Run("all tools have descriptions", func(t *testing.T) { + for _, tool := range tools { + assert.NotEmpty(t, tool.Description, "tool %s should have a description", tool.Name) + } + }) + + t.Run("required parameters are marked", func(t *testing.T) { + // Find send_email tool + var sendEmail *Tool + for i, tool := range tools { + if tool.Name == "send_email" { + sendEmail = &tools[i] + break + } + } + require.NotNil(t, sendEmail, "send_email tool should exist") + + // Check required parameters + requiredParams := []string{"to", "subject", "body"} + for _, param := range sendEmail.Parameters { + if contains(requiredParams, param.Name) { + assert.True(t, param.Required, "parameter %s should be required", param.Name) + } + } + }) + + t.Run("all parameters have types", func(t *testing.T) { + for _, tool := range tools { + for _, param := range tool.Parameters { + assert.NotEmpty(t, param.Type, "parameter %s in tool %s should have a type", param.Name, tool.Name) + } + } + }) +} + +func TestFormatToolsForPrompt(t *testing.T) { + tools := []Tool{ + { + Name: "test_tool", + Description: "A test tool for testing", + Parameters: []ToolParameter{ + {Name: "arg1", Type: "string", Description: "First argument", Required: true}, + {Name: "arg2", Type: "number", Description: "Second argument", Required: false}, + }, + }, + { + Name: "simple_tool", + Description: "A tool with no parameters", + Parameters: []ToolParameter{}, + }, + } + + output := FormatToolsForPrompt(tools) + + t.Run("contains header", func(t *testing.T) { + assert.Contains(t, output, "Available tools:") + }) + + t.Run("contains all tool names", func(t *testing.T) { + assert.Contains(t, output, "test_tool") + assert.Contains(t, output, "simple_tool") + }) + + t.Run("contains tool descriptions", func(t *testing.T) { + assert.Contains(t, output, "A test tool for testing") + assert.Contains(t, output, "A tool with no parameters") + }) + + t.Run("contains parameter details", func(t *testing.T) { + assert.Contains(t, output, "arg1") + assert.Contains(t, output, "string") + assert.Contains(t, output, "First argument") + assert.Contains(t, output, "(required)") + }) + + t.Run("shows optional parameters", func(t *testing.T) { + assert.Contains(t, output, "arg2") + assert.Contains(t, output, "number") + assert.Contains(t, output, "Second argument") + // Should NOT contain "(required)" for arg2 + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "arg2") { + assert.NotContains(t, line, "(required)", "arg2 should not be marked as required") + } + } + }) + + t.Run("tool with no parameters omits Parameters section", func(t *testing.T) { + // Find the simple_tool in the output + lines := strings.Split(output, "\n") + foundSimpleTool := false + hasParameters := false + + for i, line := range lines { + if strings.Contains(line, "simple_tool") { + foundSimpleTool = true + // Check next few lines for "Parameters:" + for j := i + 1; j < len(lines) && j < i+5; j++ { + if strings.Contains(lines[j], "- **") { + // Hit next tool + break + } + if strings.Contains(lines[j], "Parameters:") { + hasParameters = true + break + } + } + break + } + } + + assert.True(t, foundSimpleTool, "should find simple_tool in output") + assert.False(t, hasParameters, "simple_tool should not have Parameters section") + }) +} + +// Helper functions + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} From 661cb7e191b371d915d260f79395c41583ebda9e Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 12 Feb 2026 23:20:37 -0500 Subject: [PATCH 2/9] feat(chat): add slash commands, approval gating, streaming, and comprehensive tests - Add slash commands (/help, /new, /clear, /model, /email, /calendar, /contacts, /status) with tab completion and server-side execution - Add approval gating for destructive operations (send_email, create_event) with channel-based synchronization and SSE approval_required/resolved events - Add streaming responses with token-by-token SSE delivery and stream-then-parse strategy for tool call detection - Split chat.css into chat.css + components.css for maintainability - Add comprehensive Go unit tests for executor (email/calendar/contacts/ folders tool dispatch), conversation handlers, prompt builder, and session - Add Playwright E2E tests (28 tests) covering smoke, UI interactions, slash commands, and API health endpoints - Fix welcome element preservation when clearing/resetting messages - Fix async command execution to properly await sidebar operations --- internal/adapters/nylas/mock_client.go | 3 + internal/adapters/nylas/mock_contacts.go | 3 + internal/chat/agent_stream.go | 229 +++++++++ internal/chat/agent_stream_test.go | 333 +++++++++++++ internal/chat/approval.go | 139 ++++++ internal/chat/approval_test.go | 294 +++++++++++ internal/chat/executor_operations_test.go | 512 +++++++++++++++++++ internal/chat/executor_test.go | 438 +++++++++++++++++ internal/chat/handlers.go | 71 ++- internal/chat/handlers_approval.go | 72 +++ internal/chat/handlers_approval_test.go | 232 +++++++++ internal/chat/handlers_cmd.go | 130 +++++ internal/chat/handlers_cmd_test.go | 270 ++++++++++ internal/chat/handlers_conv_test.go | 569 ++++++++++++++++++++++ internal/chat/prompt_test.go | 102 ++++ internal/chat/server.go | 49 +- internal/chat/session_test.go | 143 ++++++ internal/chat/static/css/chat.css | 146 ------ internal/chat/static/css/components.css | 280 +++++++++++ internal/chat/static/js/api.js | 32 ++ internal/chat/static/js/chat.js | 153 +++++- internal/chat/static/js/commands.js | 124 +++++ internal/chat/templates/index.gohtml | 2 + tests/chat/e2e/smoke.spec.js | 116 +++++ tests/chat/e2e/ui-interactions.spec.js | 204 ++++++++ tests/package.json | 1 + tests/playwright.config.js | 29 +- tests/shared/helpers/chat-selectors.js | 54 ++ 28 files changed, 4553 insertions(+), 177 deletions(-) create mode 100644 internal/chat/agent_stream.go create mode 100644 internal/chat/agent_stream_test.go create mode 100644 internal/chat/approval.go create mode 100644 internal/chat/approval_test.go create mode 100644 internal/chat/executor_operations_test.go create mode 100644 internal/chat/executor_test.go create mode 100644 internal/chat/handlers_approval.go create mode 100644 internal/chat/handlers_approval_test.go create mode 100644 internal/chat/handlers_cmd.go create mode 100644 internal/chat/handlers_cmd_test.go create mode 100644 internal/chat/handlers_conv_test.go create mode 100644 internal/chat/prompt_test.go create mode 100644 internal/chat/session_test.go create mode 100644 internal/chat/static/css/components.css create mode 100644 internal/chat/static/js/commands.js create mode 100644 tests/chat/e2e/smoke.spec.js create mode 100644 tests/chat/e2e/ui-interactions.spec.js create mode 100644 tests/shared/helpers/chat-selectors.js diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go index 9803e1a..f87be63 100644 --- a/internal/adapters/nylas/mock_client.go +++ b/internal/adapters/nylas/mock_client.go @@ -88,6 +88,9 @@ type MockClient struct { CreateEventFunc func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) UpdateEventFunc func(ctx context.Context, grantID, calendarID, eventID string, req *domain.UpdateEventRequest) (*domain.Event, error) DeleteEventFunc func(ctx context.Context, grantID, calendarID, eventID string) error + + // Contact functions + GetContactsFunc func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) } // NewMockClient creates a new MockClient. diff --git a/internal/adapters/nylas/mock_contacts.go b/internal/adapters/nylas/mock_contacts.go index ffa44da..2c95aa8 100644 --- a/internal/adapters/nylas/mock_contacts.go +++ b/internal/adapters/nylas/mock_contacts.go @@ -7,6 +7,9 @@ import ( ) func (m *MockClient) GetContacts(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { + if m.GetContactsFunc != nil { + return m.GetContactsFunc(ctx, grantID, params) + } return []domain.Contact{ { ID: "contact-1", diff --git a/internal/chat/agent_stream.go b/internal/chat/agent_stream.go new file mode 100644 index 0000000..4e0484f --- /dev/null +++ b/internal/chat/agent_stream.go @@ -0,0 +1,229 @@ +package chat + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "strings" +) + +// TokenCallback is called for each token received during streaming. +type TokenCallback func(token string) + +// SupportsStreaming returns true if the agent supports token-by-token streaming. +func (a *Agent) SupportsStreaming() bool { + return a.Type == AgentClaude || a.Type == AgentOllama +} + +// RunStreaming executes the agent with streaming output. +// The onToken callback is called for each text chunk received. +// Returns the complete response text and any error. +func (a *Agent) RunStreaming(ctx context.Context, prompt string, onToken TokenCallback) (string, error) { + switch a.Type { + case AgentClaude: + return a.streamClaude(ctx, prompt, onToken) + case AgentOllama: + return a.streamOllama(ctx, prompt, onToken) + default: + // Fallback: run non-streaming, emit full response as single token + return a.fallbackStream(ctx, prompt, onToken) + } +} + +// streamClaude streams tokens from Claude using stream-json output format. +// Each line is a JSON object; we extract text from content_block_delta events. +func (a *Agent) streamClaude(ctx context.Context, prompt string, onToken TokenCallback) (string, error) { + cmd := exec.CommandContext(ctx, a.Path, "-p", "--verbose", "--output-format", "stream-json", prompt) + cmd.Env = cleanEnv() + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("claude stream pipe: %w", err) + } + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("claude stream start: %w", err) + } + + var full strings.Builder + var resultText string // fallback from result event + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // allow large lines + + for scanner.Scan() { + line := scanner.Bytes() + + // Try streaming token first + token := parseClaudeStreamLine(line) + if token != "" { + full.WriteString(token) + if onToken != nil { + onToken(token) + } + continue + } + + // Check for result event (contains full response as fallback) + if r := parseClaudeResultLine(line); r != "" { + resultText = r + } + } + + if err := cmd.Wait(); err != nil { + // If we got output, return it despite the error + if full.Len() > 0 { + return full.String(), nil + } + if resultText != "" { + return resultText, nil + } + return "", fmt.Errorf("claude stream error: %w: %s", err, stderr.String()) + } + + // Prefer streamed tokens; fall back to result event + if full.Len() > 0 { + return full.String(), nil + } + if resultText != "" { + return resultText, nil + } + + return "", nil +} + +// claudeStreamEvent represents a Claude Code CLI stream-json line. +// The CLI wraps Anthropic API events in a stream_event envelope: +// +// {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}} +// +// Result events contain the full response: +// +// {"type":"result","result":"full text","subtype":"success"} +type claudeStreamEvent struct { + Type string `json:"type"` + // stream_event wraps the inner API event + Event struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + } `json:"event"` + // result event contains the full response text + Result string `json:"result"` +} + +// parseClaudeStreamLine extracts text tokens from a Claude stream-json line. +func parseClaudeStreamLine(line []byte) string { + if len(line) == 0 { + return "" + } + + var event claudeStreamEvent + if err := json.Unmarshal(line, &event); err != nil { + return "" + } + + // stream_event envelope with nested content_block_delta + if event.Type == "stream_event" && event.Event.Type == "content_block_delta" { + return event.Event.Delta.Text + } + + return "" +} + +// parseClaudeResultLine extracts the full response from a result event. +func parseClaudeResultLine(line []byte) string { + if len(line) == 0 { + return "" + } + + var event claudeStreamEvent + if err := json.Unmarshal(line, &event); err != nil { + return "" + } + + if event.Type == "result" && event.Result != "" { + return event.Result + } + + return "" +} + +// streamOllama streams tokens from Ollama by reading stdout chunks. +func (a *Agent) streamOllama(ctx context.Context, prompt string, onToken TokenCallback) (string, error) { + model := a.Model + if model == "" { + model = "mistral" + } + + cmd := exec.CommandContext(ctx, a.Path, "run", model) + cmd.Env = cleanEnv() + cmd.Stdin = strings.NewReader(prompt) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("ollama stream pipe: %w", err) + } + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("ollama stream start: %w", err) + } + + var full strings.Builder + buf := make([]byte, 256) + + for { + n, readErr := stdout.Read(buf) + if n > 0 { + token := string(buf[:n]) + full.WriteString(token) + if onToken != nil { + onToken(token) + } + } + if readErr != nil { + if readErr != io.EOF { + // Non-EOF error, but if we have content, return it + if full.Len() > 0 { + break + } + return "", fmt.Errorf("ollama read: %w", readErr) + } + break + } + } + + if err := cmd.Wait(); err != nil { + if full.Len() > 0 { + return strings.TrimSpace(full.String()), nil + } + return "", fmt.Errorf("ollama stream error: %w: %s", err, stderr.String()) + } + + return strings.TrimSpace(full.String()), nil +} + +// fallbackStream runs the agent non-streaming and emits the full response as one token. +func (a *Agent) fallbackStream(ctx context.Context, prompt string, onToken TokenCallback) (string, error) { + response, err := a.Run(ctx, prompt) + if err != nil { + return "", err + } + + if onToken != nil && response != "" { + onToken(response) + } + + return response, nil +} diff --git a/internal/chat/agent_stream_test.go b/internal/chat/agent_stream_test.go new file mode 100644 index 0000000..847fc82 --- /dev/null +++ b/internal/chat/agent_stream_test.go @@ -0,0 +1,333 @@ +package chat + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSupportsStreaming(t *testing.T) { + tests := []struct { + name string + agent Agent + expected bool + }{ + { + name: "claude supports streaming", + agent: Agent{ + Type: AgentClaude, + Path: "/usr/bin/claude", + }, + expected: true, + }, + { + name: "ollama supports streaming", + agent: Agent{ + Type: AgentOllama, + Path: "/usr/bin/ollama", + Model: "mistral", + }, + expected: true, + }, + { + name: "codex does not support streaming", + agent: Agent{ + Type: AgentCodex, + Path: "/usr/bin/codex", + }, + expected: false, + }, + { + name: "unknown type does not support streaming", + agent: Agent{ + Type: AgentType("unknown"), + Path: "/usr/bin/unknown", + }, + expected: false, + }, + { + name: "empty type does not support streaming", + agent: Agent{ + Type: AgentType(""), + Path: "/usr/bin/empty", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.agent.SupportsStreaming() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseClaudeStreamLine(t *testing.T) { + tests := []struct { + name string + line string + expected string + }{ + { + name: "stream_event with content_block_delta", + line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}`, + expected: "Hello", + }, + { + name: "stream_event with multiword text", + line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello, world!"}}}`, + expected: "Hello, world!", + }, + { + name: "stream_event with newline in text", + line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Line 1\nLine 2"}}}`, + expected: "Line 1\nLine 2", + }, + { + name: "stream_event with empty text", + line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}}`, + expected: "", + }, + { + name: "stream_event with message_start (not delta)", + line: `{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_123"}}}`, + expected: "", + }, + { + name: "stream_event with content_block_stop", + line: `{"type":"stream_event","event":{"type":"content_block_stop","index":0}}`, + expected: "", + }, + { + name: "stream_event with message_delta", + line: `{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"}}}`, + expected: "", + }, + { + name: "non-stream_event type", + line: `{"type":"result","result":"Full response","subtype":"success"}`, + expected: "", + }, + { + name: "empty line", + line: "", + expected: "", + }, + { + name: "whitespace only", + line: " ", + expected: "", + }, + { + name: "invalid JSON", + line: `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"text":"Hello}`, + expected: "", + }, + { + name: "malformed JSON", + line: `not json at all`, + expected: "", + }, + { + name: "valid JSON but unknown type", + line: `{"type":"ping","timestamp":1234567890}`, + expected: "", + }, + { + name: "stream_event with unicode", + line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello 👋 世界"}}}`, + expected: "Hello 👋 世界", + }, + { + name: "stream_event with escaped quotes", + line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"He said \"Hi\""}}}`, + expected: `He said "Hi"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseClaudeStreamLine([]byte(tt.line)) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseClaudeResultLine(t *testing.T) { + tests := []struct { + name string + line string + expected string + }{ + { + name: "result event with text", + line: `{"type":"result","result":"Hello, world!","subtype":"success"}`, + expected: "Hello, world!", + }, + { + name: "result event with empty result", + line: `{"type":"result","result":"","subtype":"success"}`, + expected: "", + }, + { + name: "non-result event", + line: `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"text":"Hi"}}}`, + expected: "", + }, + { + name: "empty line", + line: "", + expected: "", + }, + { + name: "invalid JSON", + line: `not json`, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseClaudeResultLine([]byte(tt.line)) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFallbackStream(t *testing.T) { + tests := []struct { + name string + agent Agent + prompt string + expectError bool + expectToken bool + errorContains string + }{ + { + name: "codex agent with non-existent binary", + agent: Agent{ + Type: AgentCodex, + Path: "/nonexistent/codex", + }, + prompt: "test prompt", + expectError: true, + expectToken: false, + errorContains: "codex error", + }, + { + name: "unknown agent type", + agent: Agent{ + Type: AgentType("unknown"), + Path: "/nonexistent/unknown", + }, + prompt: "test prompt", + expectError: true, + expectToken: false, + errorContains: "unsupported agent type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + var receivedToken string + var tokenReceived bool + + callback := func(token string) { + receivedToken = token + tokenReceived = true + } + + result, err := tt.agent.fallbackStream(ctx, tt.prompt, callback) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + assert.Empty(t, result) + assert.False(t, tokenReceived, "should not receive token on error") + } else { + require.NoError(t, err) + if tt.expectToken { + assert.True(t, tokenReceived, "should receive token callback") + assert.NotEmpty(t, receivedToken) + assert.Equal(t, result, receivedToken) + } + } + }) + } +} + +func TestFallbackStream_CallbackBehavior(t *testing.T) { + t.Run("nil callback does not panic", func(t *testing.T) { + agent := Agent{ + Type: AgentCodex, + Path: "/nonexistent/codex", + } + + ctx := context.Background() + _, err := agent.fallbackStream(ctx, "test", nil) + + // Should error because binary doesn't exist, but shouldn't panic + assert.Error(t, err) + }) + + t.Run("callback receives full response", func(t *testing.T) { + // This test would require mocking exec.Command or using a real binary + // For now, we verify the logic path with a non-existent binary + agent := Agent{ + Type: AgentType("unknown"), + Path: "/path/to/agent", + } + + ctx := context.Background() + callbackCount := 0 + + callback := func(token string) { + callbackCount++ + } + + _, err := agent.fallbackStream(ctx, "prompt", callback) + + // Should error with unsupported agent type + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported agent type") + assert.Equal(t, 0, callbackCount, "callback should not be called on error") + }) +} + +func TestTokenCallback(t *testing.T) { + t.Run("multiple tokens collected", func(t *testing.T) { + var tokens []string + callback := TokenCallback(func(token string) { + tokens = append(tokens, token) + }) + + // Simulate streaming multiple tokens + callback("Hello") + callback(" ") + callback("world") + callback("!") + + assert.Equal(t, []string{"Hello", " ", "world", "!"}, tokens) + assert.Equal(t, "Hello world!", strings.Join(tokens, "")) + }) + + t.Run("empty token handling", func(t *testing.T) { + var tokens []string + callback := TokenCallback(func(token string) { + tokens = append(tokens, token) + }) + + callback("") + callback("text") + callback("") + + assert.Equal(t, []string{"", "text", ""}, tokens) + }) +} diff --git a/internal/chat/approval.go b/internal/chat/approval.go new file mode 100644 index 0000000..d9bbe49 --- /dev/null +++ b/internal/chat/approval.go @@ -0,0 +1,139 @@ +package chat + +import ( + "sync" + "sync/atomic" + "time" +) + +// approvalTimeout is how long to wait for user approval before auto-rejecting. +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, +} + +// IsGated returns true if the tool requires user approval. +func IsGated(toolName string) bool { + return gatedTools[toolName] +} + +// ApprovalDecision is the user's response to an approval request. +type ApprovalDecision struct { + Approved bool `json:"approved"` + Reason string `json:"reason,omitempty"` +} + +// PendingApproval represents a tool call waiting for user approval. +type PendingApproval struct { + ID string `json:"id"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Preview map[string]any `json:"preview"` + ch chan ApprovalDecision +} + +// Wait blocks until the user approves/rejects or the timeout expires. +func (pa *PendingApproval) Wait() (ApprovalDecision, bool) { + select { + case decision := <-pa.ch: + return decision, true + case <-time.After(approvalTimeout): + return ApprovalDecision{Approved: false, Reason: "timed out"}, false + } +} + +// ApprovalStore manages pending approval requests. +type ApprovalStore struct { + pending sync.Map + counter atomic.Int64 +} + +// NewApprovalStore creates a new ApprovalStore. +func NewApprovalStore() *ApprovalStore { + return &ApprovalStore{} +} + +// Create registers a new pending approval and returns it. +func (s *ApprovalStore) Create(call ToolCall, preview map[string]any) *PendingApproval { + id := s.nextID() + pa := &PendingApproval{ + ID: id, + Tool: call.Name, + Args: call.Args, + Preview: preview, + ch: make(chan ApprovalDecision, 1), // buffered so sender never blocks + } + s.pending.Store(id, pa) + return pa +} + +// Resolve sends a decision for a pending approval. Returns false if not found. +func (s *ApprovalStore) Resolve(id string, decision ApprovalDecision) bool { + val, ok := s.pending.LoadAndDelete(id) + if !ok { + return false + } + pa := val.(*PendingApproval) + pa.ch <- decision + return true +} + +// nextID generates a sequential approval ID. +func (s *ApprovalStore) nextID() string { + n := s.counter.Add(1) + return "approval_" + itoa(n) +} + +// itoa converts int64 to string without importing strconv. +func itoa(n int64) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) - 1 + for n > 0 { + buf[i] = byte('0' + n%10) + i-- + n /= 10 + } + return string(buf[i+1:]) +} + +// BuildPreview creates a human-readable preview of a gated tool call. +func BuildPreview(call ToolCall) map[string]any { + preview := make(map[string]any) + + switch call.Name { + case "send_email": + if to, ok := call.Args["to"].(string); ok { + preview["to"] = to + } + if subj, ok := call.Args["subject"].(string); ok { + preview["subject"] = subj + } + if body, ok := call.Args["body"].(string); ok { + if len(body) > 200 { + body = body[:200] + "..." + } + preview["body"] = body + } + case "create_event": + if title, ok := call.Args["title"].(string); ok { + preview["title"] = title + } + if start, ok := call.Args["start_time"].(string); ok { + preview["start_time"] = start + } + if end, ok := call.Args["end_time"].(string); ok { + preview["end_time"] = end + } + if desc, ok := call.Args["description"].(string); ok { + preview["description"] = desc + } + } + + return preview +} diff --git a/internal/chat/approval_test.go b/internal/chat/approval_test.go new file mode 100644 index 0000000..b2f0c2d --- /dev/null +++ b/internal/chat/approval_test.go @@ -0,0 +1,294 @@ +package chat + +import ( + "sync" + "testing" + "time" +) + +func TestIsGated(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toolName string + want bool + }{ + {"send_email is gated", "send_email", true}, + {"create_event is gated", "create_event", true}, + {"list_emails is not gated", "list_emails", false}, + {"get_event is not gated", "get_event", false}, + {"empty string is not gated", "", false}, + {"unknown tool is not gated", "unknown_tool", 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 TestApprovalStore_Create(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + + call1 := ToolCall{Name: "send_email", Args: map[string]any{"to": "test@example.com"}} + preview1 := map[string]any{"to": "test@example.com"} + + pa1 := store.Create(call1, preview1) + + if pa1.ID != "approval_1" { + t.Errorf("First approval ID = %q, want %q", pa1.ID, "approval_1") + } + if pa1.Tool != "send_email" { + t.Errorf("Tool = %q, want %q", pa1.Tool, "send_email") + } + if pa1.Preview["to"] != "test@example.com" { + t.Errorf("Preview[to] = %v, want %q", pa1.Preview["to"], "test@example.com") + } + + // Second approval should have sequential ID + call2 := ToolCall{Name: "create_event", Args: map[string]any{"title": "Meeting"}} + preview2 := map[string]any{"title": "Meeting"} + pa2 := store.Create(call2, preview2) + + if pa2.ID != "approval_2" { + t.Errorf("Second approval ID = %q, want %q", pa2.ID, "approval_2") + } +} + +func TestApprovalStore_Resolve(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + createID bool + resolveID string + decision ApprovalDecision + want bool + }{ + { + name: "resolve existing approval", + createID: true, + resolveID: "approval_1", + decision: ApprovalDecision{Approved: true}, + want: true, + }, + { + name: "resolve non-existent approval", + createID: false, + resolveID: "approval_999", + decision: ApprovalDecision{Approved: false}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + + if tt.createID { + call := ToolCall{Name: "send_email", Args: map[string]any{}} + store.Create(call, map[string]any{}) + } + + got := store.Resolve(tt.resolveID, tt.decision) + if got != tt.want { + t.Errorf("Resolve(%q) = %v, want %v", tt.resolveID, got, tt.want) + } + }) + } +} + +func TestApprovalStore_ResolveAlreadyResolved(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + call := ToolCall{Name: "send_email", Args: map[string]any{}} + pa := store.Create(call, map[string]any{}) + + // Resolve once + decision := ApprovalDecision{Approved: true} + if !store.Resolve(pa.ID, decision) { + t.Fatal("First resolve failed") + } + + // Try to resolve again + if store.Resolve(pa.ID, decision) { + t.Error("Second resolve succeeded, want false for already resolved approval") + } +} + +func TestPendingApproval_Wait(t *testing.T) { + t.Parallel() + + t.Run("wait returns decision when resolved", func(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + call := ToolCall{Name: "send_email", Args: map[string]any{}} + pa := store.Create(call, map[string]any{}) + + expectedDecision := ApprovalDecision{Approved: true, Reason: "looks good"} + + // Resolve from another goroutine + go func() { + time.Sleep(50 * time.Millisecond) + store.Resolve(pa.ID, expectedDecision) + }() + + decision, ok := pa.Wait() + if !ok { + t.Fatal("Wait returned false, want true") + } + if decision.Approved != expectedDecision.Approved { + t.Errorf("Approved = %v, want %v", decision.Approved, expectedDecision.Approved) + } + if decision.Reason != expectedDecision.Reason { + t.Errorf("Reason = %q, want %q", decision.Reason, expectedDecision.Reason) + } + }) + + t.Run("wait rejects when not resolved", func(t *testing.T) { + // This test would take 5 minutes with the real timeout + // We can't easily test the timeout without modifying the code + // Skip this in normal test runs + t.Skip("Timeout test would take 5 minutes") + }) +} + +func TestPendingApproval_WaitConcurrent(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + const numApprovals = 10 + + var wg sync.WaitGroup + wg.Add(numApprovals) + + for i := 0; i < numApprovals; i++ { + go func(idx int) { + defer wg.Done() + + call := ToolCall{Name: "send_email", Args: map[string]any{"id": idx}} + pa := store.Create(call, map[string]any{}) + + // Resolve from another goroutine + go func() { + time.Sleep(10 * time.Millisecond) + store.Resolve(pa.ID, ApprovalDecision{Approved: idx%2 == 0}) + }() + + decision, ok := pa.Wait() + if !ok { + t.Errorf("Wait for approval %d failed", idx) + } + expectedApproval := idx%2 == 0 + if decision.Approved != expectedApproval { + t.Errorf("Approval %d: got %v, want %v", idx, decision.Approved, expectedApproval) + } + }(i) + } + + wg.Wait() +} + +func TestBuildPreview(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + call ToolCall + want map[string]any + }{ + { + name: "send_email with all fields", + call: ToolCall{ + Name: "send_email", + Args: map[string]any{ + "to": "user@example.com", + "subject": "Test Subject", + "body": "Short body", + }, + }, + want: map[string]any{ + "to": "user@example.com", + "subject": "Test Subject", + "body": "Short body", + }, + }, + { + name: "send_email with long body truncation", + call: ToolCall{ + Name: "send_email", + Args: map[string]any{ + "to": "user@example.com", + "subject": "Test", + "body": string(make([]byte, 300)), // 300 null bytes + }, + }, + want: map[string]any{ + "to": "user@example.com", + "subject": "Test", + "body": string(make([]byte, 200)) + "...", + }, + }, + { + name: "create_event with all fields", + call: ToolCall{ + Name: "create_event", + Args: map[string]any{ + "title": "Team Meeting", + "start_time": "2026-02-12T10:00:00Z", + "end_time": "2026-02-12T11:00:00Z", + "description": "Discuss Q1 goals", + }, + }, + want: map[string]any{ + "title": "Team Meeting", + "start_time": "2026-02-12T10:00:00Z", + "end_time": "2026-02-12T11:00:00Z", + "description": "Discuss Q1 goals", + }, + }, + { + name: "unknown tool returns empty preview", + call: ToolCall{ + Name: "unknown_tool", + Args: map[string]any{"foo": "bar"}, + }, + want: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := BuildPreview(tt.call) + + if len(got) != len(tt.want) { + t.Errorf("Preview length = %d, want %d", len(got), len(tt.want)) + } + + for key, wantVal := range tt.want { + gotVal, ok := got[key] + if !ok { + t.Errorf("Preview missing key %q", key) + continue + } + if gotVal != wantVal { + t.Errorf("Preview[%q] = %v, want %v", key, gotVal, wantVal) + } + } + }) + } +} diff --git a/internal/chat/executor_operations_test.go b/internal/chat/executor_operations_test.go new file mode 100644 index 0000000..4466459 --- /dev/null +++ b/internal/chat/executor_operations_test.go @@ -0,0 +1,512 @@ +package chat + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tests for send email, events, contacts, and folders operations + +func TestSendEmail_Success(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { + assert.Equal(t, "test@example.com", req.To[0].Email) + assert.Equal(t, "Test Subject", req.Subject) + assert.Equal(t, "Test body", req.Body) + return &domain.Message{ID: "sent-123"}, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "send_email", + Args: map[string]any{ + "to": "test@example.com", + "subject": "Test Subject", + "body": "Test body", + }, + }) + + assert.Empty(t, result.Error) + + jsonData, err := json.Marshal(result.Data) + require.NoError(t, err) + + var data map[string]string + err = json.Unmarshal(jsonData, &data) + require.NoError(t, err) + + assert.Equal(t, "sent-123", data["id"]) + assert.Equal(t, "sent", data["status"]) +} + +func TestSendEmail_MissingParams(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing to", map[string]any{"subject": "Test", "body": "Body"}}, + {"missing subject", map[string]any{"to": "test@example.com", "body": "Body"}}, + {"missing body", map[string]any{"to": "test@example.com", "subject": "Test"}}, + {"empty to", map[string]any{"to": "", "subject": "Test", "body": "Body"}}, + {"empty subject", map[string]any{"to": "test@example.com", "subject": "", "body": "Body"}}, + {"empty body", map[string]any{"to": "test@example.com", "subject": "Test", "body": ""}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + result := executor.Execute(context.Background(), ToolCall{ + Name: "send_email", + Args: tt.args, + }) + + assert.Contains(t, result.Error, "to, subject, and body are required") + }) + } +} + +func TestSendEmail_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { + return nil, errors.New("send failed") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "send_email", + Args: map[string]any{"to": "test@example.com", "subject": "Test", "body": "Body"}, + }) + + assert.Contains(t, result.Error, "send failed") +} + +func TestListEvents_Success(t *testing.T) { + tests := []struct { + name string + args map[string]any + wantCalID string + wantLimit int + events []domain.Event + wantEvents int + }{ + { + name: "default calendar", + args: map[string]any{}, + wantCalID: "primary", + wantLimit: 10, + events: []domain.Event{ + {ID: "evt1", Title: "Meeting", When: domain.EventWhen{StartTime: 1707753600, EndTime: 1707757200}}, + }, + wantEvents: 1, + }, + { + name: "custom calendar and limit", + args: map[string]any{"calendar_id": "work", "limit": float64(5)}, + wantCalID: "work", + wantLimit: 5, + events: []domain.Event{ + {ID: "evt2", Title: "Standup", When: domain.EventWhen{StartTime: 1707753600, EndTime: 1707755400}}, + }, + wantEvents: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) { + assert.Equal(t, tt.wantCalID, calendarID) + assert.Equal(t, tt.wantLimit, params.Limit) + return tt.events, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_events", + Args: tt.args, + }) + + assert.Empty(t, result.Error) + + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var events []map[string]any + err = json.Unmarshal(data, &events) + require.NoError(t, err) + assert.Len(t, events, tt.wantEvents) + + if tt.wantEvents > 0 { + assert.NotEmpty(t, events[0]["id"]) + assert.NotEmpty(t, events[0]["title"]) + } + }) + } +} + +func TestListEvents_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) { + return nil, errors.New("calendar not found") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_events", + Args: map[string]any{}, + }) + + assert.Contains(t, result.Error, "calendar not found") +} + +func TestCreateEvent_Success(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) { + assert.Equal(t, "primary", calendarID) + assert.Equal(t, "Team Meeting", req.Title) + assert.Equal(t, "Discuss Q1 goals", req.Description) + assert.Greater(t, req.When.EndTime, req.When.StartTime) + return &domain.Event{ID: "evt-123", Title: "Team Meeting"}, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "create_event", + Args: map[string]any{ + "title": "Team Meeting", + "start_time": "2026-02-12T14:00:00Z", + "end_time": "2026-02-12T15:00:00Z", + "description": "Discuss Q1 goals", + }, + }) + + assert.Empty(t, result.Error) + + jsonData, err := json.Marshal(result.Data) + require.NoError(t, err) + + var data map[string]string + err = json.Unmarshal(jsonData, &data) + require.NoError(t, err) + + assert.Equal(t, "evt-123", data["id"]) + assert.Equal(t, "Team Meeting", data["title"]) +} + +func TestCreateEvent_CustomCalendar(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) { + assert.Equal(t, "work-calendar", calendarID) + return &domain.Event{ID: "evt-123", Title: "Test"}, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "create_event", + Args: map[string]any{ + "title": "Test", + "start_time": "2026-02-12T14:00:00Z", + "end_time": "2026-02-12T15:00:00Z", + "calendar_id": "work-calendar", + }, + }) + + assert.Empty(t, result.Error) +} + +func TestCreateEvent_MissingParams(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing title", map[string]any{"start_time": "2026-02-12T14:00:00Z", "end_time": "2026-02-12T15:00:00Z"}}, + {"missing start_time", map[string]any{"title": "Test", "end_time": "2026-02-12T15:00:00Z"}}, + {"missing end_time", map[string]any{"title": "Test", "start_time": "2026-02-12T14:00:00Z"}}, + {"empty title", map[string]any{"title": "", "start_time": "2026-02-12T14:00:00Z", "end_time": "2026-02-12T15:00:00Z"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + result := executor.Execute(context.Background(), ToolCall{ + Name: "create_event", + Args: tt.args, + }) + + assert.Contains(t, result.Error, "title, start_time, and end_time are required") + }) + } +} + +func TestCreateEvent_InvalidTimeFormat(t *testing.T) { + tests := []struct { + name string + startTime string + endTime string + wantError string + }{ + {"invalid start", "invalid-time", "2026-02-12T15:00:00Z", "invalid start_time"}, + {"invalid end", "2026-02-12T14:00:00Z", "invalid-time", "invalid end_time"}, + {"wrong format", "2026-02-12 14:00:00", "2026-02-12 15:00:00", "invalid start_time"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + result := executor.Execute(context.Background(), ToolCall{ + Name: "create_event", + Args: map[string]any{ + "title": "Test", + "start_time": tt.startTime, + "end_time": tt.endTime, + }, + }) + + assert.Contains(t, result.Error, tt.wantError) + }) + } +} + +func TestCreateEvent_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) { + return nil, errors.New("calendar permission denied") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "create_event", + Args: map[string]any{ + "title": "Test", + "start_time": "2026-02-12T14:00:00Z", + "end_time": "2026-02-12T15:00:00Z", + }, + }) + + assert.Contains(t, result.Error, "calendar permission denied") +} + +func TestListContacts_Success(t *testing.T) { + tests := []struct { + name string + args map[string]any + contacts []domain.Contact + wantLimit int + wantEmail string + wantContacts int + }{ + { + name: "default limit", + args: map[string]any{}, + wantLimit: 10, + contacts: []domain.Contact{ + {ID: "c1", GivenName: "John", Surname: "Doe", Emails: []domain.ContactEmail{{Email: "john@example.com"}}}, + {ID: "c2", GivenName: "Jane", Emails: []domain.ContactEmail{{Email: "jane@example.com"}}}, + }, + wantContacts: 2, + }, + { + name: "with query", + args: map[string]any{"query": "john@example.com", "limit": float64(5)}, + wantLimit: 5, + wantEmail: "john@example.com", + contacts: []domain.Contact{ + {ID: "c1", GivenName: "John", Surname: "Doe", Emails: []domain.ContactEmail{{Email: "john@example.com"}}}, + }, + wantContacts: 1, + }, + { + name: "no emails", + args: map[string]any{}, + wantLimit: 10, + contacts: []domain.Contact{{ID: "c1", GivenName: "Test", Emails: []domain.ContactEmail{}}}, + wantContacts: 1, + }, + { + name: "surname only", + args: map[string]any{}, + wantLimit: 10, + contacts: []domain.Contact{ + {ID: "c1", Surname: "Smith", Emails: []domain.ContactEmail{{Email: "smith@example.com"}}}, + }, + wantContacts: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { + assert.Equal(t, tt.wantLimit, params.Limit) + if tt.wantEmail != "" { + assert.Equal(t, tt.wantEmail, params.Email) + } + return tt.contacts, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_contacts", + Args: tt.args, + }) + + assert.Empty(t, result.Error) + + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var contacts []map[string]any + err = json.Unmarshal(data, &contacts) + require.NoError(t, err) + assert.Len(t, contacts, tt.wantContacts) + + if tt.wantContacts > 0 { + assert.NotEmpty(t, contacts[0]["id"]) + } + }) + } +} + +func TestListContacts_NameFormatting(t *testing.T) { + tests := []struct { + name string + contact domain.Contact + wantName string + wantEmail string + }{ + { + name: "full name", + contact: domain.Contact{ID: "c1", GivenName: "John", Surname: "Doe", Emails: []domain.ContactEmail{{Email: "john@example.com"}}}, + wantName: "John Doe", + wantEmail: "john@example.com", + }, + { + name: "given name only", + contact: domain.Contact{ID: "c2", GivenName: "Jane", Emails: []domain.ContactEmail{{Email: "jane@example.com"}}}, + wantName: "Jane", + wantEmail: "jane@example.com", + }, + { + name: "no name", + contact: domain.Contact{ID: "c3", Emails: []domain.ContactEmail{{Email: "noname@example.com"}}}, + wantName: "", + wantEmail: "noname@example.com", + }, + { + name: "no email", + contact: domain.Contact{ID: "c4", GivenName: "Test", Emails: []domain.ContactEmail{}}, + wantName: "Test", + wantEmail: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { + return []domain.Contact{tt.contact}, nil + } + + result := executor.listContacts(context.Background(), map[string]any{}) + + require.Empty(t, result.Error) + + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var contacts []map[string]any + err = json.Unmarshal(data, &contacts) + require.NoError(t, err) + require.Len(t, contacts, 1) + + assert.Equal(t, tt.wantName, contacts[0]["name"]) + assert.Equal(t, tt.wantEmail, contacts[0]["email"]) + }) + } +} + +func TestListContacts_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { + return nil, errors.New("contacts not available") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_contacts", + Args: map[string]any{}, + }) + + assert.Contains(t, result.Error, "contacts not available") +} + +func TestListFolders_Success(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) { + return []domain.Folder{ + {ID: "f1", Name: "Inbox"}, + {ID: "f2", Name: "Sent"}, + {ID: "f3", Name: "Archive"}, + }, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_folders", + Args: map[string]any{}, + }) + + assert.Empty(t, result.Error) + + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var folders []map[string]any + err = json.Unmarshal(data, &folders) + require.NoError(t, err) + assert.Len(t, folders, 3) + + assert.Equal(t, "f1", folders[0]["id"]) + assert.Equal(t, "Inbox", folders[0]["name"]) +} + +func TestListFolders_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) { + return nil, errors.New("folders not accessible") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_folders", + Args: map[string]any{}, + }) + + assert.Contains(t, result.Error, "folders not accessible") +} diff --git a/internal/chat/executor_test.go b/internal/chat/executor_test.go new file mode 100644 index 0000000..c38dc71 --- /dev/null +++ b/internal/chat/executor_test.go @@ -0,0 +1,438 @@ +package chat + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewToolExecutor(t *testing.T) { + client := nylas.NewMockClient() + grantID := "test-grant" + + executor := NewToolExecutor(client, grantID) + + require.NotNil(t, executor) + assert.Equal(t, grantID, executor.grantID) + assert.Equal(t, client, executor.client) +} + +func TestExecute_UnknownTool(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + result := executor.Execute(context.Background(), ToolCall{ + Name: "unknown_tool", + Args: map[string]any{}, + }) + + assert.Equal(t, "unknown_tool", result.Name) + assert.Contains(t, result.Error, "unknown tool: unknown_tool") + assert.Nil(t, result.Data) +} + +func TestExecute_Dispatcher(t *testing.T) { + tests := []struct { + name string + toolName string + wantDispatch bool + }{ + {"list_emails", "list_emails", true}, + {"read_email", "read_email", true}, + {"search_emails", "search_emails", true}, + {"send_email", "send_email", true}, + {"list_events", "list_events", true}, + {"create_event", "create_event", true}, + {"list_contacts", "list_contacts", true}, + {"list_folders", "list_folders", true}, + {"invalid_tool", "invalid_tool", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + // Set up mock to avoid nil errors + client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { + return []domain.Message{}, nil + } + client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) { + return nil, errors.New("missing id") + } + client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { + return nil, errors.New("missing required fields") + } + client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) { + return []domain.Event{}, nil + } + client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) { + return nil, errors.New("missing required fields") + } + client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { + return []domain.Contact{}, nil + } + client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) { + return []domain.Folder{}, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: tt.toolName, + Args: map[string]any{}, + }) + + assert.Equal(t, tt.toolName, result.Name) + + if !tt.wantDispatch { + assert.Contains(t, result.Error, "unknown tool") + } + }) + } +} + +func TestListEmails_Success(t *testing.T) { + now := time.Now() + tests := []struct { + name string + args map[string]any + messages []domain.Message + wantLen int + }{ + { + name: "default limit", + args: map[string]any{}, + messages: []domain.Message{ + {ID: "msg1", Subject: "Test 1", Snippet: "snippet1", Date: now, Unread: true, From: []domain.EmailParticipant{{Email: "test@example.com", Name: "Test User"}}}, + {ID: "msg2", Subject: "Test 2", Snippet: "snippet2", Date: now, Unread: false, From: []domain.EmailParticipant{{Email: "other@example.com"}}}, + }, + wantLen: 2, + }, + { + name: "with limit", + args: map[string]any{"limit": float64(5)}, + messages: []domain.Message{ + {ID: "msg1", Subject: "Test", Snippet: "snippet", Date: now, From: []domain.EmailParticipant{{Email: "test@example.com"}}}, + }, + wantLen: 1, + }, + { + name: "with filters", + args: map[string]any{ + "subject": "Meeting", + "from": "boss@example.com", + "unread": true, + }, + messages: []domain.Message{ + {ID: "msg1", Subject: "Meeting Notes", Snippet: "snippet", Date: now, Unread: true, From: []domain.EmailParticipant{{Email: "boss@example.com", Name: "Boss"}}}, + }, + wantLen: 1, + }, + { + name: "empty from array", + args: map[string]any{}, + messages: []domain.Message{{ID: "msg1", Subject: "Test", Date: now, From: []domain.EmailParticipant{}}}, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { + return tt.messages, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_emails", + Args: tt.args, + }) + + assert.Empty(t, result.Error) + assert.NotNil(t, result.Data) + + // The executor returns a custom struct slice, not []any + // We can verify it's not nil and use JSON to validate structure + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var emails []map[string]any + err = json.Unmarshal(data, &emails) + require.NoError(t, err) + assert.Len(t, emails, tt.wantLen) + + if tt.wantLen > 0 { + assert.NotEmpty(t, emails[0]["id"]) + assert.NotEmpty(t, emails[0]["date"]) + } + }) + } +} + +func TestListEmails_FromFormatting(t *testing.T) { + now := time.Now() + tests := []struct { + name string + from []domain.EmailParticipant + wantFrom string + }{ + { + name: "with name", + from: []domain.EmailParticipant{{Email: "test@example.com", Name: "Test User"}}, + wantFrom: "Test User ", + }, + { + name: "without name", + from: []domain.EmailParticipant{{Email: "test@example.com"}}, + wantFrom: "test@example.com", + }, + { + name: "empty from", + from: []domain.EmailParticipant{}, + wantFrom: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + 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 + } + + result := executor.listEmails(context.Background(), map[string]any{}) + + require.Empty(t, result.Error) + + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var emails []map[string]any + err = json.Unmarshal(data, &emails) + require.NoError(t, err) + require.Len(t, emails, 1) + + assert.Equal(t, tt.wantFrom, emails[0]["from"]) + }) + } +} + +func TestListEmails_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { + return nil, errors.New("API error") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "list_emails", + Args: map[string]any{}, + }) + + assert.Equal(t, "list_emails", result.Name) + assert.Contains(t, result.Error, "API error") + assert.Nil(t, result.Data) +} + +func TestReadEmail_Success(t *testing.T) { + now := time.Now() + tests := []struct { + name string + message *domain.Message + wantLen int + }{ + { + name: "normal email", + message: &domain.Message{ + ID: "msg1", + Subject: "Test Subject", + Body: "Email body", + Date: now, + From: []domain.EmailParticipant{{Email: "sender@example.com", Name: "Sender"}}, + To: []domain.EmailParticipant{{Email: "recipient@example.com"}}, + }, + wantLen: len("Email body"), + }, + { + name: "long body truncation", + message: &domain.Message{ + ID: "msg2", + Subject: "Long Email", + Body: strings.Repeat("a", 6000), + Date: now, + From: []domain.EmailParticipant{{Email: "sender@example.com"}}, + }, + wantLen: 5000 + len("\n... [truncated]"), + }, + { + name: "exactly 5000 chars", + message: &domain.Message{ + ID: "msg3", + Subject: "Edge Case", + Body: strings.Repeat("a", 5000), + Date: now, + From: []domain.EmailParticipant{{Email: "sender@example.com"}}, + }, + wantLen: 5000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) { + return tt.message, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "read_email", + Args: map[string]any{"id": "msg1"}, + }) + + assert.Empty(t, result.Error) + require.NotNil(t, result.Data) + + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var email map[string]any + err = json.Unmarshal(data, &email) + require.NoError(t, err) + + assert.Equal(t, tt.message.ID, email["id"]) + assert.Equal(t, tt.message.Subject, email["subject"]) + assert.Len(t, email["body"].(string), tt.wantLen) + + if len(tt.message.Body) > 5000 { + assert.True(t, strings.HasSuffix(email["body"].(string), "\n... [truncated]")) + } + }) + } +} + +func TestReadEmail_MissingID(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"no id", map[string]any{}}, + {"empty id", map[string]any{"id": ""}}, + {"wrong type", map[string]any{"id": 123}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + result := executor.Execute(context.Background(), ToolCall{ + Name: "read_email", + Args: tt.args, + }) + + assert.Equal(t, "read_email", result.Name) + assert.Contains(t, result.Error, "id parameter is required") + assert.Nil(t, result.Data) + }) + } +} + +func TestReadEmail_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) { + return nil, errors.New("message not found") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "read_email", + Args: map[string]any{"id": "nonexistent"}, + }) + + assert.Contains(t, result.Error, "message not found") +} + +func TestSearchEmails_Success(t *testing.T) { + now := time.Now() + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { + assert.Equal(t, "budget report", params.SearchQuery) + 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"}}}, + }, nil + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "search_emails", + Args: map[string]any{"query": "budget report", "limit": float64(20)}, + }) + + assert.Empty(t, result.Error) + + data, err := json.Marshal(result.Data) + require.NoError(t, err) + + var emails []map[string]any + err = json.Unmarshal(data, &emails) + require.NoError(t, err) + assert.Len(t, emails, 1) +} + +func TestSearchEmails_MissingQuery(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"no query", map[string]any{}}, + {"empty query", map[string]any{"query": ""}}, + {"wrong type", map[string]any{"query": 123}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + result := executor.Execute(context.Background(), ToolCall{ + Name: "search_emails", + Args: tt.args, + }) + + assert.Contains(t, result.Error, "query parameter is required") + }) + } +} + +func TestSearchEmails_Error(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant") + + client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { + return nil, errors.New("search failed") + } + + result := executor.Execute(context.Background(), ToolCall{ + Name: "search_emails", + Args: map[string]any{"query": "test"}, + }) + + assert.Contains(t, result.Error, "search failed") +} diff --git a/internal/chat/handlers.go b/internal/chat/handlers.go index b0085b2..6742338 100644 --- a/internal/chat/handlers.go +++ b/internal/chat/handlers.go @@ -105,21 +105,39 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { for i := range maxToolIterations { _ = i - response, err := agent.Run(ctx, prompt) - if err != nil { - sendSSE(w, flusher, "error", map[string]string{"error": err.Error()}) - return + // Use streaming when supported + var response string + if agent.SupportsStreaming() { + var streamErr error + response, streamErr = agent.RunStreaming(ctx, prompt, func(token string) { + sendSSE(w, flusher, "token", map[string]string{"text": token}) + }) + if streamErr != nil { + sendSSE(w, flusher, "error", map[string]string{"error": streamErr.Error()}) + return + } + } else { + var runErr error + response, runErr = agent.Run(ctx, prompt) + if runErr != nil { + sendSSE(w, flusher, "error", map[string]string{"error": runErr.Error()}) + return + } } // Parse tool calls from response toolCalls, textResponse := ParseToolCalls(response) if len(toolCalls) == 0 { - // No tool calls - this is the final response + // No tool calls — streamed content is the final response + sendSSE(w, flusher, "stream_end", nil) finalResponse = textResponse break } + // Tool calls found — tell frontend to discard streamed tokens + sendSSE(w, flusher, "stream_discard", nil) + // Execute tool calls and collect results for _, call := range toolCalls { sendSSE(w, flusher, "tool_call", map[string]any{ @@ -127,6 +145,49 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { "args": call.Args, }) + // Check if this tool requires approval + if IsGated(call.Name) { + preview := BuildPreview(call) + pa := s.approvals.Create(call, preview) + + sendSSE(w, flusher, "approval_required", map[string]any{ + "approval_id": pa.ID, + "tool": call.Name, + "preview": preview, + }) + + // Block until user approves/rejects or timeout + decision, _ := pa.Wait() + + sendSSE(w, flusher, "approval_resolved", map[string]any{ + "approval_id": pa.ID, + "approved": decision.Approved, + "reason": decision.Reason, + }) + + if !decision.Approved { + // Inject rejection as tool result + result := ToolResult{ + Name: call.Name, + Error: "Action rejected by user: " + decision.Reason, + } + sendSSE(w, flusher, "tool_result", map[string]any{ + "name": result.Name, + "error": result.Error, + }) + + resultJSON, _ := json.Marshal(result) + _ = s.memory.AddMessage(conv.ID, Message{ + Role: "tool_result", + Name: result.Name, + Content: string(resultJSON), + }) + + prompt += "\n" + FormatToolResult(result) + continue + } + } + result := s.executor.Execute(ctx, call) sendSSE(w, flusher, "tool_result", map[string]any{ diff --git a/internal/chat/handlers_approval.go b/internal/chat/handlers_approval.go new file mode 100644 index 0000000..fdbedd6 --- /dev/null +++ b/internal/chat/handlers_approval.go @@ -0,0 +1,72 @@ +package chat + +import ( + "net/http" + + "github.com/nylas/cli/internal/httputil" +) + +// approvalRequest is the body for approve/reject endpoints. +type approvalRequest struct { + ApprovalID string `json:"approval_id"` + Reason string `json:"reason,omitempty"` +} + +// handleApprove approves a pending tool call. +// POST /api/chat/approve +func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req approvalRequest + if err := httputil.DecodeJSON(w, r, &req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ApprovalID == "" { + http.Error(w, "approval_id is required", http.StatusBadRequest) + return + } + + if !s.approvals.Resolve(req.ApprovalID, ApprovalDecision{Approved: true}) { + http.Error(w, "approval not found or already resolved", http.StatusNotFound) + return + } + + httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "approved"}) +} + +// handleReject rejects a pending tool call. +// POST /api/chat/reject +func (s *Server) handleReject(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req approvalRequest + if err := httputil.DecodeJSON(w, r, &req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ApprovalID == "" { + http.Error(w, "approval_id is required", http.StatusBadRequest) + return + } + + reason := req.Reason + if reason == "" { + reason = "rejected by user" + } + + if !s.approvals.Resolve(req.ApprovalID, ApprovalDecision{Approved: false, Reason: reason}) { + http.Error(w, "approval not found or already resolved", http.StatusNotFound) + return + } + + httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "rejected"}) +} diff --git a/internal/chat/handlers_approval_test.go b/internal/chat/handlers_approval_test.go new file mode 100644 index 0000000..cea8988 --- /dev/null +++ b/internal/chat/handlers_approval_test.go @@ -0,0 +1,232 @@ +package chat + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleApprove(t *testing.T) { + tests := []struct { + name string + method string + body map[string]any + setupApproval bool + approvalID string + expectedStatus int + expectedBody string + }{ + { + name: "wrong method returns 405", + method: http.MethodGet, + body: map[string]any{"approval_id": "approval_1"}, + setupApproval: false, + expectedStatus: http.StatusMethodNotAllowed, + expectedBody: "Method not allowed", + }, + { + name: "missing approval_id returns 400", + method: http.MethodPost, + body: map[string]any{}, + setupApproval: false, + expectedStatus: http.StatusBadRequest, + expectedBody: "approval_id is required", + }, + { + name: "empty approval_id returns 400", + method: http.MethodPost, + body: map[string]any{"approval_id": ""}, + setupApproval: false, + expectedStatus: http.StatusBadRequest, + expectedBody: "approval_id is required", + }, + { + name: "unknown approval_id returns 404", + method: http.MethodPost, + body: map[string]any{"approval_id": "nonexistent"}, + setupApproval: false, + expectedStatus: http.StatusNotFound, + expectedBody: "approval not found or already resolved", + }, + { + name: "valid approval returns 200", + method: http.MethodPost, + body: map[string]any{"approval_id": "approval_1"}, + setupApproval: true, + approvalID: "approval_1", + expectedStatus: http.StatusOK, + expectedBody: `{"status":"approved"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create server with approval store + s := &Server{ + approvals: NewApprovalStore(), + } + + // Setup pending approval if needed + if tt.setupApproval { + pa := s.approvals.Create( + ToolCall{Name: "send_email", Args: map[string]any{"to": "test@example.com"}}, + map[string]any{"to": "test@example.com"}, + ) + require.Equal(t, tt.approvalID, pa.ID) + + // Start goroutine to receive decision + go func() { + decision, ok := pa.Wait() + assert.True(t, ok) + assert.True(t, decision.Approved) + assert.Empty(t, decision.Reason) + }() + } + + // Create request + bodyBytes, err := json.Marshal(tt.body) + require.NoError(t, err) + + req := httptest.NewRequest(tt.method, "/api/chat/approve", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + // Record response + w := httptest.NewRecorder() + + // Call handler + s.handleApprove(w, req) + + // Verify response + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.expectedStatus == http.StatusOK { + // JSON response + assert.JSONEq(t, tt.expectedBody, w.Body.String()) + } else { + // Plain text error + assert.Contains(t, w.Body.String(), tt.expectedBody) + } + }) + } +} + +func TestHandleReject(t *testing.T) { + tests := []struct { + name string + method string + body map[string]any + setupApproval bool + approvalID string + expectedStatus int + expectedBody string + expectedReason string + }{ + { + name: "wrong method returns 405", + method: http.MethodGet, + body: map[string]any{"approval_id": "approval_1"}, + setupApproval: false, + expectedStatus: http.StatusMethodNotAllowed, + expectedBody: "Method not allowed", + }, + { + name: "missing approval_id returns 400", + method: http.MethodPost, + body: map[string]any{}, + setupApproval: false, + expectedStatus: http.StatusBadRequest, + expectedBody: "approval_id is required", + }, + { + name: "empty approval_id returns 400", + method: http.MethodPost, + body: map[string]any{"approval_id": ""}, + setupApproval: false, + expectedStatus: http.StatusBadRequest, + expectedBody: "approval_id is required", + }, + { + name: "unknown approval_id returns 404", + method: http.MethodPost, + body: map[string]any{"approval_id": "nonexistent"}, + setupApproval: false, + expectedStatus: http.StatusNotFound, + expectedBody: "approval not found or already resolved", + }, + { + name: "valid reject with custom reason", + method: http.MethodPost, + body: map[string]any{"approval_id": "approval_1", "reason": "Not authorized"}, + setupApproval: true, + approvalID: "approval_1", + expectedStatus: http.StatusOK, + expectedBody: `{"status":"rejected"}`, + expectedReason: "Not authorized", + }, + { + name: "valid reject with default reason", + method: http.MethodPost, + body: map[string]any{"approval_id": "approval_1"}, + setupApproval: true, + approvalID: "approval_1", + expectedStatus: http.StatusOK, + expectedBody: `{"status":"rejected"}`, + expectedReason: "rejected by user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create server with approval store + s := &Server{ + approvals: NewApprovalStore(), + } + + // Setup pending approval if needed + if tt.setupApproval { + pa := s.approvals.Create( + ToolCall{Name: "create_event", Args: map[string]any{"title": "Meeting"}}, + map[string]any{"title": "Meeting"}, + ) + require.Equal(t, tt.approvalID, pa.ID) + + // Start goroutine to receive decision + go func() { + decision, ok := pa.Wait() + assert.True(t, ok) + assert.False(t, decision.Approved) + assert.Equal(t, tt.expectedReason, decision.Reason) + }() + } + + // Create request + bodyBytes, err := json.Marshal(tt.body) + require.NoError(t, err) + + req := httptest.NewRequest(tt.method, "/api/chat/reject", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + // Record response + w := httptest.NewRecorder() + + // Call handler + s.handleReject(w, req) + + // Verify response + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.expectedStatus == http.StatusOK { + // JSON response + assert.JSONEq(t, tt.expectedBody, w.Body.String()) + } else { + // Plain text error + assert.Contains(t, w.Body.String(), tt.expectedBody) + } + }) + } +} diff --git a/internal/chat/handlers_cmd.go b/internal/chat/handlers_cmd.go new file mode 100644 index 0000000..b11a638 --- /dev/null +++ b/internal/chat/handlers_cmd.go @@ -0,0 +1,130 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/nylas/cli/internal/httputil" +) + +// commandRequest is the body for POST /api/command. +type commandRequest struct { + Name string `json:"name"` + Args string `json:"args"` + ConversationID string `json:"conversation_id"` +} + +// commandResponse is the JSON response from a slash command. +type commandResponse struct { + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` +} + +// handleCommand dispatches slash commands. +// POST /api/command +func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req commandRequest + if err := httputil.DecodeJSON(w, r, &req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "command name is required", http.StatusBadRequest) + return + } + + var resp commandResponse + + switch req.Name { + case "status": + resp = s.cmdStatus() + case "email": + resp = s.cmdEmail(r.Context(), req.Args) + case "calendar": + resp = s.cmdCalendar(r.Context(), req.Args) + case "contacts": + resp = s.cmdContacts(r.Context(), req.Args) + default: + resp = commandResponse{Error: "unknown command: " + req.Name} + } + + httputil.WriteJSON(w, http.StatusOK, resp) +} + +// cmdStatus returns current session info. +func (s *Server) cmdStatus() commandResponse { + agent := s.ActiveAgent() + convs, _ := s.memory.List() + + return commandResponse{ + Content: fmt.Sprintf("**Status**\n- Agent: `%s`\n- Grant ID: `%s`\n- Conversations: %d", + agent.String(), s.grantID, len(convs)), + } +} + +// cmdEmail runs a quick email lookup. +func (s *Server) cmdEmail(ctx context.Context, query string) commandResponse { + args := map[string]any{"limit": float64(5)} + if query != "" { + // Use as search query + result := s.executor.Execute(ctx, ToolCall{Name: "search_emails", Args: map[string]any{ + "query": query, + "limit": float64(5), + }}) + return toolResultToResponse(result, "emails") + } + + result := s.executor.Execute(ctx, ToolCall{Name: "list_emails", Args: args}) + return toolResultToResponse(result, "emails") +} + +// cmdCalendar lists upcoming events. +func (s *Server) cmdCalendar(ctx context.Context, daysStr string) commandResponse { + limit := float64(10) + if daysStr != "" { + if n, err := strconv.Atoi(daysStr); err == nil && n > 0 { + limit = float64(n) + } + } + + result := s.executor.Execute(ctx, ToolCall{Name: "list_events", Args: map[string]any{ + "limit": limit, + }}) + return toolResultToResponse(result, "events") +} + +// cmdContacts searches contacts. +func (s *Server) cmdContacts(ctx context.Context, query string) commandResponse { + args := map[string]any{"limit": float64(10)} + if query != "" { + args["query"] = query + } + + result := s.executor.Execute(ctx, ToolCall{Name: "list_contacts", Args: args}) + return toolResultToResponse(result, "contacts") +} + +// toolResultToResponse converts a ToolResult into a command response. +func toolResultToResponse(result ToolResult, label string) commandResponse { + if result.Error != "" { + return commandResponse{Error: result.Error} + } + + data, err := json.MarshalIndent(result.Data, "", " ") + if err != nil { + return commandResponse{Error: "failed to format results"} + } + + return commandResponse{ + Content: fmt.Sprintf("**%s results:**\n```json\n%s\n```", label, string(data)), + } +} diff --git a/internal/chat/handlers_cmd_test.go b/internal/chat/handlers_cmd_test.go new file mode 100644 index 0000000..383a61a --- /dev/null +++ b/internal/chat/handlers_cmd_test.go @@ -0,0 +1,270 @@ +package chat + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupTestServer creates a test server with mocked dependencies. +func setupTestServer(t *testing.T) *Server { + t.Helper() + memory := setupMemoryStore(t) + mockClient := nylas.NewMockClient() + agent := &Agent{Type: AgentClaude, Version: "1.0"} + executor := NewToolExecutor(mockClient, "test-grant") + + return &Server{ + agent: agent, + grantID: "test-grant", + memory: memory, + executor: executor, + } +} + +func TestHandleCommand(t *testing.T) { + tests := []struct { + name string + method string + body commandRequest + wantStatus int + wantErrMessage string + }{ + { + name: "rejects non-POST method", + method: http.MethodGet, + body: commandRequest{}, + wantStatus: http.StatusMethodNotAllowed, + wantErrMessage: "Method not allowed", + }, + { + name: "rejects missing command name", + method: http.MethodPost, + body: commandRequest{Name: ""}, + wantStatus: http.StatusBadRequest, + wantErrMessage: "command name is required", + }, + { + name: "handles unknown command", + method: http.MethodPost, + body: commandRequest{Name: "invalid"}, + wantStatus: http.StatusOK, + }, + { + name: "handles status command", + method: http.MethodPost, + body: commandRequest{Name: "status"}, + wantStatus: http.StatusOK, + }, + { + name: "handles email command", + method: http.MethodPost, + body: commandRequest{Name: "email", Args: "test"}, + wantStatus: http.StatusOK, + }, + { + name: "handles calendar command", + method: http.MethodPost, + body: commandRequest{Name: "calendar", Args: "7"}, + wantStatus: http.StatusOK, + }, + { + name: "handles contacts command", + method: http.MethodPost, + body: commandRequest{Name: "contacts", Args: "john"}, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupTestServer(t) + + // Create request + var body []byte + if tt.method == http.MethodPost { + body, _ = json.Marshal(tt.body) + } + req := httptest.NewRequest(tt.method, "/api/command", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Execute + server.handleCommand(w, req) + + // Verify status code + assert.Equal(t, tt.wantStatus, w.Code) + + // Verify error message for non-200 responses + if tt.wantErrMessage != "" { + assert.Contains(t, w.Body.String(), tt.wantErrMessage) + } + + // Verify successful responses have proper structure + if tt.wantStatus == http.StatusOK { + var resp commandResponse + err := json.NewDecoder(w.Body).Decode(&resp) + require.NoError(t, err) + + // Unknown command returns error + if tt.body.Name == "invalid" { + assert.Contains(t, resp.Error, "unknown command") + } else { + // Valid commands return content or data + assert.True(t, resp.Content != "" || resp.Error == "") + } + } + }) + } +} + +func TestCmdStatus(t *testing.T) { + tests := []struct { + name string + agent *Agent + grantID string + convCount int + }{ + { + name: "returns status with claude agent", + agent: &Agent{Type: AgentClaude, Version: "1.0"}, + grantID: "grant-123", + convCount: 0, + }, + { + name: "returns status with ollama agent", + agent: &Agent{Type: AgentOllama, Model: "mistral"}, + grantID: "grant-456", + convCount: 3, + }, + { + name: "handles empty grant ID", + agent: &Agent{Type: AgentClaude}, + grantID: "", + convCount: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + memory := setupMemoryStore(t) + // Create conversations + for i := 0; i < tt.convCount; i++ { + _, _ = memory.Create("test-agent") + } + + server := &Server{ + agent: tt.agent, + grantID: tt.grantID, + memory: memory, + } + + resp := server.cmdStatus() + + // Verify response structure + require.NotEmpty(t, resp.Content) + assert.Contains(t, resp.Content, "Status") + assert.Contains(t, resp.Content, "Agent:") + assert.Contains(t, resp.Content, "Grant ID:") + assert.Contains(t, resp.Content, "Conversations:") + assert.Empty(t, resp.Error) + + // Verify agent type is included + assert.Contains(t, resp.Content, string(tt.agent.Type)) + + // Verify grant ID is included + if tt.grantID != "" { + assert.Contains(t, resp.Content, tt.grantID) + } + }) + } +} + +func TestToolResultToResponse(t *testing.T) { + tests := []struct { + name string + result ToolResult + label string + wantContent bool + wantError string + }{ + { + name: "converts result with data", + result: ToolResult{ + Name: "test", + Data: map[string]string{"key": "value"}, + }, + label: "emails", + wantContent: true, + }, + { + name: "converts result with array data", + result: ToolResult{ + Name: "test", + Data: []map[string]string{{"id": "1"}, {"id": "2"}}, + }, + label: "events", + wantContent: true, + }, + { + name: "converts result with error", + result: ToolResult{ + Name: "test", + Error: "api error occurred", + }, + label: "contacts", + wantError: "api error occurred", + }, + { + name: "handles nil data", + result: ToolResult{ + Name: "test", + Data: nil, + }, + label: "results", + wantContent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := toolResultToResponse(tt.result, tt.label) + + if tt.wantError != "" { + assert.Equal(t, tt.wantError, resp.Error) + assert.Empty(t, resp.Content) + } else if tt.wantContent { + assert.NotEmpty(t, resp.Content) + assert.Contains(t, resp.Content, tt.label) + assert.Contains(t, resp.Content, "```json") + assert.Empty(t, resp.Error) + + // Verify JSON formatting + if tt.result.Data != nil { + dataJSON, _ := json.MarshalIndent(tt.result.Data, "", " ") + assert.Contains(t, resp.Content, string(dataJSON)) + } + } + }) + } + + t.Run("handles marshal failure gracefully", func(t *testing.T) { + // Create unmarshalable data (channels can't be marshaled) + ch := make(chan int) + result := ToolResult{ + Name: "test", + Data: ch, + } + + resp := toolResultToResponse(result, "test") + + assert.Empty(t, resp.Content) + assert.Equal(t, "failed to format results", resp.Error) + }) +} diff --git a/internal/chat/handlers_conv_test.go b/internal/chat/handlers_conv_test.go new file mode 100644 index 0000000..c7670cc --- /dev/null +++ b/internal/chat/handlers_conv_test.go @@ -0,0 +1,569 @@ +package chat + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupServerWithMemory creates a test server with memory store. +func setupServerWithMemory(t *testing.T) *Server { + t.Helper() + memory := setupMemoryStore(t) + agent := &Agent{Type: AgentClaude, Version: "1.0"} + return &Server{ + agent: agent, + memory: memory, + } +} + +func TestHandleConversations(t *testing.T) { + tests := []struct { + name string + method string + setupConvs int // number of conversations to create before request + expectedStatus int + checkResponse func(t *testing.T, body string) + }{ + { + name: "GET returns empty array when no conversations", + method: http.MethodGet, + setupConvs: 0, + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body string) { + var convs []ConversationSummary + err := json.Unmarshal([]byte(body), &convs) + require.NoError(t, err) + assert.Empty(t, convs) + }, + }, + { + name: "GET lists all conversations", + method: http.MethodGet, + setupConvs: 3, + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body string) { + var convs []ConversationSummary + err := json.Unmarshal([]byte(body), &convs) + require.NoError(t, err) + assert.Len(t, convs, 3) + for _, conv := range convs { + assert.NotEmpty(t, conv.ID) + assert.NotEmpty(t, conv.Title) + assert.NotEmpty(t, conv.Agent) + } + }, + }, + { + name: "POST creates new conversation", + method: http.MethodPost, + setupConvs: 0, + expectedStatus: http.StatusCreated, + checkResponse: func(t *testing.T, body string) { + var resp map[string]string + err := json.Unmarshal([]byte(body), &resp) + require.NoError(t, err) + assert.NotEmpty(t, resp["id"]) + assert.Equal(t, "New conversation", resp["title"]) + }, + }, + { + name: "PUT returns method not allowed", + method: http.MethodPut, + setupConvs: 0, + expectedStatus: http.StatusMethodNotAllowed, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "Method not allowed") + }, + }, + { + name: "DELETE returns method not allowed", + method: http.MethodDelete, + setupConvs: 0, + expectedStatus: http.StatusMethodNotAllowed, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "Method not allowed") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupServerWithMemory(t) + + // Setup: create conversations if needed + for i := 0; i < tt.setupConvs; i++ { + _, err := server.memory.Create("test-agent") + require.NoError(t, err) + } + + // Create request + req := httptest.NewRequest(tt.method, "/api/conversations", nil) + w := httptest.NewRecorder() + + // Execute + server.handleConversations(w, req) + + // Verify status code + assert.Equal(t, tt.expectedStatus, w.Code) + + // Verify response body + if tt.checkResponse != nil { + tt.checkResponse(t, w.Body.String()) + } + }) + } +} + +func TestHandleConversationByID(t *testing.T) { + tests := []struct { + name string + method string + setupConv bool // whether to create a conversation first + urlPath string // URL path to use (empty = use created conv ID) + expectedStatus int + checkResponse func(t *testing.T, body string) + }{ + { + name: "GET retrieves existing conversation", + method: http.MethodGet, + setupConv: true, + urlPath: "", + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body string) { + var conv Conversation + err := json.Unmarshal([]byte(body), &conv) + require.NoError(t, err) + assert.NotEmpty(t, conv.ID) + assert.Equal(t, "New conversation", conv.Title) + assert.NotNil(t, conv.Messages) + }, + }, + { + name: "GET returns 404 for non-existent conversation", + method: http.MethodGet, + setupConv: false, + urlPath: "/api/conversations/nonexistent", + expectedStatus: http.StatusNotFound, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "Conversation not found") + }, + }, + { + name: "GET returns 400 for empty ID", + method: http.MethodGet, + setupConv: false, + urlPath: "/api/conversations/", + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "conversation ID required") + }, + }, + { + name: "DELETE removes existing conversation", + method: http.MethodDelete, + setupConv: true, + urlPath: "", + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body string) { + var resp map[string]string + err := json.Unmarshal([]byte(body), &resp) + require.NoError(t, err) + assert.Equal(t, "deleted", resp["status"]) + }, + }, + { + name: "DELETE returns 404 for non-existent conversation", + method: http.MethodDelete, + setupConv: false, + urlPath: "/api/conversations/nonexistent", + expectedStatus: http.StatusNotFound, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "Failed to delete conversation") + }, + }, + { + name: "DELETE returns 400 for empty ID", + method: http.MethodDelete, + setupConv: false, + urlPath: "/api/conversations/", + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "conversation ID required") + }, + }, + { + name: "POST returns method not allowed", + method: http.MethodPost, + setupConv: true, + urlPath: "", + expectedStatus: http.StatusMethodNotAllowed, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "Method not allowed") + }, + }, + { + name: "PUT returns method not allowed", + method: http.MethodPut, + setupConv: true, + urlPath: "", + expectedStatus: http.StatusMethodNotAllowed, + checkResponse: func(t *testing.T, body string) { + assert.Contains(t, body, "Method not allowed") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupServerWithMemory(t) + + // Setup: create conversation if needed + var convID string + if tt.setupConv { + conv, err := server.memory.Create("test-agent") + require.NoError(t, err) + convID = conv.ID + } + + // Build URL path + urlPath := tt.urlPath + if urlPath == "" && tt.setupConv { + urlPath = "/api/conversations/" + convID + } + + // Create request + req := httptest.NewRequest(tt.method, urlPath, nil) + w := httptest.NewRecorder() + + // Execute + server.handleConversationByID(w, req) + + // Verify status code + assert.Equal(t, tt.expectedStatus, w.Code) + + // Verify response body + if tt.checkResponse != nil { + tt.checkResponse(t, w.Body.String()) + } + }) + } +} + +func TestListConversations(t *testing.T) { + tests := []struct { + name string + setupConvs int + expectedCount int + }{ + { + name: "returns empty array when no conversations", + setupConvs: 0, + expectedCount: 0, + }, + { + name: "lists single conversation", + setupConvs: 1, + expectedCount: 1, + }, + { + name: "lists multiple conversations", + setupConvs: 5, + expectedCount: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupServerWithMemory(t) + + // Setup: create conversations + for i := 0; i < tt.setupConvs; i++ { + _, err := server.memory.Create("test-agent") + require.NoError(t, err) + } + + // Create request + req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil) + w := httptest.NewRecorder() + + // Execute + server.listConversations(w, req) + + // Verify response + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + + var convs []ConversationSummary + err := json.Unmarshal(w.Body.Bytes(), &convs) + require.NoError(t, err) + assert.Len(t, convs, tt.expectedCount) + }) + } +} + +func TestCreateConversation(t *testing.T) { + tests := []struct { + name string + agentType AgentType + }{ + { + name: "creates conversation with claude agent", + agentType: AgentClaude, + }, + { + name: "creates conversation with ollama agent", + agentType: AgentOllama, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + memory := setupMemoryStore(t) + server := &Server{ + agent: &Agent{Type: tt.agentType}, + memory: memory, + } + + // Create request + req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil) + w := httptest.NewRecorder() + + // Execute + server.createConversation(w, req) + + // Verify response + assert.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + + var resp map[string]string + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.NotEmpty(t, resp["id"]) + assert.Equal(t, "New conversation", resp["title"]) + + // Verify conversation was actually created + conv, err := memory.Get(resp["id"]) + require.NoError(t, err) + assert.Equal(t, resp["id"], conv.ID) + assert.Equal(t, string(tt.agentType), conv.Agent) + }) + } +} + +func TestGetConversation(t *testing.T) { + tests := []struct { + name string + setupConv bool + convID string + expectedStatus int + }{ + { + name: "retrieves existing conversation", + setupConv: true, + convID: "", + expectedStatus: http.StatusOK, + }, + { + name: "returns 404 for non-existent conversation", + setupConv: false, + convID: "nonexistent", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupServerWithMemory(t) + + // Setup: create conversation if needed + convID := tt.convID + if tt.setupConv { + conv, err := server.memory.Create("test-agent") + require.NoError(t, err) + convID = conv.ID + } + + // Create request + w := httptest.NewRecorder() + + // Execute + server.getConversation(w, convID) + + // Verify status + assert.Equal(t, tt.expectedStatus, w.Code) + + // Verify response body for successful retrieval + if tt.expectedStatus == http.StatusOK { + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + var conv Conversation + err := json.Unmarshal(w.Body.Bytes(), &conv) + require.NoError(t, err) + assert.Equal(t, convID, conv.ID) + assert.NotNil(t, conv.Messages) + } + }) + } +} + +func TestDeleteConversation(t *testing.T) { + tests := []struct { + name string + setupConv bool + convID string + expectedStatus int + }{ + { + name: "deletes existing conversation", + setupConv: true, + convID: "", + expectedStatus: http.StatusOK, + }, + { + name: "returns 404 for non-existent conversation", + setupConv: false, + convID: "nonexistent", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupServerWithMemory(t) + + // Setup: create conversation if needed + convID := tt.convID + if tt.setupConv { + conv, err := server.memory.Create("test-agent") + require.NoError(t, err) + convID = conv.ID + } + + // Create request + w := httptest.NewRecorder() + + // Execute + server.deleteConversation(w, convID) + + // Verify status + assert.Equal(t, tt.expectedStatus, w.Code) + + // Verify response body + if tt.expectedStatus == http.StatusOK { + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + var resp map[string]string + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Equal(t, "deleted", resp["status"]) + + // Verify conversation is actually deleted + _, err = server.memory.Get(convID) + assert.Error(t, err) + } + }) + } +} + +func TestConversationEndToEnd(t *testing.T) { + server := setupServerWithMemory(t) + + // List conversations (should be empty) + req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil) + w := httptest.NewRecorder() + server.handleConversations(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var convs []ConversationSummary + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &convs)) + assert.Empty(t, convs) + + // Create a conversation + req = httptest.NewRequest(http.MethodPost, "/api/conversations", nil) + w = httptest.NewRecorder() + server.handleConversations(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + var createResp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &createResp)) + convID := createResp["id"] + assert.NotEmpty(t, convID) + + // Get the conversation by ID + req = httptest.NewRequest(http.MethodGet, "/api/conversations/"+convID, nil) + w = httptest.NewRecorder() + server.handleConversationByID(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var conv Conversation + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conv)) + assert.Equal(t, convID, conv.ID) + + // List conversations (should have one) + req = httptest.NewRequest(http.MethodGet, "/api/conversations", nil) + w = httptest.NewRecorder() + server.handleConversations(w, req) + assert.Equal(t, http.StatusOK, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &convs)) + assert.Len(t, convs, 1) + assert.Equal(t, convID, convs[0].ID) + + // Delete the conversation + req = httptest.NewRequest(http.MethodDelete, "/api/conversations/"+convID, nil) + w = httptest.NewRecorder() + server.handleConversationByID(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // Verify it's deleted + req = httptest.NewRequest(http.MethodGet, "/api/conversations/"+convID, nil) + w = httptest.NewRecorder() + server.handleConversationByID(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestMemoryStoreFailure(t *testing.T) { + t.Run("listConversations handles deleted directory", func(t *testing.T) { + tempDir := t.TempDir() + memory, err := NewMemoryStore(tempDir) + require.NoError(t, err) + server := &Server{agent: &Agent{Type: AgentClaude}, memory: memory} + + _ = os.RemoveAll(tempDir) + req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil) + w := httptest.NewRecorder() + server.listConversations(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("createConversation handles permission error", func(t *testing.T) { + tempDir := t.TempDir() + memory, err := NewMemoryStore(tempDir) + require.NoError(t, err) + require.NoError(t, os.Chmod(tempDir, 0444)) + t.Cleanup(func() { _ = os.Chmod(tempDir, 0755) }) + + server := &Server{agent: &Agent{Type: AgentClaude}, memory: memory} + req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil) + w := httptest.NewRecorder() + server.createConversation(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to create conversation") + }) + + t.Run("getConversation handles corrupted file", func(t *testing.T) { + tempDir := t.TempDir() + memory, err := NewMemoryStore(tempDir) + require.NoError(t, err) + + corruptedPath := filepath.Join(tempDir, "corrupt.json") + require.NoError(t, os.WriteFile(corruptedPath, []byte("not valid json"), 0600)) + + server := &Server{agent: &Agent{Type: AgentClaude}, memory: memory} + w := httptest.NewRecorder() + server.getConversation(w, "corrupt") + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} diff --git a/internal/chat/prompt_test.go b/internal/chat/prompt_test.go new file mode 100644 index 0000000..06a1c62 --- /dev/null +++ b/internal/chat/prompt_test.go @@ -0,0 +1,102 @@ +package chat + +import ( + "strings" + "testing" +) + +func TestBuildSystemPrompt(t *testing.T) { + tests := []struct { + name string + grantID string + agentType AgentType + want []string // Expected substrings in the output + }{ + { + name: "claude agent with grant ID", + grantID: "grant-123", + agentType: AgentClaude, + want: []string{ + "You are a helpful email and calendar assistant", + "Grant ID: grant-123", + "## Tool Usage", + "TOOL_CALL:", + "## Conversation Context", + "## Response Format", + "Use markdown formatting", + }, + }, + { + name: "codex agent with grant ID", + grantID: "grant-456", + agentType: AgentCodex, + want: []string{ + "Grant ID: grant-456", + "TOOL_CALL:", + }, + }, + { + name: "ollama agent with grant ID", + grantID: "grant-789", + agentType: AgentOllama, + want: []string{ + "Grant ID: grant-789", + }, + }, + { + name: "empty grant ID", + grantID: "", + agentType: AgentClaude, + want: []string{ + "Grant ID: ", + "You are a helpful email and calendar assistant", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildSystemPrompt(tt.grantID, tt.agentType) + + // Verify the output is non-empty + if got == "" { + t.Error("BuildSystemPrompt returned empty string") + } + + // Check that all expected substrings are present + for _, want := range tt.want { + if !strings.Contains(got, want) { + t.Errorf("BuildSystemPrompt output missing expected substring:\nwant: %q\ngot: %s", want, got) + } + } + }) + } +} + +func TestBuildSystemPrompt_Structure(t *testing.T) { + grantID := "test-grant" + agentType := AgentClaude + prompt := BuildSystemPrompt(grantID, agentType) + + // Verify key sections are present in order + sections := []string{ + "You are a helpful email and calendar assistant", + "Grant ID:", + "## Tool Usage", + "## Conversation Context", + "## Response Format", + } + + lastIndex := -1 + for _, section := range sections { + index := strings.Index(prompt, section) + if index == -1 { + t.Errorf("Missing section in prompt: %q", section) + continue + } + if index <= lastIndex { + t.Errorf("Section %q appears before previous section (out of order)", section) + } + lastIndex = index + } +} diff --git a/internal/chat/server.go b/internal/chat/server.go index 6f022a8..7acb800 100644 --- a/internal/chat/server.go +++ b/internal/chat/server.go @@ -19,17 +19,18 @@ var templateFiles embed.FS // Server is the chat web UI HTTP server. type Server struct { - addr string - agent *Agent - agents []Agent - agentMu sync.RWMutex // protects agent switching - nylas ports.NylasClient - grantID string - memory *MemoryStore - executor *ToolExecutor - context *ContextBuilder - session *ActiveSession - tmpl *template.Template + addr string + agent *Agent + agents []Agent + agentMu sync.RWMutex // protects agent switching + nylas ports.NylasClient + grantID string + memory *MemoryStore + executor *ToolExecutor + context *ContextBuilder + session *ActiveSession + approvals *ApprovalStore + tmpl *template.Template } // ActiveAgent returns the current agent (thread-safe). @@ -60,16 +61,17 @@ func NewServer(addr string, agent *Agent, agents []Agent, nylas ports.NylasClien tmpl, _ := template.New("").ParseFS(templateFiles, "templates/*.gohtml") return &Server{ - addr: addr, - agent: agent, - agents: agents, - nylas: nylas, - grantID: grantID, - memory: memory, - executor: executor, - context: ctx, - session: NewActiveSession(), - tmpl: tmpl, + addr: addr, + agent: agent, + agents: agents, + nylas: nylas, + grantID: grantID, + memory: memory, + executor: executor, + context: ctx, + session: NewActiveSession(), + approvals: NewApprovalStore(), + tmpl: tmpl, } } @@ -79,6 +81,9 @@ func (s *Server) Start() error { // API routes mux.HandleFunc("/api/chat", s.handleChat) + mux.HandleFunc("/api/chat/approve", s.handleApprove) + mux.HandleFunc("/api/chat/reject", s.handleReject) + mux.HandleFunc("/api/command", s.handleCommand) mux.HandleFunc("/api/conversations", s.handleConversations) mux.HandleFunc("/api/conversations/", s.handleConversationByID) mux.HandleFunc("/api/config", s.handleConfig) @@ -103,7 +108,7 @@ func (s *Server) Start() error { Addr: s.addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second, - WriteTimeout: 120 * time.Second, // long for SSE streaming + WriteTimeout: 360 * time.Second, // long for SSE streaming + approval gating IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 20, } diff --git a/internal/chat/session_test.go b/internal/chat/session_test.go new file mode 100644 index 0000000..ec5bc62 --- /dev/null +++ b/internal/chat/session_test.go @@ -0,0 +1,143 @@ +package chat + +import ( + "sync" + "testing" +) + +func TestNewActiveSession(t *testing.T) { + session := NewActiveSession() + + if session == nil { + t.Fatal("NewActiveSession returned nil") + } + + if session.Get() != "" { + t.Errorf("NewActiveSession should return empty conversation ID, got: %q", session.Get()) + } +} + +func TestActiveSession_GetSet(t *testing.T) { + tests := []struct { + name string + conversationID string + }{ + { + name: "simple ID", + conversationID: "conv-123", + }, + { + name: "empty ID", + conversationID: "", + }, + { + name: "long ID", + conversationID: "conversation-with-very-long-identifier-12345678901234567890", + }, + { + name: "ID with special characters", + conversationID: "conv-123_test@2024", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := NewActiveSession() + + // Set the conversation ID + session.Set(tt.conversationID) + + // Get the conversation ID + got := session.Get() + + if got != tt.conversationID { + t.Errorf("Get() = %q, want %q", got, tt.conversationID) + } + }) + } +} + +func TestActiveSession_Overwrite(t *testing.T) { + session := NewActiveSession() + + // Set first value + session.Set("first-conv") + if got := session.Get(); got != "first-conv" { + t.Errorf("After first Set(), Get() = %q, want %q", got, "first-conv") + } + + // Overwrite with second value + session.Set("second-conv") + if got := session.Get(); got != "second-conv" { + t.Errorf("After second Set(), Get() = %q, want %q", got, "second-conv") + } + + // Overwrite with empty value + session.Set("") + if got := session.Get(); got != "" { + t.Errorf("After Set(\"\"), Get() = %q, want \"\"", got) + } +} + +func TestActiveSession_Concurrent(t *testing.T) { + session := NewActiveSession() + const goroutines = 100 + const iterations = 100 + + var wg sync.WaitGroup + wg.Add(goroutines * 2) // readers and writers + + // Concurrent writers + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + session.Set(string(rune(id))) + } + }(i) + } + + // Concurrent readers + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + _ = session.Get() + } + }() + } + + wg.Wait() + + // If we got here without race detector warnings, the test passed + // The final value doesn't matter - we just verify thread safety + _ = session.Get() +} + +func TestActiveSession_ConcurrentReadWrite(t *testing.T) { + session := NewActiveSession() + done := make(chan bool) + + // Writer goroutine + go func() { + for i := 0; i < 1000; i++ { + session.Set("write") + } + done <- true + }() + + // Reader goroutine + go func() { + for i := 0; i < 1000; i++ { + _ = session.Get() + } + done <- true + }() + + // Wait for both to complete + <-done + <-done + + // Verify final state is accessible + _ = session.Get() +} diff --git a/internal/chat/static/css/chat.css b/internal/chat/static/css/chat.css index 2a2ae69..311f921 100644 --- a/internal/chat/static/css/chat.css +++ b/internal/chat/static/css/chat.css @@ -289,152 +289,6 @@ body { background: var(--accent-subtle); } -/* Messages */ -.message { - display: flex; - margin-bottom: 20px; - max-width: 720px; - margin-left: auto; - margin-right: auto; - width: 100%; -} - -.message.user { justify-content: flex-end; } - -.message-content { - padding: 12px 16px; - border-radius: 18px; - font-size: 14px; - line-height: 1.6; - word-break: break-word; - max-width: 85%; -} - -.message.user .message-content { - background: var(--bg-message-user); - border-radius: 18px 18px 4px 18px; -} - -.message.assistant .message-content { - background: var(--bg-message-assistant); - padding-left: 0; - padding-right: 0; - max-width: 100%; -} - -.message-content p { margin-bottom: 10px; } -.message-content p:last-child { margin-bottom: 0; } - -.message-content code { - background: rgba(139,92,246,0.1); - padding: 2px 6px; - border-radius: 4px; - font-size: 13px; - font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; -} - -.message-content pre { - background: rgba(0,0,0,0.4); - padding: 14px 16px; - border-radius: var(--radius-sm); - overflow-x: auto; - margin: 10px 0; - font-size: 13px; - line-height: 1.5; -} - -@media (prefers-color-scheme: light) { - .message-content pre { background: #f3f4f6; } -} - -.message-content pre code { - background: none; - padding: 0; - font-size: inherit; -} - -.message-content ul, .message-content ol { - padding-left: 20px; - margin: 6px 0; -} - -.message-content li { margin-bottom: 4px; } - -.message-content a { - color: var(--accent); - text-decoration: none; -} - -.message-content a:hover { text-decoration: underline; } - -/* Tool Call Indicators */ -.tool-indicator { - font-size: 12px; - color: var(--text-secondary); - padding: 8px 14px; - background: var(--bg-tool); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - margin-bottom: 10px; - max-width: 720px; - margin-left: auto; - margin-right: auto; - cursor: pointer; - transition: background var(--transition); -} - -.tool-indicator:hover { background: var(--bg-hover); } -.tool-indicator .tool-name { font-weight: 600; color: var(--accent); } - -.tool-details { - display: none; - font-size: 12px; - padding: 10px; - background: rgba(0,0,0,0.15); - border-radius: 6px; - margin-top: 6px; - white-space: pre-wrap; - max-height: 200px; - overflow-y: auto; - font-family: 'SF Mono', 'Fira Code', monospace; - line-height: 1.5; -} - -@media (prefers-color-scheme: light) { - .tool-details { background: rgba(0,0,0,0.04); } -} - -.tool-indicator.expanded .tool-details { display: block; } - -/* Thinking Indicator */ -.thinking { - display: flex; - align-items: center; - gap: 10px; - color: var(--text-muted); - font-size: 13px; - padding: 12px 0; - max-width: 720px; - margin: 0 auto; -} - -.thinking-dots span { - display: inline-block; - width: 5px; - height: 5px; - background: var(--accent); - border-radius: 50%; - animation: pulse 1.4s infinite ease-in-out; -} - -.thinking-dots span:nth-child(2) { animation-delay: 0.2s; } -.thinking-dots span:nth-child(3) { animation-delay: 0.4s; } - -@keyframes pulse { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1); } -} - /* Input Area */ .input-area { padding: 16px 24px 24px; diff --git a/internal/chat/static/css/components.css b/internal/chat/static/css/components.css new file mode 100644 index 0000000..ae7fe66 --- /dev/null +++ b/internal/chat/static/css/components.css @@ -0,0 +1,280 @@ +/* Message and Component Styles */ + +/* Messages */ +.message { + display: flex; + margin-bottom: 20px; + max-width: 720px; + margin-left: auto; + margin-right: auto; + width: 100%; +} + +.message.user { justify-content: flex-end; } + +.message-content { + padding: 12px 16px; + border-radius: 18px; + font-size: 14px; + line-height: 1.6; + word-break: break-word; + max-width: 85%; +} + +.message.user .message-content { + background: var(--bg-message-user); + border-radius: 18px 18px 4px 18px; +} + +.message.assistant .message-content { + background: var(--bg-message-assistant); + padding-left: 0; + padding-right: 0; + max-width: 100%; +} + +.message-content p { margin-bottom: 10px; } +.message-content p:last-child { margin-bottom: 0; } + +.message-content code { + background: rgba(139,92,246,0.1); + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +.message-content pre { + background: rgba(0,0,0,0.4); + padding: 14px 16px; + border-radius: var(--radius-sm); + overflow-x: auto; + margin: 10px 0; + font-size: 13px; + line-height: 1.5; +} + +@media (prefers-color-scheme: light) { + .message-content pre { background: #f3f4f6; } +} + +.message-content pre code { + background: none; + padding: 0; + font-size: inherit; +} + +.message-content ul, .message-content ol { + padding-left: 20px; + margin: 6px 0; +} + +.message-content li { margin-bottom: 4px; } + +.message-content a { + color: var(--accent); + text-decoration: none; +} + +.message-content a:hover { text-decoration: underline; } + +/* Tool Call Indicators */ +.tool-indicator { + font-size: 12px; + color: var(--text-secondary); + padding: 8px 14px; + background: var(--bg-tool); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + margin-bottom: 10px; + max-width: 720px; + margin-left: auto; + margin-right: auto; + cursor: pointer; + transition: background var(--transition); +} + +.tool-indicator:hover { background: var(--bg-hover); } +.tool-indicator .tool-name { font-weight: 600; color: var(--accent); } + +.tool-details { + display: none; + font-size: 12px; + padding: 10px; + background: rgba(0,0,0,0.15); + border-radius: 6px; + margin-top: 6px; + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; + font-family: 'SF Mono', 'Fira Code', monospace; + line-height: 1.5; +} + +@media (prefers-color-scheme: light) { + .tool-details { background: rgba(0,0,0,0.04); } +} + +.tool-indicator.expanded .tool-details { display: block; } + +/* Thinking Indicator */ +.thinking { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-muted); + font-size: 13px; + padding: 12px 0; + max-width: 720px; + margin: 0 auto; +} + +.thinking-dots span { + display: inline-block; + width: 5px; + height: 5px; + background: var(--accent); + border-radius: 50%; + animation: pulse 1.4s infinite ease-in-out; +} + +.thinking-dots span:nth-child(2) { animation-delay: 0.2s; } +.thinking-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes pulse { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1); } +} + +/* System Messages */ +.message.system { + justify-content: center; +} + +.message.system .message-content { + background: var(--bg-tool); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 13px; + max-width: 90%; + padding: 10px 16px; +} + +.message.system .message-content code { + background: rgba(139,92,246,0.15); + font-size: 12px; +} + +/* Approval Cards */ +.approval-card { + max-width: 720px; + margin: 0 auto 16px; + background: var(--bg-tool); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: 16px; + animation: slideIn 0.2s ease; +} + +.approval-card .approval-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-weight: 600; + font-size: 14px; + color: var(--accent); +} + +.approval-card .approval-preview { + background: rgba(0,0,0,0.15); + border-radius: var(--radius-sm); + padding: 12px; + margin-bottom: 14px; + font-size: 13px; + line-height: 1.5; +} + +@media (prefers-color-scheme: light) { + .approval-card .approval-preview { background: rgba(0,0,0,0.04); } +} + +.approval-card .approval-preview dt { + font-weight: 600; + color: var(--text-secondary); + margin-top: 6px; +} + +.approval-card .approval-preview dt:first-child { margin-top: 0; } + +.approval-card .approval-preview dd { + margin-left: 0; + color: var(--text-primary); + word-break: break-word; +} + +.approval-card .approval-actions { + display: flex; + gap: 8px; +} + +.btn-approve, .btn-reject { + padding: 8px 20px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; + font-weight: 500; + font-family: inherit; + transition: all var(--transition); +} + +.btn-approve { + background: var(--success); + color: #000; +} + +.btn-approve:hover { filter: brightness(1.1); } + +.btn-reject { + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); +} + +.btn-reject:hover { background: rgba(248,113,113,0.1); } + +.approval-card.resolved { + opacity: 0.6; + border-color: var(--border-color); +} + +.approval-card.resolved .approval-actions { display: none; } + +.approval-card.resolved .approval-status { + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + +@keyframes slideIn { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Streaming Cursor */ +.message.streaming .message-content::after { + content: ''; + display: inline-block; + width: 7px; + height: 16px; + background: var(--accent); + margin-left: 2px; + vertical-align: text-bottom; + animation: blink 1s step-end infinite; +} + +@keyframes blink { + 50% { opacity: 0; } +} diff --git a/internal/chat/static/js/api.js b/internal/chat/static/js/api.js index 8246383..5cce14e 100644 --- a/internal/chat/static/js/api.js +++ b/internal/chat/static/js/api.js @@ -73,4 +73,36 @@ const ChatAPI = { if (!resp.ok) throw new Error(await resp.text()); return resp.json(); }, + + async executeCommand(name, args, conversationId) { + const body = { name, args }; + if (conversationId) body.conversation_id = conversationId; + const resp = await fetch('/api/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(await resp.text()); + return resp.json(); + }, + + async approveAction(approvalId) { + const resp = await fetch('/api/chat/approve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approval_id: approvalId }), + }); + if (!resp.ok) throw new Error(await resp.text()); + return resp.json(); + }, + + async rejectAction(approvalId, reason) { + const resp = await fetch('/api/chat/reject', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approval_id: approvalId, reason: reason || '' }), + }); + if (!resp.ok) throw new Error(await resp.text()); + return resp.json(); + }, }; diff --git a/internal/chat/static/js/chat.js b/internal/chat/static/js/chat.js index 573c31c..4d795c5 100644 --- a/internal/chat/static/js/chat.js +++ b/internal/chat/static/js/chat.js @@ -2,6 +2,8 @@ const Chat = { conversationId: null, sending: false, + streamingEl: null, + streamingContent: '', init() { const form = document.getElementById('chat-form'); @@ -18,9 +20,13 @@ const Chat = { input.style.height = Math.min(input.scrollHeight, 120) + 'px'; }); - // Enter to send, Shift+Enter for newline + // Enter to send, Shift+Enter for newline; Tab for command completion input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { + if (e.key === 'Tab' && input.value.startsWith('/')) { + e.preventDefault(); + const completed = Commands.complete(input.value); + if (completed) input.value = completed; + } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.send(); } @@ -40,6 +46,21 @@ const Chat = { const message = input.value.trim(); if (!message || this.sending) return; + // Check for slash commands + const parsed = Commands.parse(message); + if (parsed.isCommand) { + input.value = ''; + input.style.height = 'auto'; + this.hideWelcome(); + + if (parsed.type === 'client') { + await Commands.executeClient(parsed.name, parsed.args); + } else { + await Commands.executeServer(parsed.name, parsed.args, this.conversationId); + } + return; + } + this.sending = true; this.hideWelcome(); this.appendMessage('user', message); @@ -55,18 +76,37 @@ const Chat = { case 'thinking': // Already showing indicator break; + case 'token': + thinkingEl.remove(); + this.handleStreamToken(data.text); + break; + case 'stream_end': + this.finalizeStream(); + break; + case 'stream_discard': + this.discardStream(); + break; case 'tool_call': this.appendToolCall(data); break; case 'tool_result': this.appendToolResult(data); break; + case 'approval_required': + this.showApprovalCard(data); + break; + case 'approval_resolved': + this.resolveApprovalCard(data); + break; case 'message': thinkingEl.remove(); - this.appendMessage('assistant', data.content); + if (!this.streamingEl) { + this.appendMessage('assistant', data.content); + } break; case 'error': thinkingEl.remove(); + this.discardStream(); this.appendMessage('assistant', 'Error: ' + data.error); break; case 'done': @@ -83,6 +123,7 @@ const Chat = { }); } catch (err) { thinkingEl.remove(); + this.discardStream(); this.appendMessage('assistant', 'Connection error: ' + err.message); } @@ -91,10 +132,100 @@ const Chat = { input.focus(); }, + // Streaming support + handleStreamToken(text) { + if (!this.streamingEl) { + this.streamingEl = this.createStreamingMessage(); + this.streamingContent = ''; + } + this.streamingContent += text; + this.renderStream(); + }, + + createStreamingMessage() { + const messages = document.getElementById('messages'); + const div = document.createElement('div'); + div.className = 'message assistant streaming'; + const bubble = document.createElement('div'); + bubble.className = 'message-content'; + div.appendChild(bubble); + messages.appendChild(div); + return div; + }, + + renderStream() { + if (!this.streamingEl) return; + const bubble = this.streamingEl.querySelector('.message-content'); + bubble.innerHTML = Markdown.render(this.streamingContent); + this.scrollToBottom(); + }, + + finalizeStream() { + if (this.streamingEl) { + this.streamingEl.classList.remove('streaming'); + this.renderStream(); + this.streamingEl = null; + this.streamingContent = ''; + } + }, + + discardStream() { + if (this.streamingEl) { + this.streamingEl.remove(); + this.streamingEl = null; + this.streamingContent = ''; + } + }, + + // Approval gating + showApprovalCard(data) { + const messages = document.getElementById('messages'); + const card = document.createElement('div'); + card.className = 'approval-card'; + card.id = 'approval-' + data.approval_id; + + let previewHtml = '
    '; + for (const [key, val] of Object.entries(data.preview || {})) { + previewHtml += '
    ' + this.escapeHtml(key) + '
    ' + + '
    ' + this.escapeHtml(String(val)) + '
    '; + } + previewHtml += '
    '; + + card.innerHTML = + '
    Confirm: ' + this.escapeHtml(data.tool) + '
    ' + + previewHtml + + '
    ' + + '' + + '' + + '
    ' + + '
    '; + + messages.appendChild(card); + this.scrollToBottom(); + }, + + resolveApprovalCard(data) { + const card = document.getElementById('approval-' + data.approval_id); + if (!card) return; + card.classList.add('resolved'); + const status = card.querySelector('.approval-status'); + status.textContent = data.approved ? 'Approved' : 'Rejected' + (data.reason ? ': ' + data.reason : ''); + }, + + async approve(approvalId) { + try { await ChatAPI.approveAction(approvalId); } catch { /* handled via SSE */ } + }, + + async reject(approvalId) { + try { await ChatAPI.rejectAction(approvalId, 'rejected by user'); } catch { /* handled via SSE */ } + }, + loadConversation(conv) { this.conversationId = conv.id; const messages = document.getElementById('messages'); + const welcome = document.getElementById('welcome'); messages.innerHTML = ''; + if (welcome) messages.appendChild(welcome); this.hideWelcome(); document.getElementById('chat-title').textContent = conv.title || 'New conversation'; @@ -121,7 +252,9 @@ const Chat = { startNew(id) { this.conversationId = id; const messages = document.getElementById('messages'); + const welcome = document.getElementById('welcome'); messages.innerHTML = ''; + if (welcome) messages.appendChild(welcome); document.getElementById('chat-title').textContent = 'New conversation'; this.showWelcome(); }, @@ -145,6 +278,20 @@ const Chat = { this.scrollToBottom(); }, + appendSystemMessage(content) { + const messages = document.getElementById('messages'); + const div = document.createElement('div'); + div.className = 'message system'; + + const bubble = document.createElement('div'); + bubble.className = 'message-content'; + bubble.innerHTML = Markdown.render(content); + + div.appendChild(bubble); + messages.appendChild(div); + this.scrollToBottom(); + }, + appendToolCall(data) { const messages = document.getElementById('messages'); const div = document.createElement('div'); diff --git a/internal/chat/static/js/commands.js b/internal/chat/static/js/commands.js new file mode 100644 index 0000000..fa844f0 --- /dev/null +++ b/internal/chat/static/js/commands.js @@ -0,0 +1,124 @@ +// commands.js — Slash command handling +const Commands = { + // Registry of all commands + registry: [ + { name: 'help', type: 'client', description: 'Show available commands' }, + { name: 'new', type: 'client', description: 'Start a new conversation' }, + { name: 'reset', type: 'client', description: 'Start a new conversation' }, + { name: 'clear', type: 'client', description: 'Clear current messages' }, + { name: 'model', type: 'client', description: 'Switch AI agent (e.g. /model claude)', args: '' }, + { name: 'agent', type: 'client', description: 'Switch AI agent (e.g. /agent ollama)', args: '' }, + { name: 'status', type: 'server', description: 'Show current session status' }, + { name: 'email', type: 'server', description: 'Quick email lookup (e.g. /email from:sarah)', args: '[query]' }, + { name: 'calendar', type: 'server', description: 'Show upcoming events (e.g. /calendar 7)', args: '[days]' }, + { name: 'contacts', type: 'server', description: 'Search contacts (e.g. /contacts john)', args: '[query]' }, + ], + + // Parse input to check if it's a command + parse(input) { + const trimmed = input.trim(); + if (!trimmed.startsWith('/')) { + return { isCommand: false }; + } + + const parts = trimmed.slice(1).split(/\s+/); + const name = parts[0].toLowerCase(); + const args = parts.slice(1).join(' '); + + const cmd = this.registry.find(c => c.name === name); + if (!cmd) { + return { isCommand: false }; + } + + return { isCommand: true, name, args, type: cmd.type }; + }, + + // Execute a client-side command. Returns true if handled. + async executeClient(name, args) { + switch (name) { + case 'help': + this.showHelp(); + return true; + case 'new': + case 'reset': + await Sidebar.newChat(); + return true; + case 'clear': + this.clearMessages(); + return true; + case 'model': + case 'agent': + this.switchAgent(args); + return true; + default: + return false; + } + }, + + // Execute a server-side command via API + async executeServer(name, args, conversationId) { + try { + const result = await ChatAPI.executeCommand(name, args, conversationId); + Chat.appendSystemMessage(result.content || result.error || 'Command executed.'); + } catch (err) { + Chat.appendSystemMessage('Command error: ' + err.message); + } + }, + + // Show help with all available commands + showHelp() { + const lines = ['**Available Commands:**', '']; + for (const cmd of this.registry) { + const argHint = cmd.args ? ' ' + cmd.args : ''; + lines.push('`/' + cmd.name + argHint + '` — ' + cmd.description); + } + lines.push('', 'Type a command or just chat normally.'); + Chat.appendSystemMessage(lines.join('\n')); + }, + + // Clear messages in current conversation view + clearMessages() { + const messages = document.getElementById('messages'); + const welcome = document.getElementById('welcome'); + messages.innerHTML = ''; + if (welcome) messages.appendChild(welcome); + Chat.showWelcome(); + Chat.appendSystemMessage('Messages cleared.'); + }, + + // Switch agent via sidebar + switchAgent(name) { + if (!name) { + Chat.appendSystemMessage('Usage: `/model ` — Available agents are in the sidebar dropdown.'); + return; + } + const select = document.getElementById('agent-select'); + const options = Array.from(select.options); + const match = options.find(o => o.value.toLowerCase() === name.toLowerCase()); + if (!match) { + Chat.appendSystemMessage('Unknown agent: `' + name + '`. Available: ' + + options.map(o => '`' + o.value + '`').join(', ')); + return; + } + select.value = match.value; + Sidebar.switchAgent(match.value); + Chat.appendSystemMessage('Switched to agent: `' + match.value + '`'); + }, + + // Tab completion for command names + complete(partial) { + if (!partial.startsWith('/')) return null; + + const typed = partial.slice(1).toLowerCase(); + if (!typed) return null; + + const matches = this.registry + .filter(c => c.name.startsWith(typed)) + .map(c => '/' + c.name); + + if (matches.length === 1) { + return matches[0] + ' '; + } + return null; + } +}; diff --git a/internal/chat/templates/index.gohtml b/internal/chat/templates/index.gohtml index 5caafb2..fc69b78 100644 --- a/internal/chat/templates/index.gohtml +++ b/internal/chat/templates/index.gohtml @@ -5,6 +5,7 @@ Nylas Chat +
    @@ -59,6 +60,7 @@ + diff --git a/tests/chat/e2e/smoke.spec.js b/tests/chat/e2e/smoke.spec.js new file mode 100644 index 0000000..d90828e --- /dev/null +++ b/tests/chat/e2e/smoke.spec.js @@ -0,0 +1,116 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const sel = require('../../shared/helpers/chat-selectors'); + +/** + * Smoke tests for Nylas Chat. + * + * Verifies the application loads correctly and all major + * UI elements are present and functional. + */ + +test.describe('Smoke Tests', () => { + test.beforeEach(async ({ page }) => { + page.on('pageerror', (error) => { + console.error('Page error:', error.message); + }); + await page.goto('/'); + await expect(page.locator(sel.app)).toBeVisible(); + }); + + test('page loads without JavaScript errors', async ({ page }) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(500); + + const criticalErrors = errors.filter((e) => { + if (e.includes('Failed to load resource')) return false; + if (e.includes('404')) return false; + return true; + }); + + expect(criticalErrors).toHaveLength(0); + }); + + test('has correct page title', async ({ page }) => { + await expect(page).toHaveTitle('Nylas Chat'); + }); + + test('has viewport meta tag', async ({ page }) => { + const viewport = page.locator('meta[name="viewport"]'); + await expect(viewport).toHaveAttribute( + 'content', + expect.stringContaining('width=device-width') + ); + }); + + test('sidebar is visible with header', async ({ page }) => { + const sidebar = page.locator(sel.sidebar.root); + await expect(sidebar).toBeVisible(); + + await expect(page.locator(sel.sidebar.title)).toHaveText('Nylas Chat'); + await expect(page.locator(sel.sidebar.newChatBtn)).toBeVisible(); + }); + + test('agent selector is present in sidebar', async ({ page }) => { + await expect(page.locator(sel.sidebar.agentLabel)).toBeVisible(); + await expect(page.locator(sel.sidebar.agentSelect)).toBeVisible(); + }); + + test('chat main area is visible', async ({ page }) => { + const main = page.locator(sel.chat.main); + await expect(main).toBeVisible(); + + await expect(page.locator(sel.chat.title)).toHaveText('New conversation'); + // Toggle button is in DOM but hidden at desktop width (shown on mobile via media query) + await expect(page.locator(sel.chat.toggleSidebar)).toBeAttached(); + }); + + test('welcome screen is displayed', async ({ page }) => { + const welcome = page.locator(sel.chat.welcome); + await expect(welcome).toBeVisible(); + + await expect(page.locator(sel.chat.welcomeTitle)).toHaveText( + 'Welcome to Nylas Chat' + ); + }); + + test('suggestion buttons are present', async ({ page }) => { + const suggestions = page.locator(sel.chat.suggestionBtn); + await expect(suggestions).toHaveCount(3); + + await expect(suggestions.nth(0)).toContainText('Unread emails'); + await expect(suggestions.nth(1)).toContainText("Today's meetings"); + await expect(suggestions.nth(2)).toContainText('Search emails'); + }); + + test('input area is present and functional', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await expect(textarea).toBeVisible(); + await expect(textarea).toHaveAttribute( + 'placeholder', + expect.stringContaining('emails') + ); + + const sendBtn = page.locator(sel.input.sendBtn); + await expect(sendBtn).toBeVisible(); + }); + + test('CSS files loaded correctly', async ({ page }) => { + const chatCss = page.locator('link[href="/css/chat.css"]'); + await expect(chatCss).toBeAttached(); + + const componentsCss = page.locator('link[href="/css/components.css"]'); + await expect(componentsCss).toBeAttached(); + }); + + test('JavaScript files loaded correctly', async ({ page }) => { + const scripts = ['markdown.js', 'api.js', 'commands.js', 'sidebar.js', 'chat.js']; + for (const script of scripts) { + const el = page.locator(`script[src="/js/${script}"]`); + await expect(el).toBeAttached(); + } + }); +}); diff --git a/tests/chat/e2e/ui-interactions.spec.js b/tests/chat/e2e/ui-interactions.spec.js new file mode 100644 index 0000000..4a899e9 --- /dev/null +++ b/tests/chat/e2e/ui-interactions.spec.js @@ -0,0 +1,204 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const sel = require('../../shared/helpers/chat-selectors'); + +/** + * UI interaction tests for Nylas Chat. + * + * Tests user interactions with the chat interface including + * input, sidebar, and slash commands. + */ + +test.describe('Chat Input', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.locator(sel.app)).toBeVisible(); + }); + + test('textarea is focused on load', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await expect(textarea).toBeFocused(); + }); + + test('textarea auto-resizes on input', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + + const initialHeight = await textarea.evaluate((el) => el.offsetHeight); + + // Type multiple lines + await textarea.fill('Line 1\nLine 2\nLine 3\nLine 4'); + await textarea.dispatchEvent('input'); + + const expandedHeight = await textarea.evaluate((el) => el.offsetHeight); + expect(expandedHeight).toBeGreaterThanOrEqual(initialHeight); + }); + + test('empty message is not sent', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await textarea.fill(''); + + await page.locator(sel.input.sendBtn).click(); + + // Welcome should still be visible (no message sent) + await expect(page.locator(sel.chat.welcome)).toBeVisible(); + }); + + test('Shift+Enter creates newline without sending', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await textarea.fill('Line 1'); + await textarea.press('Shift+Enter'); + await textarea.pressSequentially('Line 2'); + + const value = await textarea.inputValue(); + expect(value).toContain('Line 1'); + expect(value).toContain('Line 2'); + + // Welcome should still be visible (not sent) + await expect(page.locator(sel.chat.welcome)).toBeVisible(); + }); +}); + +test.describe('Sidebar', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.locator(sel.app)).toBeVisible(); + }); + + test('new chat button resets conversation', async ({ page }) => { + // Click new chat + await page.locator(sel.sidebar.newChatBtn).click(); + + // Title should be reset + await expect(page.locator(sel.chat.title)).toHaveText('New conversation'); + }); + + test('sidebar toggle button is in DOM', async ({ page }) => { + // Hidden at desktop width (display: none), visible only on mobile via media query + const toggleBtn = page.locator(sel.chat.toggleSidebar); + await expect(toggleBtn).toBeAttached(); + await expect(toggleBtn).toHaveText('\u2630'); // hamburger icon + }); + + test('agent selector has options', async ({ page }) => { + const select = page.locator(sel.sidebar.agentSelect); + await expect(select).toBeVisible(); + + // Should have at least one option + const options = select.locator('option'); + const count = await options.count(); + expect(count).toBeGreaterThan(0); + }); +}); + +test.describe('Slash Commands', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.locator(sel.app)).toBeVisible(); + }); + + test('/help shows available commands', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await textarea.fill('/help'); + await textarea.press('Enter'); + + // Should show a system message with help content + const systemMsg = page.locator(sel.message.system); + await expect(systemMsg.first()).toBeVisible({ timeout: 5000 }); + + // Help content should mention available commands + const content = await systemMsg.first().textContent(); + expect(content).toContain('help'); + }); + + test('/clear clears the messages', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + + // Send /help first to populate messages + await textarea.fill('/help'); + await textarea.press('Enter'); + await expect(page.locator(sel.message.system).first()).toBeVisible({ timeout: 5000 }); + + // Now clear + await textarea.fill('/clear'); + await textarea.press('Enter'); + + // Old messages should be gone; only "Messages cleared." system message remains + await page.waitForTimeout(500); + const userAndAssistant = page.locator(`${sel.message.user}, ${sel.message.assistant}`); + await expect(userAndAssistant).toHaveCount(0); + + // The "Messages cleared." confirmation should be shown + const systemMsg = page.locator(sel.message.system); + await expect(systemMsg).toHaveCount(1); + const content = await systemMsg.first().textContent(); + expect(content).toContain('cleared'); + }); + + test('/new resets to new conversation', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await textarea.fill('/new'); + await textarea.press('Enter'); + + // /new calls async API to create conversation, then resets UI + await expect(page.locator(sel.chat.title)).toHaveText('New conversation', { timeout: 5000 }); + await expect(page.locator(sel.chat.welcome)).toBeVisible({ timeout: 5000 }); + }); + + test('Tab completes slash commands', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await textarea.fill('/hel'); + await textarea.press('Tab'); + + const value = await textarea.inputValue(); + // Tab completion appends a trailing space for args + expect(value.trim()).toBe('/help'); + }); + + test('Tab completes partial command names', async ({ page }) => { + const textarea = page.locator(sel.input.textarea); + await textarea.fill('/cal'); + await textarea.press('Tab'); + + const value = await textarea.inputValue(); + expect(value.trim()).toBe('/calendar'); + }); +}); + +test.describe('API Health', () => { + test('health endpoint returns ok', async ({ request }) => { + const resp = await request.get('/api/health'); + expect(resp.status()).toBe(200); + + const body = await resp.json(); + expect(body.status).toBe('ok'); + }); + + test('config endpoint returns agent info', async ({ request }) => { + const resp = await request.get('/api/config'); + expect(resp.status()).toBe(200); + + const body = await resp.json(); + expect(body.agent).toBeTruthy(); + expect(body.available).toBeInstanceOf(Array); + expect(body.available.length).toBeGreaterThan(0); + }); + + test('conversations endpoint returns list', async ({ request }) => { + const resp = await request.get('/api/conversations'); + expect(resp.status()).toBe(200); + + // API returns a raw array of conversation summaries + const body = await resp.json(); + expect(body).toBeInstanceOf(Array); + }); + + test('command endpoint rejects GET', async ({ request }) => { + const resp = await request.get('/api/command'); + expect(resp.status()).toBe(405); + }); + + test('chat endpoint rejects GET', async ({ request }) => { + const resp = await request.get('/api/chat'); + expect(resp.status()).toBe(405); + }); +}); diff --git a/tests/package.json b/tests/package.json index 55a24e1..22ef4b8 100644 --- a/tests/package.json +++ b/tests/package.json @@ -8,6 +8,7 @@ "test:all": "playwright test", "test:air": "playwright test --project=air-chromium", "test:ui": "playwright test --project=ui-chromium", + "test:chat": "playwright test --project=chat-chromium", "test:air:ui": "playwright test --project=air-chromium --ui", "test:ui:ui": "playwright test --project=ui-chromium --ui", "test:air:headed": "playwright test --project=air-chromium --headed", diff --git a/tests/playwright.config.js b/tests/playwright.config.js index 938d217..55b66dc 100644 --- a/tests/playwright.config.js +++ b/tests/playwright.config.js @@ -4,9 +4,10 @@ const { defineConfig, devices } = require('@playwright/test'); /** * Playwright configuration for Nylas E2E tests. * - * Supports two test targets: + * Supports three test targets: * - Air: Modern web email client (http://localhost:7365) * - UI: Web-based CLI admin interface (http://localhost:7363) + * - Chat: AI chat interface (http://localhost:7367) * * @see https://playwright.dev/docs/test-configuration */ @@ -15,6 +16,7 @@ const { defineConfig, devices } = require('@playwright/test'); const isCI = !!process.env.CI; const airPort = parseInt(process.env.AIR_PORT || '7365', 10); const uiPort = parseInt(process.env.UI_PORT || '7363', 10); +const chatPort = parseInt(process.env.CHAT_PORT || '7367', 10); module.exports = defineConfig({ // Run tests in parallel within files @@ -84,6 +86,24 @@ module.exports = defineConfig({ navigationTimeout: 30000, }, }, + + // ========================================================================= + // Nylas Chat (AI Chat Interface) + // ========================================================================= + { + name: 'chat-chromium', + testDir: './chat/e2e', + use: { + ...devices['Desktop Chrome'], + baseURL: `http://localhost:${chatPort}`, + viewport: { width: 1280, height: 720 }, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + actionTimeout: 10000, + navigationTimeout: 30000, + }, + }, ], // Web server configurations @@ -105,5 +125,12 @@ module.exports = defineConfig({ timeout: 60000, reuseExistingServer: !isCI, }, + // Nylas Chat server (port 7367) + { + command: 'cd .. && go run cmd/nylas/main.go chat --no-browser --port ' + chatPort, + port: chatPort, + timeout: 60000, + reuseExistingServer: !isCI, + }, ], }); diff --git a/tests/shared/helpers/chat-selectors.js b/tests/shared/helpers/chat-selectors.js new file mode 100644 index 0000000..7b1fa5d --- /dev/null +++ b/tests/shared/helpers/chat-selectors.js @@ -0,0 +1,54 @@ +/** + * Semantic selectors for Nylas Chat E2E tests. + * Uses IDs and semantic attributes from index.gohtml and chat.js. + */ +module.exports = { + app: '.app', + sidebar: { + root: '#sidebar', + header: '.sidebar-header', + title: '.sidebar-header h2', + newChatBtn: '#btn-new-chat', + conversationList: '#conversation-list', + agentSelect: '#agent-select', + agentLabel: '.agent-label', + }, + chat: { + main: '.chat-main', + header: '#chat-header', + title: '#chat-title', + toggleSidebar: '#btn-toggle-sidebar', + messages: '#messages', + welcome: '#welcome', + welcomeTitle: '#welcome h2', + suggestions: '.suggestions', + suggestionBtn: '.suggestion', + }, + input: { + form: '#chat-form', + textarea: '#chat-input', + sendBtn: '#btn-send', + }, + message: { + user: '.message.user', + assistant: '.message.assistant', + system: '.message.system', + content: '.message-content', + streaming: '.message.streaming', + }, + tool: { + indicator: '.tool-indicator', + name: '.tool-name', + details: '.tool-details', + }, + approval: { + card: '.approval-card', + header: '.approval-header', + preview: '.approval-preview', + approveBtn: '.btn-approve', + rejectBtn: '.btn-reject', + status: '.approval-status', + resolved: '.approval-card.resolved', + }, + thinking: '.thinking', +}; From 1fcf595bc58728951cd5ffadd34caed77a7aa65e Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 13 Feb 2026 07:12:26 -0500 Subject: [PATCH 3/9] feat(api): auto-paginate when --limit exceeds API max (200) When --limit is set above 200, automatically switch to cursor-based pagination instead of returning an API error. This applies to email list, calendar events list, and contacts list commands. Also extracts MaxAPILimit constant to common/pagination.go, replaces custom contains() helper with slices.Contains, optimizes page size to use the full API limit (200) instead of hardcoded 50, and refactors contacts list to use common.WithClient. --- internal/cli/calendar/events_list.go | 44 +++++++- internal/cli/common/pagination.go | 3 + internal/cli/contacts/list.go | 149 ++++++++++++++++----------- internal/cli/email/helpers_test.go | 65 ------------ internal/cli/email/list.go | 41 ++++---- 5 files changed, 153 insertions(+), 149 deletions(-) diff --git a/internal/cli/calendar/events_list.go b/internal/cli/calendar/events_list.go index e3472fa..8538571 100644 --- a/internal/cli/calendar/events_list.go +++ b/internal/cli/calendar/events_list.go @@ -54,6 +54,13 @@ Examples: } } + // Auto-paginate when limit exceeds API maximum + maxItems := 0 + if limit > common.MaxAPILimit { + maxItems = limit + limit = common.MaxAPILimit + } + _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { // If no calendar specified, try to get the primary calendar calID, err := GetDefaultCalendarID(ctx, client, grantID, calendarID, false) @@ -77,9 +84,38 @@ Examples: params.ShowCancelled = true } - events, err := client.GetEvents(ctx, grantID, calID, params) - if err != nil { - return struct{}{}, common.WrapListError("events", err) + var events []domain.Event + if maxItems > 0 { + // Paginated fetch for large limits + pageSize := min(limit, common.MaxAPILimit) + params.Limit = pageSize + + fetcher := func(ctx context.Context, cursor string) (common.PageResult[domain.Event], error) { + params.PageToken = cursor + resp, err := client.GetEventsWithCursor(ctx, grantID, calID, params) + if err != nil { + return common.PageResult[domain.Event]{}, err + } + return common.PageResult[domain.Event]{ + Data: resp.Data, + NextCursor: resp.Pagination.NextCursor, + }, nil + } + + config := common.DefaultPaginationConfig() + config.PageSize = pageSize + config.MaxItems = maxItems + + events, err = common.FetchAllPages(ctx, config, fetcher) + if err != nil { + return struct{}{}, common.WrapListError("events", err) + } + } else { + // Standard single-page fetch + events, err = client.GetEvents(ctx, grantID, calID, params) + if err != nil { + return struct{}{}, common.WrapListError("events", err) + } } // JSON output (including empty array) @@ -179,7 +215,7 @@ Examples: } cmd.Flags().StringVarP(&calendarID, "calendar", "c", "", "Calendar ID (defaults to primary)") - cmd.Flags().IntVarP(&limit, "limit", "n", 10, "Maximum number of events to show") + cmd.Flags().IntVarP(&limit, "limit", "n", 10, "Maximum number of events to show (auto-paginates if >200)") cmd.Flags().IntVarP(&days, "days", "d", 7, "Show events for the next N days (0 for no limit)") cmd.Flags().BoolVar(&showAll, "show-cancelled", false, "Include cancelled events") cmd.Flags().StringVar(&targetTZ, "timezone", "", "Display times in this timezone (e.g., America/Los_Angeles). Defaults to local timezone.") diff --git a/internal/cli/common/pagination.go b/internal/cli/common/pagination.go index 188f808..5325abb 100644 --- a/internal/cli/common/pagination.go +++ b/internal/cli/common/pagination.go @@ -7,6 +7,9 @@ import ( "os" ) +// MaxAPILimit is the maximum number of items the Nylas API returns per request. +const MaxAPILimit = 200 + // PageResult represents a paginated API response. type PageResult[T any] struct { Data []T // The items in this page diff --git a/internal/cli/contacts/list.go b/internal/cli/contacts/list.go index 3c90e41..86d0727 100644 --- a/internal/cli/contacts/list.go +++ b/internal/cli/contacts/list.go @@ -25,6 +25,13 @@ func newListCmd() *cobra.Command { Long: "List all contacts for the specified grant or default account.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Auto-paginate when limit exceeds API maximum + maxItems := 0 + if limit > common.MaxAPILimit { + maxItems = limit + limit = common.MaxAPILimit + } + // Check if we should use structured output (JSON/YAML/quiet) if common.IsJSON(cmd) { _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { @@ -34,7 +41,7 @@ func newListCmd() *cobra.Command { Source: source, } - contacts, err := client.GetContacts(ctx, grantID, params) + contacts, err := fetchContacts(ctx, client, grantID, params, maxItems) if err != nil { return struct{}{}, common.WrapListError("contacts", err) } @@ -46,81 +53,99 @@ func newListCmd() *cobra.Command { } // Traditional table output - client, err := common.GetNylasClient() - if err != nil { - return err - } - - grantID, err := common.GetGrantID(args) - if err != nil { - return err - } - - ctx, cancel := common.CreateContext() - defer cancel() - - params := &domain.ContactQueryParams{ - Limit: limit, - Email: email, - Source: source, - } - - contacts, err := client.GetContacts(ctx, grantID, params) - if err != nil { - return common.WrapListError("contacts", err) - } - - if len(contacts) == 0 { - common.PrintEmptyState("contacts") - return nil - } + _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + params := &domain.ContactQueryParams{ + Limit: limit, + Email: email, + Source: source, + } - fmt.Printf("Found %d contact(s):\n\n", len(contacts)) + contacts, err := fetchContacts(ctx, client, grantID, params, maxItems) + if err != nil { + return struct{}{}, common.WrapListError("contacts", err) + } - var table *common.Table - if showID { - table = common.NewTable("ID", "NAME", "EMAIL", "PHONE", "COMPANY") - } else { - table = common.NewTable("NAME", "EMAIL", "PHONE", "COMPANY") - } - for _, contact := range contacts { - name := contact.DisplayName() - email := contact.PrimaryEmail() - phone := contact.PrimaryPhone() - company := contact.CompanyName - if contact.JobTitle != "" && company != "" { - company = fmt.Sprintf("%s - %s", contact.JobTitle, company) - } else if contact.JobTitle != "" { - company = contact.JobTitle + if len(contacts) == 0 { + common.PrintEmptyState("contacts") + return struct{}{}, nil } + fmt.Printf("Found %d contact(s):\n\n", len(contacts)) + + var table *common.Table if showID { - table.AddRow( - common.Dim.Sprint(contact.ID), - common.Cyan.Sprint(name), - email, - phone, - common.Dim.Sprint(company), - ) + table = common.NewTable("ID", "NAME", "EMAIL", "PHONE", "COMPANY") } else { - table.AddRow( - common.Cyan.Sprint(name), - email, - phone, - common.Dim.Sprint(company), - ) + table = common.NewTable("NAME", "EMAIL", "PHONE", "COMPANY") } - } - table.Render() + for _, contact := range contacts { + name := contact.DisplayName() + emailAddr := contact.PrimaryEmail() + phone := contact.PrimaryPhone() + company := contact.CompanyName + if contact.JobTitle != "" && company != "" { + company = fmt.Sprintf("%s - %s", contact.JobTitle, company) + } else if contact.JobTitle != "" { + company = contact.JobTitle + } - return nil + if showID { + table.AddRow( + common.Dim.Sprint(contact.ID), + common.Cyan.Sprint(name), + emailAddr, + phone, + common.Dim.Sprint(company), + ) + } else { + table.AddRow( + common.Cyan.Sprint(name), + emailAddr, + phone, + common.Dim.Sprint(company), + ) + } + } + table.Render() + + return struct{}{}, nil + }) + return err }, } - cmd.Flags().IntVarP(&limit, "limit", "n", 50, "Maximum number of contacts to show") + cmd.Flags().IntVarP(&limit, "limit", "n", 50, "Maximum number of contacts to show (auto-paginates if >200)") cmd.Flags().StringVarP(&email, "email", "e", "", "Filter by email address") cmd.Flags().StringVarP(&source, "source", "s", "", "Filter by source (address_book, inbox, domain)") cmd.Flags().BoolVar(&showID, "id", false, "Show contact IDs") return cmd } + +// fetchContacts retrieves contacts, using pagination when maxItems > 0. +func fetchContacts(ctx context.Context, client ports.NylasClient, grantID string, params *domain.ContactQueryParams, maxItems int) ([]domain.Contact, error) { + if maxItems > 0 { + pageSize := min(params.Limit, common.MaxAPILimit) + params.Limit = pageSize + + fetcher := func(ctx context.Context, cursor string) (common.PageResult[domain.Contact], error) { + params.PageToken = cursor + resp, err := client.GetContactsWithCursor(ctx, grantID, params) + if err != nil { + return common.PageResult[domain.Contact]{}, err + } + return common.PageResult[domain.Contact]{ + Data: resp.Data, + NextCursor: resp.Pagination.NextCursor, + }, nil + } + + config := common.DefaultPaginationConfig() + config.PageSize = pageSize + config.MaxItems = maxItems + + return common.FetchAllPages(ctx, config, fetcher) + } + + return client.GetContacts(ctx, grantID, params) +} diff --git a/internal/cli/email/helpers_test.go b/internal/cli/email/helpers_test.go index 403f03c..ceb6c0b 100644 --- a/internal/cli/email/helpers_test.go +++ b/internal/cli/email/helpers_test.go @@ -310,71 +310,6 @@ func TestRemoveTagWithContent(t *testing.T) { } } -func TestContains(t *testing.T) { - tests := []struct { - name string - slice []string - item string - expected bool - }{ - { - name: "item present", - slice: []string{"a", "b", "c"}, - item: "b", - expected: true, - }, - { - name: "item not present", - slice: []string{"a", "b", "c"}, - item: "d", - expected: false, - }, - { - name: "empty slice", - slice: []string{}, - item: "a", - expected: false, - }, - { - name: "nil slice", - slice: nil, - item: "a", - expected: false, - }, - { - name: "first item", - slice: []string{"first", "second"}, - item: "first", - expected: true, - }, - { - name: "last item", - slice: []string{"first", "last"}, - item: "last", - expected: true, - }, - { - name: "case sensitive", - slice: []string{"Hello"}, - item: "hello", - expected: false, - }, - { - name: "empty string in slice", - slice: []string{"", "a"}, - item: "", - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := contains(tt.slice, tt.item) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestPrintMetadataInfo(t *testing.T) { // Just verify it doesn't panic printMetadataInfo() diff --git a/internal/cli/email/list.go b/internal/cli/email/list.go index 8e4c08b..cad1d4a 100644 --- a/internal/cli/email/list.go +++ b/internal/cli/email/list.go @@ -3,6 +3,7 @@ package email import ( "context" "fmt" + "slices" "strings" "github.com/nylas/cli/internal/cli/common" @@ -54,6 +55,13 @@ Use --max to limit total messages when using --all.`, return runListStructured(cmd, args, limit, unread, starred, from, folder, allFolders, all, maxItems, metadataPair) } + // Auto-paginate when limit exceeds API maximum + if limit > common.MaxAPILimit && !all { + all = true + maxItems = limit + limit = common.MaxAPILimit + } + // Traditional formatted output _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { params := &domain.MessageQueryParams{ @@ -107,9 +115,9 @@ Use --max to limit total messages when using --all.`, if all { // Use pagination to fetch all messages - pageSize := 50 // Optimal page size for API - if limit > 0 && limit < pageSize { - pageSize = limit + pageSize := min(limit, common.MaxAPILimit) + if pageSize <= 0 { + pageSize = common.MaxAPILimit } params.Limit = pageSize @@ -157,7 +165,7 @@ Use --max to limit total messages when using --all.`, }, } - cmd.Flags().IntVarP(&limit, "limit", "l", 10, "Number of messages to fetch (per page with --all)") + cmd.Flags().IntVarP(&limit, "limit", "l", 10, "Number of messages to fetch (auto-paginates if >200)") cmd.Flags().BoolVarP(&unread, "unread", "u", false, "Only show unread messages") cmd.Flags().BoolVarP(&starred, "starred", "s", false, "Only show starred messages") cmd.Flags().StringVarP(&from, "from", "f", "", "Filter by sender email") @@ -198,7 +206,7 @@ func resolveFolderName(ctx context.Context, client ports.NylasClient, grantID, f // Find matching aliases for the search name var searchAliases []string for key, aliases := range nameAliases { - if key == searchName || contains(aliases, searchName) { + if key == searchName || slices.Contains(aliases, searchName) { searchAliases = aliases break } @@ -221,20 +229,17 @@ func resolveFolderName(ctx context.Context, client ports.NylasClient, grantID, f return "", nil } -// contains checks if a slice contains a string. -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - // runListStructured handles structured output (JSON/YAML/quiet) for the list command. func runListStructured(cmd *cobra.Command, args []string, limit int, unread, starred bool, from, folder string, allFolders, all bool, maxItems int, metadataPair string) error { + // Auto-paginate when limit exceeds API maximum + if limit > common.MaxAPILimit && !all { + all = true + maxItems = limit + limit = common.MaxAPILimit + } + _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { params := &domain.MessageQueryParams{ Limit: limit, @@ -280,9 +285,9 @@ func runListStructured(cmd *cobra.Command, args []string, limit int, unread, sta var err error if all { - pageSize := 50 - if limit > 0 && limit < pageSize { - pageSize = limit + pageSize := min(limit, common.MaxAPILimit) + if pageSize <= 0 { + pageSize = common.MaxAPILimit } params.Limit = pageSize From 41b3f498696dc3c43dd22068f256a8c2d3c4f375 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 13 Feb 2026 07:12:57 -0500 Subject: [PATCH 4/9] update gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d68a1ed..b8907e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,6 @@ *plan.md # Temporary refactoring/analysis documents -REFACTORING_PROGRESS.md -ENGINEERING_IMPROVEMENTS_SUMMARY.md COVERAGE_REPORT.md start.md *_PROGRESS.md @@ -26,6 +24,7 @@ CLAUDE.local.md .claude/settings.local.json .claude/*.local.* .claude/*/*.local.* +*.session # Session-specific progress tracking (personal, not shared) claude-progress.txt From ea170e95f125817dc54698002fc5c50b25d463f7 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 13 Feb 2026 07:48:23 -0500 Subject: [PATCH 5/9] refactor(cli): remove duplicated boilerplate across packages - Replace custom contains()/findSubstring() with strings.Contains() - Replace getScopesClient()/getProvidersClient() with common.GetNylasClient() - Add withSlackClient()/getSlackClientOrError() helpers to reduce slack boilerplate - Add common.IsStructuredOutput() to replace repeated 3-line format check pattern - Fix folder list UNREAD column alignment when ANSI colors are applied --- internal/cli/auth/providers.go | 45 +------------- internal/cli/auth/scopes.go | 96 ++++++------------------------ internal/cli/common/output.go | 10 ++++ internal/cli/config/get_test.go | 18 +----- internal/cli/config/gpg_test.go | 3 +- internal/cli/config/set_test.go | 5 +- internal/cli/email/folders.go | 12 ++-- internal/cli/slack/auth.go | 4 +- internal/cli/slack/channel_info.go | 83 ++++++++++++-------------- internal/cli/slack/channels.go | 11 +--- internal/cli/slack/files.go | 29 +++------ internal/cli/slack/helpers.go | 24 ++++++++ internal/cli/slack/messages.go | 11 +--- internal/cli/slack/search.go | 11 +--- internal/cli/slack/send.go | 14 ++--- internal/cli/slack/users.go | 22 ++----- 16 files changed, 132 insertions(+), 266 deletions(-) diff --git a/internal/cli/auth/providers.go b/internal/cli/auth/providers.go index 0db7dd0..49088c8 100644 --- a/internal/cli/auth/providers.go +++ b/internal/cli/auth/providers.go @@ -3,16 +3,10 @@ package auth import ( "encoding/json" "fmt" - "os" "github.com/spf13/cobra" - "github.com/nylas/cli/internal/adapters/config" - "github.com/nylas/cli/internal/adapters/keyring" - "github.com/nylas/cli/internal/adapters/nylas" "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" - "github.com/nylas/cli/internal/ports" ) func newProvidersCmd() *cobra.Command { @@ -40,7 +34,7 @@ This command shows connectors configured for your Nylas application.`, ctx, cancel := common.CreateContext() defer cancel() - client, err := getProvidersClient() + client, err := common.GetNylasClient() if err != nil { return err } @@ -84,40 +78,3 @@ This command shows connectors configured for your Nylas application.`, return cmd } - -func getProvidersClient() (ports.NylasClient, error) { - configStore := config.NewDefaultFileStore() - cfg, err := configStore.Load() - if err != nil { - cfg = &domain.Config{Region: "us"} - } - - // Check environment variables first (highest priority) - apiKey := os.Getenv("NYLAS_API_KEY") - clientID := os.Getenv("NYLAS_CLIENT_ID") - clientSecret := os.Getenv("NYLAS_CLIENT_SECRET") - - // If API key not in env, try keyring/file store - if apiKey == "" { - secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) - if err == nil { - apiKey, _ = secretStore.Get(ports.KeyAPIKey) - if clientID == "" { - clientID, _ = secretStore.Get(ports.KeyClientID) - } - if clientSecret == "" { - clientSecret, _ = secretStore.Get(ports.KeyClientSecret) - } - } - } - - if apiKey == "" { - return nil, fmt.Errorf("API key not configured. Set NYLAS_API_KEY environment variable or run 'nylas auth config'") - } - - c := nylas.NewHTTPClient() - c.SetRegion(cfg.Region) - c.SetCredentials(clientID, clientSecret, apiKey) - - return c, nil -} diff --git a/internal/cli/auth/scopes.go b/internal/cli/auth/scopes.go index ddb166c..4fc3985 100644 --- a/internal/cli/auth/scopes.go +++ b/internal/cli/auth/scopes.go @@ -3,16 +3,12 @@ package auth import ( "encoding/json" "fmt" - "os" + "strings" "github.com/spf13/cobra" "github.com/nylas/cli/internal/adapters/config" - "github.com/nylas/cli/internal/adapters/keyring" - "github.com/nylas/cli/internal/adapters/nylas" "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" - "github.com/nylas/cli/internal/ports" ) func newScopesCmd() *cobra.Command { @@ -42,7 +38,7 @@ If no grant ID is provided, shows scopes for the currently active grant.`, ctx, cancel := common.CreateContext() defer cancel() - client, err := getScopesClient() + client, err := common.GetNylasClient() if err != nil { return err } @@ -121,118 +117,64 @@ If no grant ID is provided, shows scopes for the currently active grant.`, // describeScopeCategory provides a brief description for common scope patterns func describeScopeCategory(scope string) string { // Google scopes - if contains(scope, "gmail") { - if contains(scope, "readonly") { + if strings.Contains(scope, "gmail") { + if strings.Contains(scope, "readonly") { return "→ Read-only access to Gmail" } - if contains(scope, "send") { + if strings.Contains(scope, "send") { return "→ Send email via Gmail" } - if contains(scope, "modify") || contains(scope, "compose") { + if strings.Contains(scope, "modify") || strings.Contains(scope, "compose") { return "→ Read and modify Gmail messages" } return "→ Gmail access" } - if contains(scope, "calendar") { - if contains(scope, "readonly") { + if strings.Contains(scope, "calendar") { + if strings.Contains(scope, "readonly") { return "→ Read-only access to Google Calendar" } - if contains(scope, "events") { + if strings.Contains(scope, "events") { return "→ Manage calendar events" } return "→ Calendar access" } - if contains(scope, "contacts") { - if contains(scope, "readonly") { + if strings.Contains(scope, "contacts") { + if strings.Contains(scope, "readonly") { return "→ Read-only access to contacts" } return "→ Manage contacts" } // Microsoft scopes - if contains(scope, "Mail.") { - if contains(scope, "Read") { + if strings.Contains(scope, "Mail.") { + if strings.Contains(scope, "Read") { return "→ Read email messages" } - if contains(scope, "Send") { + if strings.Contains(scope, "Send") { return "→ Send email messages" } return "→ Email access" } - if contains(scope, "Calendars.") { - if contains(scope, "Read") { + if strings.Contains(scope, "Calendars.") { + if strings.Contains(scope, "Read") { return "→ Read calendar events" } return "→ Manage calendar events" } - if contains(scope, "Contacts.") { - if contains(scope, "Read") { + if strings.Contains(scope, "Contacts.") { + if strings.Contains(scope, "Read") { return "→ Read contacts" } return "→ Manage contacts" } - if contains(scope, "User.Read") { + if strings.Contains(scope, "User.Read") { return "→ Read user profile information" } return "" } - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || - (len(s) > len(substr) && (s[:len(substr)] == substr || - s[len(s)-len(substr):] == substr || - s[max(0, len(s)-len(substr)-1):len(s)-len(substr)] == substr || - findSubstring(s, substr)))) -} - -func findSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -func getScopesClient() (ports.NylasClient, error) { - configStore := config.NewDefaultFileStore() - cfg, err := configStore.Load() - if err != nil { - cfg = &domain.Config{Region: "us"} - } - - // Check environment variables first (highest priority) - apiKey := os.Getenv("NYLAS_API_KEY") - clientID := os.Getenv("NYLAS_CLIENT_ID") - clientSecret := os.Getenv("NYLAS_CLIENT_SECRET") - - // If API key not in env, try keyring/file store - if apiKey == "" { - secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) - if err == nil { - apiKey, _ = secretStore.Get(ports.KeyAPIKey) - if clientID == "" { - clientID, _ = secretStore.Get(ports.KeyClientID) - } - if clientSecret == "" { - clientSecret, _ = secretStore.Get(ports.KeyClientSecret) - } - } - } - - if apiKey == "" { - return nil, fmt.Errorf("API key not configured. Set NYLAS_API_KEY environment variable or run 'nylas auth config'") - } - - c := nylas.NewHTTPClient() - c.SetRegion(cfg.Region) - c.SetCredentials(clientID, clientSecret, apiKey) - - return c, nil -} diff --git a/internal/cli/common/output.go b/internal/cli/common/output.go index dac20fd..e070932 100644 --- a/internal/cli/common/output.go +++ b/internal/cli/common/output.go @@ -79,6 +79,16 @@ func IsJSON(cmd *cobra.Command) bool { return format == "json" } +// IsStructuredOutput returns true if non-table output is requested (JSON, YAML, or quiet). +func IsStructuredOutput(cmd *cobra.Command) bool { + if IsJSON(cmd) { + return true + } + format, _ := cmd.Flags().GetString("format") + quiet, _ := cmd.Flags().GetBool("quiet") + return format == "yaml" || format == "quiet" || quiet +} + // IsWide returns true if wide output mode is enabled func IsWide(cmd *cobra.Command) bool { wide, _ := cmd.Flags().GetBool("wide") diff --git a/internal/cli/config/get_test.go b/internal/cli/config/get_test.go index c0b84da..707b0b0 100644 --- a/internal/cli/config/get_test.go +++ b/internal/cli/config/get_test.go @@ -1,6 +1,7 @@ package config import ( + "strings" "testing" ) @@ -167,7 +168,7 @@ func TestGetConfigValue(t *testing.T) { t.Errorf("getConfigValue() expected error containing %q, got nil", tt.errMsg) return } - if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("getConfigValue() error = %q, want error containing %q", err.Error(), tt.errMsg) } return @@ -194,18 +195,3 @@ func TestGetConfigValue_WithNonStruct(t *testing.T) { t.Error("getConfigValue() with non-struct should return error, got nil") } } - -// Helper function to check if a string contains a substring -func contains(s, substr string) bool { - return len(substr) > 0 && len(s) >= len(substr) && s[:len(substr)] == substr || - len(s) > len(substr) && containsHelper(s, substr) -} - -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/internal/cli/config/gpg_test.go b/internal/cli/config/gpg_test.go index 4f79f84..f6b098e 100644 --- a/internal/cli/config/gpg_test.go +++ b/internal/cli/config/gpg_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "github.com/nylas/cli/internal/adapters/config" @@ -182,7 +183,7 @@ func TestGPGConfig_FileFormat(t *testing.T) { } for _, line := range expectedLines { - if !contains(content, line) { + if !strings.Contains(content, line) { t.Errorf("config file missing expected line: %s\nGot:\n%s", line, content) } } diff --git a/internal/cli/config/set_test.go b/internal/cli/config/set_test.go index 92a27c9..791c247 100644 --- a/internal/cli/config/set_test.go +++ b/internal/cli/config/set_test.go @@ -2,6 +2,7 @@ package config import ( "reflect" + "strings" "testing" ) @@ -109,7 +110,7 @@ func TestSetFieldValue(t *testing.T) { t.Errorf("setFieldValue() expected error containing %q, got nil", tt.errMsg) return } - if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("setFieldValue() error = %q, want error containing %q", err.Error(), tt.errMsg) } return @@ -250,7 +251,7 @@ func TestSetConfigValue(t *testing.T) { t.Errorf("setConfigValue() expected error containing %q, got nil", tt.errMsg) return } - if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("setConfigValue() error = %q, want error containing %q", err.Error(), tt.errMsg) } return diff --git a/internal/cli/email/folders.go b/internal/cli/email/folders.go index ee4ab6e..266e8b1 100644 --- a/internal/cli/email/folders.go +++ b/internal/cli/email/folders.go @@ -73,17 +73,17 @@ func newFoldersListCmd() *cobra.Command { name = name[:25] + "..." } - unreadStr := fmt.Sprintf("%d", f.UnreadCount) + unreadPadded := fmt.Sprintf("%8d", f.UnreadCount) if f.UnreadCount > 0 { - unreadStr = common.Cyan.Sprintf("%d", f.UnreadCount) + unreadPadded = common.Cyan.Sprint(unreadPadded) } if showID { - fmt.Printf("%-36s %-30s %-12s %8d %8s\n", - common.Dim.Sprint(f.ID), name, folderType, f.TotalCount, unreadStr) + fmt.Printf("%-36s %-30s %-12s %8d %s\n", + common.Dim.Sprint(f.ID), name, folderType, f.TotalCount, unreadPadded) } else { - fmt.Printf("%-30s %-12s %8d %8s\n", - name, folderType, f.TotalCount, unreadStr) + fmt.Printf("%-30s %-12s %8d %s\n", + name, folderType, f.TotalCount, unreadPadded) } } diff --git a/internal/cli/slack/auth.go b/internal/cli/slack/auth.go index 83053b4..7540cad 100644 --- a/internal/cli/slack/auth.go +++ b/internal/cli/slack/auth.go @@ -106,9 +106,7 @@ func newAuthStatusCmd() *cobra.Command { } // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(auth) } diff --git a/internal/cli/slack/channel_info.go b/internal/cli/slack/channel_info.go index a775966..9020567 100644 --- a/internal/cli/slack/channel_info.go +++ b/internal/cli/slack/channel_info.go @@ -3,11 +3,13 @@ package slack import ( + "context" "fmt" "github.com/spf13/cobra" "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" ) // newChannelInfoCmd creates the info subcommand for getting channel details. @@ -17,52 +19,41 @@ func newChannelInfoCmd() *cobra.Command { Short: "Get detailed info about a channel", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() - if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) - } - - ctx, cancel := common.CreateContext() - defer cancel() - - channelID := args[0] - ch, err := client.GetChannel(ctx, channelID) - if err != nil { - return common.WrapGetError("channel", err) - } - - // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { - out := common.GetOutputWriter(cmd) - return out.Write(ch) - } - - _, _ = common.Cyan.Printf("Channel: #%s\n", ch.Name) - fmt.Printf(" ID: %s\n", ch.ID) - fmt.Printf(" Is Channel: %v\n", ch.IsChannel) - fmt.Printf(" Is Private: %v\n", ch.IsPrivate) - fmt.Printf(" Is Archived: %v\n", ch.IsArchived) - fmt.Printf(" Is Member: %v\n", ch.IsMember) - fmt.Printf(" Is Shared: %v\n", ch.IsShared) - fmt.Printf(" Is OrgShared: %v\n", ch.IsOrgShared) - fmt.Printf(" Is ExtShared: %v\n", ch.IsExtShared) - fmt.Printf(" Is IM: %v\n", ch.IsIM) - fmt.Printf(" Is MPIM: %v\n", ch.IsMPIM) - fmt.Printf(" Is Group: %v\n", ch.IsGroup) - fmt.Printf(" Members: %d\n", ch.MemberCount) - if ch.Purpose != "" { - _, _ = common.Dim.Printf(" Purpose: %s\n", ch.Purpose) - } - if ch.Topic != "" { - _, _ = common.Dim.Printf(" Topic: %s\n", ch.Topic) - } - - return nil + return withSlackClient(func(ctx context.Context, client ports.SlackClient) error { + channelID := args[0] + ch, err := client.GetChannel(ctx, channelID) + if err != nil { + return common.WrapGetError("channel", err) + } + + // Handle structured output (JSON/YAML/quiet) + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return out.Write(ch) + } + + _, _ = common.Cyan.Printf("Channel: #%s\n", ch.Name) + fmt.Printf(" ID: %s\n", ch.ID) + fmt.Printf(" Is Channel: %v\n", ch.IsChannel) + fmt.Printf(" Is Private: %v\n", ch.IsPrivate) + fmt.Printf(" Is Archived: %v\n", ch.IsArchived) + fmt.Printf(" Is Member: %v\n", ch.IsMember) + fmt.Printf(" Is Shared: %v\n", ch.IsShared) + fmt.Printf(" Is OrgShared: %v\n", ch.IsOrgShared) + fmt.Printf(" Is ExtShared: %v\n", ch.IsExtShared) + fmt.Printf(" Is IM: %v\n", ch.IsIM) + fmt.Printf(" Is MPIM: %v\n", ch.IsMPIM) + fmt.Printf(" Is Group: %v\n", ch.IsGroup) + fmt.Printf(" Members: %d\n", ch.MemberCount) + if ch.Purpose != "" { + _, _ = common.Dim.Printf(" Purpose: %s\n", ch.Purpose) + } + if ch.Topic != "" { + _, _ = common.Dim.Printf(" Topic: %s\n", ch.Topic) + } + + return nil + }) }, } diff --git a/internal/cli/slack/channels.go b/internal/cli/slack/channels.go index ed88088..85d15bd 100644 --- a/internal/cli/slack/channels.go +++ b/internal/cli/slack/channels.go @@ -69,12 +69,9 @@ Examples: # Exclude archived channels nylas slack channels list --exclude-archived`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } // Parse created-after duration if provided @@ -163,9 +160,7 @@ Examples: } // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(allChannels) } diff --git a/internal/cli/slack/files.go b/internal/cli/slack/files.go index 2b074dd..2bb5f00 100644 --- a/internal/cli/slack/files.go +++ b/internal/cli/slack/files.go @@ -77,12 +77,9 @@ Examples: # List files uploaded by a specific user nylas slack files list --user U1234567890`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() @@ -124,9 +121,7 @@ Examples: } // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(resp.Files) } @@ -193,12 +188,9 @@ func newFilesShowCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { fileID := args[0] - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() @@ -210,9 +202,7 @@ func newFilesShowCmd() *cobra.Command { } // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(file) } @@ -278,12 +268,9 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { fileID := args[0] - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() diff --git a/internal/cli/slack/helpers.go b/internal/cli/slack/helpers.go index 202180e..cf4b313 100644 --- a/internal/cli/slack/helpers.go +++ b/internal/cli/slack/helpers.go @@ -11,6 +11,7 @@ import ( "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/domain" "github.com/nylas/cli/internal/ports" ) @@ -71,6 +72,29 @@ func getSlackClient(token string) (ports.SlackClient, error) { return slackadapter.NewClient(config) } +// withSlackClient creates a Slack client and context, then runs fn. +func withSlackClient(fn func(ctx context.Context, client ports.SlackClient) error) error { + client, err := getSlackClientOrError() + if err != nil { + return err + } + ctx, cancel := common.CreateContext() + defer cancel() + return fn(ctx, client) +} + +// getSlackClientOrError wraps getSlackClientFromKeyring with a user-friendly error. +func getSlackClientOrError() (ports.SlackClient, error) { + client, err := getSlackClientFromKeyring() + if err != nil { + return nil, common.NewUserError( + "not authenticated with Slack", + "Run: nylas slack auth set --token YOUR_TOKEN", + ) + } + return client, nil +} + // createContext creates a context with default timeout. // Uses common.CreateContext for consistency across CLI packages. diff --git a/internal/cli/slack/messages.go b/internal/cli/slack/messages.go index bdfdf87..ffcd58b 100644 --- a/internal/cli/slack/messages.go +++ b/internal/cli/slack/messages.go @@ -66,12 +66,9 @@ Examples: # Expand all threads inline (show thread replies under parent messages) nylas slack messages list --channel general --expand-threads`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } // Use longer timeout when fetching all messages @@ -144,9 +141,7 @@ Examples: } // Handle structured output (JSON/YAML/quiet) - before enrichment for performance - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(allMessages) } diff --git a/internal/cli/slack/search.go b/internal/cli/slack/search.go index 10111f2..cba9e0e 100644 --- a/internal/cli/slack/search.go +++ b/internal/cli/slack/search.go @@ -43,12 +43,9 @@ Examples: return common.NewUserError("search query is required", "Use --query") } - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() @@ -65,9 +62,7 @@ Examples: } // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(messages) } diff --git a/internal/cli/slack/send.go b/internal/cli/slack/send.go index 9594602..011e8ce 100644 --- a/internal/cli/slack/send.go +++ b/internal/cli/slack/send.go @@ -38,12 +38,9 @@ Examples: # Send without confirmation nylas slack send --channel general --text "Quick update" --yes`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() @@ -126,12 +123,9 @@ Examples: # Reply and also post to channel nylas slack reply --channel general --thread 1234567890.123456 --text "Update" --broadcast`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() diff --git a/internal/cli/slack/users.go b/internal/cli/slack/users.go index 7154613..d6d2bf5 100644 --- a/internal/cli/slack/users.go +++ b/internal/cli/slack/users.go @@ -51,12 +51,9 @@ Examples: # Limit results nylas slack users list --limit 20`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() @@ -73,9 +70,7 @@ Examples: } // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(resp.Users) } @@ -135,12 +130,9 @@ Examples: nylas slack users get @username`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := getSlackClientFromKeyring() + client, err := getSlackClientOrError() if err != nil { - return common.NewUserError( - "not authenticated with Slack", - "Run: nylas slack auth set --token YOUR_TOKEN", - ) + return err } ctx, cancel := common.CreateContext() @@ -166,9 +158,7 @@ Examples: } // Handle structured output (JSON/YAML/quiet) - format, _ := cmd.Flags().GetString("format") - quiet, _ := cmd.Flags().GetBool("quiet") - if common.IsJSON(cmd) || format == "yaml" || quiet { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(user) } From bcb11c9778d8ae24b19e4673c21b02d32f946559 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 13 Feb 2026 08:10:07 -0500 Subject: [PATCH 6/9] refactor(cli): replace local duplicates with common helpers - Replace local truncate/truncateString with common.Truncate() (audit, slack, integration tests) - Replace local printJSON with common.PrintJSON() (timezone package, 5 files) - Widen IsJSON(cmd) to IsStructuredOutput(cmd) so --format yaml and --quiet use the structured output path instead of falling through to table (15 files) - Fix buggy truncate in email_gpg_test.go that produced maxLen+3 chars --- internal/cli/audit/logs_show.go | 13 +++---------- internal/cli/auth/list.go | 2 +- internal/cli/calendar/events_list.go | 2 +- internal/cli/calendar/list.go | 6 +++--- internal/cli/contacts/groups.go | 2 +- internal/cli/contacts/list.go | 2 +- internal/cli/email/attachments.go | 2 +- internal/cli/email/drafts.go | 2 +- internal/cli/email/folders.go | 2 +- internal/cli/email/list.go | 2 +- internal/cli/email/scheduled.go | 2 +- internal/cli/email/templates_create.go | 2 +- internal/cli/email/templates_list.go | 2 +- internal/cli/email/templates_show.go | 2 +- internal/cli/email/templates_update.go | 2 +- internal/cli/email/threads.go | 2 +- internal/cli/integration/email_gpg_test.go | 17 ++++++----------- internal/cli/slack/channels.go | 10 +--------- internal/cli/timezone/convert.go | 2 +- internal/cli/timezone/dst.go | 2 +- internal/cli/timezone/find.go | 2 +- internal/cli/timezone/helpers.go | 9 --------- internal/cli/timezone/info.go | 2 +- internal/cli/timezone/list.go | 2 +- 24 files changed, 32 insertions(+), 61 deletions(-) diff --git a/internal/cli/audit/logs_show.go b/internal/cli/audit/logs_show.go index f08244f..ac53423 100644 --- a/internal/cli/audit/logs_show.go +++ b/internal/cli/audit/logs_show.go @@ -169,9 +169,9 @@ func showEntryTable(cmd *cobra.Command, entries []domain.AuditEntry) error { Duration: FormatDuration(e.Duration), RequestID: e.RequestID, HTTPStatus: e.HTTPStatus, - GrantDisplay: truncate(orDash(e.GrantID), 12), - InvokerDisplay: truncate(orDash(e.Invoker), 12), - SourceDisplay: truncate(orDash(e.InvokerSource), 12), + GrantDisplay: common.Truncate(orDash(e.GrantID), 12), + InvokerDisplay: common.Truncate(orDash(e.Invoker), 12), + SourceDisplay: common.Truncate(orDash(e.InvokerSource), 12), } } @@ -246,13 +246,6 @@ func parseDate(s string) (time.Time, error) { return time.Time{}, fmt.Errorf("unrecognized date format: %s", s) } -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max-3] + "..." -} - // orDash returns "-" if s is empty, otherwise returns s. func orDash(s string) string { if s == "" { diff --git a/internal/cli/auth/list.go b/internal/cli/auth/list.go index cf792cf..d4c0d98 100644 --- a/internal/cli/auth/list.go +++ b/internal/cli/auth/list.go @@ -32,7 +32,7 @@ func newListCmd() *cobra.Command { } // Check if we should use structured output - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(grants) } diff --git a/internal/cli/calendar/events_list.go b/internal/cli/calendar/events_list.go index 8538571..b235358 100644 --- a/internal/cli/calendar/events_list.go +++ b/internal/cli/calendar/events_list.go @@ -119,7 +119,7 @@ Examples: } // JSON output (including empty array) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return struct{}{}, out.Write(events) } diff --git a/internal/cli/calendar/list.go b/internal/cli/calendar/list.go index 15b1d2a..7e8162e 100644 --- a/internal/cli/calendar/list.go +++ b/internal/cli/calendar/list.go @@ -27,14 +27,14 @@ func newListCmd() *cobra.Command { } if len(calendars) == 0 { - if !common.IsQuiet() && !common.IsJSON(cmd) { + if !common.IsStructuredOutput(cmd) { common.PrintEmptyState("calendars") } return nil } - // Check if using structured output (JSON/YAML) - if common.IsJSON(cmd) { + // Check if using structured output (JSON/YAML/quiet) + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return out.Write(calendars) } diff --git a/internal/cli/contacts/groups.go b/internal/cli/contacts/groups.go index 1b21616..5c9ed44 100644 --- a/internal/cli/contacts/groups.go +++ b/internal/cli/contacts/groups.go @@ -42,7 +42,7 @@ func newGroupsListCmd() *cobra.Command { } // JSON output (including empty array) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return struct{}{}, out.Write(groups) } diff --git a/internal/cli/contacts/list.go b/internal/cli/contacts/list.go index 86d0727..ab36cde 100644 --- a/internal/cli/contacts/list.go +++ b/internal/cli/contacts/list.go @@ -33,7 +33,7 @@ func newListCmd() *cobra.Command { } // Check if we should use structured output (JSON/YAML/quiet) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { params := &domain.ContactQueryParams{ Limit: limit, diff --git a/internal/cli/email/attachments.go b/internal/cli/email/attachments.go index 616bab6..08528ae 100644 --- a/internal/cli/email/attachments.go +++ b/internal/cli/email/attachments.go @@ -46,7 +46,7 @@ func newAttachmentsListCmd() *cobra.Command { } // JSON output (including empty array) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return struct{}{}, out.Write(attachments) } diff --git a/internal/cli/email/drafts.go b/internal/cli/email/drafts.go index b9af5e6..911907d 100644 --- a/internal/cli/email/drafts.go +++ b/internal/cli/email/drafts.go @@ -47,7 +47,7 @@ func newDraftsListCmd() *cobra.Command { } // JSON output (including empty array) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return struct{}{}, out.Write(drafts) } diff --git a/internal/cli/email/folders.go b/internal/cli/email/folders.go index 266e8b1..131b925 100644 --- a/internal/cli/email/folders.go +++ b/internal/cli/email/folders.go @@ -41,7 +41,7 @@ func newFoldersListCmd() *cobra.Command { } // JSON output (including empty array) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return struct{}{}, out.Write(folders) } diff --git a/internal/cli/email/list.go b/internal/cli/email/list.go index cad1d4a..37b0327 100644 --- a/internal/cli/email/list.go +++ b/internal/cli/email/list.go @@ -51,7 +51,7 @@ Use --max to limit total messages when using --all.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Check if we should use structured output (JSON/YAML/quiet) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { return runListStructured(cmd, args, limit, unread, starred, from, folder, allFolders, all, maxItems, metadataPair) } diff --git a/internal/cli/email/scheduled.go b/internal/cli/email/scheduled.go index 65a9158..545b1ed 100644 --- a/internal/cli/email/scheduled.go +++ b/internal/cli/email/scheduled.go @@ -38,7 +38,7 @@ func newScheduledListCmd() *cobra.Command { } // JSON output (including empty array) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return struct{}{}, out.Write(scheduled) } diff --git a/internal/cli/email/templates_create.go b/internal/cli/email/templates_create.go index ced9ef8..9fd21b1 100644 --- a/internal/cli/email/templates_create.go +++ b/internal/cli/email/templates_create.go @@ -111,7 +111,7 @@ Use --interactive for a guided creation experience.`, } // JSON output - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { return common.PrintJSON(created) } diff --git a/internal/cli/email/templates_list.go b/internal/cli/email/templates_list.go index b3eac89..5e06c79 100644 --- a/internal/cli/email/templates_list.go +++ b/internal/cli/email/templates_list.go @@ -36,7 +36,7 @@ Use --json or --yaml for machine-readable output.`, } // JSON output - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { return common.PrintJSON(templates) } diff --git a/internal/cli/email/templates_show.go b/internal/cli/email/templates_show.go index 1cb8b72..00d8384 100644 --- a/internal/cli/email/templates_show.go +++ b/internal/cli/email/templates_show.go @@ -41,7 +41,7 @@ Shows the template's name, subject, body, variables, and usage statistics.`, } // JSON output - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { return common.PrintJSON(tpl) } diff --git a/internal/cli/email/templates_update.go b/internal/cli/email/templates_update.go index 9a4c96a..8f471ad 100644 --- a/internal/cli/email/templates_update.go +++ b/internal/cli/email/templates_update.go @@ -81,7 +81,7 @@ re-extracted from the updated subject and body.`, } // JSON output - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { return common.PrintJSON(updated) } diff --git a/internal/cli/email/threads.go b/internal/cli/email/threads.go index 80a1600..2574919 100644 --- a/internal/cli/email/threads.go +++ b/internal/cli/email/threads.go @@ -59,7 +59,7 @@ func newThreadsListCmd() *cobra.Command { } // JSON output (including empty array) - if common.IsJSON(cmd) { + if common.IsStructuredOutput(cmd) { out := common.GetOutputWriter(cmd) return struct{}{}, out.Write(threads) } diff --git a/internal/cli/integration/email_gpg_test.go b/internal/cli/integration/email_gpg_test.go index 378a08a..31a323e 100644 --- a/internal/cli/integration/email_gpg_test.go +++ b/internal/cli/integration/email_gpg_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/nylas/cli/internal/cli/common" ) // ============================================================================= @@ -224,7 +226,7 @@ func TestCLI_EmailRead_RawMIME(t *testing.T) { t.Errorf("Expected MIME headers in output, got: %s", mimeStdout) } - t.Logf("Raw MIME output (first 500 chars):\n%s", truncate(mimeStdout, 500)) + t.Logf("Raw MIME output (first 500 chars):\n%s", common.Truncate(mimeStdout, 500)) // Cleanup acquireRateLimit(t) @@ -275,17 +277,17 @@ func TestCLI_EmailRead_SignedMIME(t *testing.T) { // Should be multipart/signed if !strings.Contains(mimeStdout, "multipart/signed") { - t.Errorf("Expected 'multipart/signed' in MIME output, got: %s", truncate(mimeStdout, 500)) + t.Errorf("Expected 'multipart/signed' in MIME output, got: %s", common.Truncate(mimeStdout, 500)) } // Should contain PGP signature if !strings.Contains(mimeStdout, "application/pgp-signature") { - t.Errorf("Expected 'application/pgp-signature' in MIME output, got: %s", truncate(mimeStdout, 500)) + t.Errorf("Expected 'application/pgp-signature' in MIME output, got: %s", common.Truncate(mimeStdout, 500)) } // Should contain BEGIN PGP SIGNATURE if !strings.Contains(mimeStdout, "BEGIN PGP SIGNATURE") { - t.Errorf("Expected 'BEGIN PGP SIGNATURE' in MIME output, got: %s", truncate(mimeStdout, 500)) + t.Errorf("Expected 'BEGIN PGP SIGNATURE' in MIME output, got: %s", common.Truncate(mimeStdout, 500)) } t.Logf("Signed MIME structure verified") @@ -354,10 +356,3 @@ func extractMessageID(output string) string { return "" } -// truncate truncates a string to maxLen characters -func truncate(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." -} diff --git a/internal/cli/slack/channels.go b/internal/cli/slack/channels.go index 85d15bd..dba7897 100644 --- a/internal/cli/slack/channels.go +++ b/internal/cli/slack/channels.go @@ -218,15 +218,7 @@ func printChannels(channels []domain.SlackChannel, showID bool) { fmt.Println() if ch.Purpose != "" { - _, _ = dim.Printf(" %s\n", truncateString(ch.Purpose, 60)) + _, _ = dim.Printf(" %s\n", common.Truncate(ch.Purpose, 60)) } } } - -// truncateString shortens a string to maxLen, adding "..." if truncated. -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cli/timezone/convert.go b/internal/cli/timezone/convert.go index 6a7dc4b..5a2c08b 100644 --- a/internal/cli/timezone/convert.go +++ b/internal/cli/timezone/convert.go @@ -93,7 +93,7 @@ func runConvert(fromZone, toZone, timeStr string, jsonOut bool) error { // Output if jsonOut { - return printJSON(map[string]any{ + return common.PrintJSON(map[string]any{ "from": map[string]any{ "zone": fromZone, "time": inputTime.Format(time.RFC3339), diff --git a/internal/cli/timezone/dst.go b/internal/cli/timezone/dst.go index 73ca077..f10fe82 100644 --- a/internal/cli/timezone/dst.go +++ b/internal/cli/timezone/dst.go @@ -69,7 +69,7 @@ func runDST(zone string, year int, jsonOut bool) error { // Output if jsonOut { - return printJSON(map[string]any{ + return common.PrintJSON(map[string]any{ "zone": zone, "year": year, "transitions": transitions, diff --git a/internal/cli/timezone/find.go b/internal/cli/timezone/find.go index a16714b..2e2dc9b 100644 --- a/internal/cli/timezone/find.go +++ b/internal/cli/timezone/find.go @@ -144,7 +144,7 @@ func runFindMeeting(zonesStr, durationStr, startHour, endHour, // Output if jsonOut { - return printJSON(result) + return common.PrintJSON(result) } // Human-readable output diff --git a/internal/cli/timezone/helpers.go b/internal/cli/timezone/helpers.go index 5ac7d07..a4c2ae1 100644 --- a/internal/cli/timezone/helpers.go +++ b/internal/cli/timezone/helpers.go @@ -1,9 +1,7 @@ package timezone import ( - "encoding/json" "fmt" - "os" "strings" "time" @@ -24,13 +22,6 @@ func formatTime(t time.Time, showZone bool) string { return t.Format("2006-01-02 15:04:05") } -// printJSON prints data as formatted JSON. -func printJSON(data any) error { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(data) -} - // parseTimeZones parses a comma-separated list of time zones. func parseTimeZones(zonesStr string) []string { if zonesStr == "" { diff --git a/internal/cli/timezone/info.go b/internal/cli/timezone/info.go index 6a812b2..9969139 100644 --- a/internal/cli/timezone/info.go +++ b/internal/cli/timezone/info.go @@ -96,7 +96,7 @@ func runInfo(zone, timeStr string, jsonOut bool) error { // Output if jsonOut { - return printJSON(map[string]any{ + return common.PrintJSON(map[string]any{ "zone": info.Name, "abbreviation": info.Abbreviation, "offset": formatOffset(info.Offset), diff --git a/internal/cli/timezone/list.go b/internal/cli/timezone/list.go index 689e891..bfc0947 100644 --- a/internal/cli/timezone/list.go +++ b/internal/cli/timezone/list.go @@ -72,7 +72,7 @@ func runList(filter string, jsonOut bool) error { // Output if jsonOut { - return printJSON(map[string]any{ + return common.PrintJSON(map[string]any{ "zones": zones, "count": len(zones), }) From 4ab622e29a7f336655144a3647950d6c57f204b2 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 13 Feb 2026 10:49:03 -0500 Subject: [PATCH 7/9] fix(inbound): accept full email addresses and sync grants locally The create command rejected '@' and '*' characters, preventing full email addresses and wildcard patterns. Also, created inboxes were not saved to the local grant store, so they didn't appear in `auth list`. --- internal/cli/inbound/create.go | 41 +++++++++++++++------------- internal/cli/inbound/delete.go | 3 ++ internal/cli/inbound/helpers.go | 26 ++++++++++++++++++ internal/cli/inbound/inbound_test.go | 7 ++--- 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/internal/cli/inbound/create.go b/internal/cli/inbound/create.go index edd1cae..581e36a 100644 --- a/internal/cli/inbound/create.go +++ b/internal/cli/inbound/create.go @@ -15,21 +15,22 @@ func newCreateCmd() *cobra.Command { var jsonOutput bool cmd := &cobra.Command{ - Use: "create ", + Use: "create ", Short: "Create a new inbound inbox", Long: `Create a new inbound inbox with a managed email address. -The email prefix you provide will be combined with your application's -Nylas domain to create the full email address (e.g., support@yourapp.nylas.email). +You can provide either a full email address or just the local part (prefix). +Wildcards (*) are supported for catch-all patterns. Examples: - # Create a support inbox + # Create with full email address + nylas inbound create support@yourapp.nylas.email + + # Create with just the prefix (domain added by API) nylas inbound create support - # Creates: support@yourapp.nylas.email - # Create a leads inbox - nylas inbound create leads - # Creates: leads@yourapp.nylas.email + # Create a wildcard catch-all inbox + nylas inbound create "e2e-*@yourapp.nylas.email" # Create and output as JSON nylas inbound create tickets --json`, @@ -44,26 +45,27 @@ Examples: return cmd } -func runCreate(emailPrefix string, jsonOutput bool) error { - // Validate email prefix - emailPrefix = strings.TrimSpace(emailPrefix) - if emailPrefix == "" { - printError("Email prefix cannot be empty") - return common.NewInputError("email prefix cannot be empty") +func runCreate(email string, jsonOutput bool) error { + email = strings.TrimSpace(email) + if email == "" { + printError("Email address cannot be empty") + return common.NewInputError("email address cannot be empty") } - // Basic validation - no @ symbol, no spaces - if strings.Contains(emailPrefix, "@") || strings.Contains(emailPrefix, " ") { - printError("Email prefix should not contain '@' or spaces. Just provide the local part (e.g., 'support')") - return common.NewInputError("invalid email prefix - should not contain '@' or spaces") + if strings.Contains(email, " ") { + printError("Email address should not contain spaces") + return common.NewInputError("invalid email address - should not contain spaces") } _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - inbox, err := client.CreateInboundInbox(ctx, emailPrefix) + inbox, err := client.CreateInboundInbox(ctx, email) if err != nil { return struct{}{}, common.WrapCreateError("inbound inbox", err) } + // Save the new grant to local store so it appears in `nylas auth list` + saveGrantLocally(inbox.ID, inbox.Email) + if jsonOutput { data, _ := json.MarshalIndent(inbox, "", " ") fmt.Println(string(data)) @@ -85,3 +87,4 @@ func runCreate(emailPrefix string, jsonOutput bool) error { return err } + diff --git a/internal/cli/inbound/delete.go b/internal/cli/inbound/delete.go index 6346315..74ac0b8 100644 --- a/internal/cli/inbound/delete.go +++ b/internal/cli/inbound/delete.go @@ -89,6 +89,9 @@ Examples: return common.WrapDeleteError("inbox", err) } + // Remove from local grant store + removeGrantLocally(inboxID) + printSuccess("Inbox %s deleted successfully!", inbox.Email) return nil diff --git a/internal/cli/inbound/helpers.go b/internal/cli/inbound/helpers.go index a63d029..b4d5a9b 100644 --- a/internal/cli/inbound/helpers.go +++ b/internal/cli/inbound/helpers.go @@ -5,6 +5,8 @@ import ( "os" "strings" + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/domain" ) @@ -80,6 +82,30 @@ func formatStatus(status string) string { } } +// saveGrantLocally saves the inbound inbox grant to the local keyring store. +func saveGrantLocally(grantID, email string) { + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return + } + grantStore := keyring.NewGrantStore(secretStore) + _ = grantStore.SaveGrant(domain.GrantInfo{ + ID: grantID, + Email: email, + Provider: domain.ProviderInbox, + }) +} + +// removeGrantLocally removes the inbound inbox grant from the local keyring store. +func removeGrantLocally(grantID string) { + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return + } + grantStore := keyring.NewGrantStore(secretStore) + _ = grantStore.DeleteGrant(grantID) +} + // printInboundMessageSummary prints an inbound message summary. func printInboundMessageSummary(msg domain.InboundMessage, _ int) { status := " " diff --git a/internal/cli/inbound/inbound_test.go b/internal/cli/inbound/inbound_test.go index 4fbc4fd..465de22 100644 --- a/internal/cli/inbound/inbound_test.go +++ b/internal/cli/inbound/inbound_test.go @@ -131,7 +131,7 @@ func TestCreateCommand(t *testing.T) { cmd := newCreateCmd() t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "create ", cmd.Use) + assert.Equal(t, "create ", cmd.Use) }) t.Run("has_short_description", func(t *testing.T) { @@ -146,8 +146,7 @@ func TestCreateCommand(t *testing.T) { }) t.Run("requires_one_argument", func(t *testing.T) { - // Test that the command requires exactly one argument by checking the help text - assert.Contains(t, cmd.Use, "") + assert.Contains(t, cmd.Use, "") }) t.Run("has_long_description_with_examples", func(t *testing.T) { @@ -465,7 +464,7 @@ func TestInboundCreateHelp(t *testing.T) { assert.NoError(t, err) assert.Contains(t, stdout, "create") assert.Contains(t, stdout, "--json") - assert.Contains(t, stdout, "email-prefix") + assert.Contains(t, stdout, "email") } func TestInboundDeleteHelp(t *testing.T) { From da58820df6eaf4dd47df51800af4eed7d190451e Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 13 Feb 2026 10:59:59 -0500 Subject: [PATCH 8/9] fix(chat): return empty slice instead of nil from DetectAgents When no agents are installed (e.g., CI runners), DetectAgents returned nil which caused TestDetectAgents_Structure to fail with NotNil assert. --- internal/chat/agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/chat/agent.go b/internal/chat/agent.go index 59bbaad..1638196 100644 --- a/internal/chat/agent.go +++ b/internal/chat/agent.go @@ -30,7 +30,7 @@ type Agent struct { // DetectAgents scans the system for installed AI agents. // It checks for claude, codex, and ollama in $PATH. func DetectAgents() []Agent { - var agents []Agent + agents := []Agent{} checks := []struct { name AgentType From 134a5297f3d9b099f912ed553f246358eb7a745e Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 13 Feb 2026 11:00:29 -0500 Subject: [PATCH 9/9] fix inbound --- internal/cli/inbound/create.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cli/inbound/create.go b/internal/cli/inbound/create.go index 581e36a..fbd4afe 100644 --- a/internal/cli/inbound/create.go +++ b/internal/cli/inbound/create.go @@ -87,4 +87,3 @@ func runCreate(email string, jsonOutput bool) error { return err } -