390 lines
10 KiB
Go
390 lines
10 KiB
Go
// Package copilot provides integration with the GitHub Copilot SDK.
|
|
// This is a thin wrapper around github.com/github/copilot-sdk/go that
|
|
// provides a consistent interface for the kandev agent system.
|
|
//
|
|
// When CLIUrl is configured, the SDK connects to an externally managed
|
|
// Copilot CLI server via TCP (JSON-RPC). Otherwise, the SDK spawns and
|
|
// manages the CLI process internally via stdio.
|
|
package copilot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/github/copilot-sdk/go"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Re-export SDK types for convenience
|
|
type (
|
|
SessionEvent = copilot.SessionEvent
|
|
SessionEventType = copilot.SessionEventType
|
|
SessionConfig = copilot.SessionConfig
|
|
MessageOptions = copilot.MessageOptions
|
|
Data = copilot.Data
|
|
// Permission types
|
|
PermissionHandler = copilot.PermissionHandler
|
|
PermissionRequest = copilot.PermissionRequest
|
|
PermissionInvocation = copilot.PermissionInvocation
|
|
PermissionRequestResult = copilot.PermissionRequestResult
|
|
// MCP types
|
|
MCPServerConfig = copilot.MCPServerConfig
|
|
)
|
|
|
|
// Re-export event type constants
|
|
const (
|
|
EventTypeSessionStart = copilot.SessionStart
|
|
EventTypeSessionResume = copilot.SessionResume
|
|
EventTypeSessionIdle = copilot.SessionIdle
|
|
EventTypeSessionError = copilot.SessionError
|
|
EventTypeSessionUsageInfo = copilot.SessionUsageInfo
|
|
EventTypeAssistantMessage = copilot.AssistantMessage
|
|
EventTypeAssistantMessageDelta = copilot.AssistantMessageDelta
|
|
EventTypeAssistantReasoning = copilot.AssistantReasoning
|
|
EventTypeAssistantReasoningDelta = copilot.AssistantReasoningDelta
|
|
EventTypeAssistantTurnStart = copilot.AssistantTurnStart
|
|
EventTypeAssistantTurnEnd = copilot.AssistantTurnEnd
|
|
EventTypeAssistantUsage = copilot.AssistantUsage
|
|
EventTypeToolStart = copilot.ToolExecutionStart
|
|
EventTypeToolComplete = copilot.ToolExecutionComplete
|
|
EventTypeToolProgress = copilot.ToolExecutionProgress
|
|
EventTypeAbort = copilot.Abort
|
|
)
|
|
|
|
// Client wraps the Copilot SDK client with additional functionality.
|
|
type Client struct {
|
|
sdkClient *copilot.Client
|
|
session *copilot.Session
|
|
logger *logger.Logger
|
|
|
|
// Configuration
|
|
cliURL string
|
|
model string
|
|
|
|
// Event handler
|
|
eventHandler func(SessionEvent)
|
|
unsubscribe func()
|
|
handlerMu sync.RWMutex
|
|
|
|
// Permission handler
|
|
permissionHandler PermissionHandler
|
|
permissionMu sync.RWMutex
|
|
|
|
// State
|
|
sessionID string
|
|
mu sync.RWMutex
|
|
started bool
|
|
}
|
|
|
|
// ClientConfig holds configuration for creating a Client.
|
|
type ClientConfig struct {
|
|
// CLIUrl is the address of an externally managed Copilot CLI server (e.g. "localhost:12345").
|
|
// When set, the SDK connects via TCP instead of spawning its own process.
|
|
CLIUrl string
|
|
Model string
|
|
}
|
|
|
|
// NewClient creates a new Copilot client wrapper.
|
|
func NewClient(cfg ClientConfig, log *logger.Logger) *Client {
|
|
if cfg.Model == "" {
|
|
cfg.Model = "gpt-4.1"
|
|
}
|
|
|
|
return &Client{
|
|
cliURL: cfg.CLIUrl,
|
|
model: cfg.Model,
|
|
logger: log.WithFields(zap.String("component", "copilot-sdk-client")),
|
|
}
|
|
}
|
|
|
|
// SetEventHandler sets the handler for session events.
|
|
func (c *Client) SetEventHandler(handler func(SessionEvent)) {
|
|
c.handlerMu.Lock()
|
|
defer c.handlerMu.Unlock()
|
|
c.eventHandler = handler
|
|
}
|
|
|
|
// SetPermissionHandler sets the handler for permission requests.
|
|
func (c *Client) SetPermissionHandler(handler PermissionHandler) {
|
|
c.permissionMu.Lock()
|
|
defer c.permissionMu.Unlock()
|
|
c.permissionHandler = handler
|
|
}
|
|
|
|
// Start initializes the Copilot SDK client.
|
|
// When CLIUrl is configured, the SDK connects to an external CLI server via TCP.
|
|
// Otherwise, the SDK spawns and manages the CLI process internally via stdio.
|
|
// The actual connection is deferred to the first CreateSession call via AutoStart.
|
|
func (c *Client) Start(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.started {
|
|
return fmt.Errorf("client already started")
|
|
}
|
|
|
|
c.logger.Info("starting Copilot SDK client",
|
|
zap.String("model", c.model),
|
|
zap.String("cli_url", c.cliURL))
|
|
|
|
if c.cliURL != "" {
|
|
// Connect to externally managed CLI server via TCP
|
|
c.sdkClient = copilot.NewClient(&copilot.ClientOptions{
|
|
CLIUrl: c.cliURL,
|
|
LogLevel: "error",
|
|
})
|
|
} else {
|
|
// SDK spawns and manages the CLI process internally (stdio)
|
|
c.sdkClient = copilot.NewClient(nil)
|
|
}
|
|
|
|
// SDK AutoStart (default: true) defers the actual connection
|
|
// to the first CreateSession call, so no explicit Start() needed.
|
|
|
|
c.started = true
|
|
c.logger.Info("Copilot SDK client initialized")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop shuts down the Copilot SDK client.
|
|
func (c *Client) Stop() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if !c.started {
|
|
return nil
|
|
}
|
|
|
|
c.logger.Info("stopping Copilot SDK client")
|
|
|
|
// Unsubscribe from events
|
|
c.handlerMu.Lock()
|
|
if c.unsubscribe != nil {
|
|
c.unsubscribe()
|
|
c.unsubscribe = nil
|
|
}
|
|
c.handlerMu.Unlock()
|
|
|
|
// Destroy session if active
|
|
if c.session != nil {
|
|
if err := c.session.Destroy(); err != nil {
|
|
c.logger.Warn("error destroying session", zap.Error(err))
|
|
}
|
|
c.session = nil
|
|
}
|
|
|
|
// Stop the SDK client
|
|
if c.sdkClient != nil {
|
|
errs := c.sdkClient.Stop()
|
|
for _, err := range errs {
|
|
c.logger.Warn("error stopping SDK client", zap.Error(err))
|
|
}
|
|
c.sdkClient = nil
|
|
}
|
|
|
|
c.started = false
|
|
return nil
|
|
}
|
|
|
|
// CreateSession creates a new Copilot session.
|
|
// mcpServers configures MCP servers for the session (nil if none).
|
|
func (c *Client) CreateSession(ctx context.Context, mcpServers map[string]MCPServerConfig) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if !c.started {
|
|
return "", fmt.Errorf("client not started")
|
|
}
|
|
|
|
// Unsubscribe from previous session events
|
|
c.handlerMu.Lock()
|
|
if c.unsubscribe != nil {
|
|
c.unsubscribe()
|
|
c.unsubscribe = nil
|
|
}
|
|
c.handlerMu.Unlock()
|
|
|
|
// Destroy existing session if any
|
|
if c.session != nil {
|
|
if err := c.session.Destroy(); err != nil {
|
|
c.logger.Warn("error destroying previous session", zap.Error(err))
|
|
}
|
|
c.session = nil
|
|
}
|
|
|
|
c.logger.Info("creating new session",
|
|
zap.String("model", c.model),
|
|
zap.Int("mcp_servers", len(mcpServers)))
|
|
|
|
// Get permission handler
|
|
c.permissionMu.RLock()
|
|
permHandler := c.permissionHandler
|
|
c.permissionMu.RUnlock()
|
|
|
|
// Create session with configuration
|
|
session, err := c.sdkClient.CreateSession(&copilot.SessionConfig{
|
|
Model: c.model,
|
|
Streaming: true,
|
|
OnPermissionRequest: permHandler,
|
|
MCPServers: mcpServers,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create session: %w", err)
|
|
}
|
|
|
|
// Register event handler BEFORE storing the session so no events are lost
|
|
// between session creation and handler registration.
|
|
c.handlerMu.Lock()
|
|
if c.eventHandler != nil {
|
|
c.unsubscribe = session.On(c.eventHandler)
|
|
}
|
|
c.handlerMu.Unlock()
|
|
|
|
c.session = session
|
|
c.sessionID = session.SessionID
|
|
|
|
c.logger.Info("session created", zap.String("session_id", c.sessionID))
|
|
|
|
return c.sessionID, nil
|
|
}
|
|
|
|
// ResumeSession resumes an existing session with streaming enabled.
|
|
// mcpServers configures MCP servers for the resumed session (nil if none).
|
|
func (c *Client) ResumeSession(ctx context.Context, sessionID string, mcpServers map[string]MCPServerConfig) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if !c.started {
|
|
return fmt.Errorf("client not started")
|
|
}
|
|
|
|
// Unsubscribe from previous session events
|
|
c.handlerMu.Lock()
|
|
if c.unsubscribe != nil {
|
|
c.unsubscribe()
|
|
c.unsubscribe = nil
|
|
}
|
|
c.handlerMu.Unlock()
|
|
|
|
// Destroy existing session if any
|
|
if c.session != nil {
|
|
if err := c.session.Destroy(); err != nil {
|
|
c.logger.Warn("error destroying previous session", zap.Error(err))
|
|
}
|
|
c.session = nil
|
|
}
|
|
|
|
c.logger.Info("resuming session",
|
|
zap.String("session_id", sessionID),
|
|
zap.Int("mcp_servers", len(mcpServers)))
|
|
|
|
// Get permission handler
|
|
c.permissionMu.RLock()
|
|
permHandler := c.permissionHandler
|
|
c.permissionMu.RUnlock()
|
|
|
|
// Use ResumeSessionWithOptions to enable streaming on resumed sessions
|
|
session, err := c.sdkClient.ResumeSessionWithOptions(sessionID, &copilot.ResumeSessionConfig{
|
|
Streaming: true,
|
|
OnPermissionRequest: permHandler,
|
|
MCPServers: mcpServers,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resume session: %w", err)
|
|
}
|
|
|
|
// Register event handler BEFORE storing the session so no events are lost.
|
|
c.handlerMu.Lock()
|
|
if c.eventHandler != nil {
|
|
c.unsubscribe = session.On(c.eventHandler)
|
|
}
|
|
c.handlerMu.Unlock()
|
|
|
|
c.session = session
|
|
c.sessionID = sessionID
|
|
|
|
c.logger.Info("session resumed", zap.String("session_id", sessionID))
|
|
|
|
return nil
|
|
}
|
|
|
|
// Send sends a message to the current session (non-blocking).
|
|
func (c *Client) Send(ctx context.Context, message string) (string, error) {
|
|
c.mu.RLock()
|
|
session := c.session
|
|
c.mu.RUnlock()
|
|
|
|
if session == nil {
|
|
return "", fmt.Errorf("no active session")
|
|
}
|
|
|
|
c.logger.Info("sending message to session")
|
|
|
|
messageID, err := session.Send(copilot.MessageOptions{
|
|
Prompt: message,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to send message: %w", err)
|
|
}
|
|
|
|
return messageID, nil
|
|
}
|
|
|
|
// SendAndWait sends a message and waits for completion.
|
|
func (c *Client) SendAndWait(ctx context.Context, message string, timeout time.Duration) (*SessionEvent, error) {
|
|
c.mu.RLock()
|
|
session := c.session
|
|
c.mu.RUnlock()
|
|
|
|
if session == nil {
|
|
return nil, fmt.Errorf("no active session")
|
|
}
|
|
|
|
if timeout == 0 {
|
|
timeout = 5 * time.Minute
|
|
}
|
|
|
|
c.logger.Info("sending message and waiting for completion")
|
|
|
|
result, err := session.SendAndWait(copilot.MessageOptions{
|
|
Prompt: message,
|
|
}, timeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send message: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Abort cancels the current operation.
|
|
func (c *Client) Abort(ctx context.Context) error {
|
|
c.mu.RLock()
|
|
session := c.session
|
|
c.mu.RUnlock()
|
|
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
|
|
c.logger.Info("aborting current operation")
|
|
return session.Abort()
|
|
}
|
|
|
|
// GetSessionID returns the current session ID.
|
|
func (c *Client) GetSessionID() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.sessionID
|
|
}
|
|
|
|
// IsStarted returns whether the client has been started.
|
|
func (c *Client) IsStarted() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.started
|
|
}
|