Files
kandev/apps/backend/pkg/copilot/client.go
T

351 lines
8.7 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
)
// 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
// 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
}
// 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.
func (c *Client) CreateSession(ctx context.Context) (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))
// Create session with configuration
session, err := c.sdkClient.CreateSession(&copilot.SessionConfig{
Model: c.model,
Streaming: true,
})
if err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
}
c.session = session
c.sessionID = session.SessionID
// Register event handler
c.handlerMu.Lock()
if c.eventHandler != nil {
c.unsubscribe = session.On(c.eventHandler)
}
c.handlerMu.Unlock()
c.logger.Info("session created", zap.String("session_id", c.sessionID))
return c.sessionID, nil
}
// ResumeSession resumes an existing session with streaming enabled.
func (c *Client) ResumeSession(ctx context.Context, sessionID 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("resuming session", zap.String("session_id", sessionID))
// Use ResumeSessionWithOptions to enable streaming on resumed sessions
session, err := c.sdkClient.ResumeSessionWithOptions(sessionID, &copilot.ResumeSessionConfig{
Streaming: true,
})
if err != nil {
return fmt.Errorf("failed to resume session: %w", err)
}
c.session = session
c.sessionID = sessionID
// Register event handler
c.handlerMu.Lock()
if c.eventHandler != nil {
c.unsubscribe = session.On(c.eventHandler)
}
c.handlerMu.Unlock()
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
}