From 5bc05e97c0c144e98245d9cd35dc5e1f4a1d3e79 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 24 Feb 2026 16:03:25 -0800 Subject: [PATCH 1/9] Add Copilot SDK foundation alongside existing langchaingo agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the GitHub Copilot SDK (github.com/github/copilot-sdk/go) as a new dependency and create the foundational types for a Copilot SDK-based agent implementation. All new code coexists alongside the existing langchaingo agent — no existing code is modified or deleted. New files: - pkg/llm/copilot_client.go: CopilotClientManager wrapping copilot.Client lifecycle (Start, Stop, GetAuthStatus, ListModels) - pkg/llm/session_config.go: SessionConfigBuilder that reads ai.agent.* config keys and produces copilot.SessionConfig, including MCP server merging (built-in + user-configured) and tool control - internal/agent/copilot_agent.go: CopilotAgent implementing the Agent interface backed by copilot.Session with SendAndWait - internal/agent/copilot_agent_factory.go: CopilotAgentFactory that creates CopilotAgent instances with SDK client, session, permission hooks, MCP servers, and event handlers - internal/agent/logging/session_event_handler.go: SessionEventLogger, SessionFileLogger, and CompositeEventHandler for SDK SessionEvent streaming to UX thought channel and daily log files Config additions (resources/config_options.yaml): - ai.agent.model: Default model for Copilot SDK sessions - ai.agent.mode: Agent mode (autopilot/interactive/plan) - ai.agent.mcp.servers: Additional MCP servers - ai.agent.tools.available/excluded: Tool allow/deny lists - ai.agent.systemMessage: Custom system prompt append - ai.agent.copilot.logLevel: SDK log level Resolves #6871, #6872, #6873, #6874, #6875 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/go.mod | 2 + cli/azd/go.sum | 5 + cli/azd/internal/agent/copilot_agent.go | 265 ++++++++++++++++++ .../internal/agent/copilot_agent_factory.go | 156 +++++++++++ .../agent/logging/session_event_handler.go | 191 +++++++++++++ .../logging/session_event_handler_test.go | 121 ++++++++ cli/azd/pkg/llm/copilot_client.go | 91 ++++++ cli/azd/pkg/llm/copilot_client_test.go | 26 ++ cli/azd/pkg/llm/session_config.go | 203 ++++++++++++++ cli/azd/pkg/llm/session_config_test.go | 169 +++++++++++ cli/azd/resources/config_options.yaml | 30 ++ 11 files changed, 1259 insertions(+) create mode 100644 cli/azd/internal/agent/copilot_agent.go create mode 100644 cli/azd/internal/agent/copilot_agent_factory.go create mode 100644 cli/azd/internal/agent/logging/session_event_handler.go create mode 100644 cli/azd/internal/agent/logging/session_event_handler_test.go create mode 100644 cli/azd/pkg/llm/copilot_client.go create mode 100644 cli/azd/pkg/llm/copilot_client_test.go create mode 100644 cli/azd/pkg/llm/session_config.go create mode 100644 cli/azd/pkg/llm/session_config_test.go diff --git a/cli/azd/go.mod b/cli/azd/go.mod index a8942c5f394..21c0158ecdb 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -109,8 +109,10 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/github/copilot-sdk/go v0.1.25 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/goph/emperror v0.17.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect diff --git a/cli/azd/go.sum b/cli/azd/go.sum index 33c8d028d20..b23142fda7b 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -194,6 +194,8 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/getzep/zep-go v1.0.4 h1:09o26bPP2RAPKFjWuVWwUWLbtFDF/S8bfbilxzeZAAg= github.com/getzep/zep-go v1.0.4/go.mod h1:HC1Gz7oiyrzOTvzeKC4dQKUiUy87zpIJl0ZFXXdHuss= +github.com/github/copilot-sdk/go v0.1.25 h1:SJ/jSoesbpjDEBcvMkoCG+xITvgvnhxnd6oJdmNQnOs= +github.com/github/copilot-sdk/go v0.1.25/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -223,6 +225,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -264,6 +268,7 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go new file mode 100644 index 00000000000..f00ce077b1f --- /dev/null +++ b/cli/azd/internal/agent/copilot_agent.go @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent + +import ( + "context" + "fmt" + "strings" + "time" + + copilot "github.com/github/copilot-sdk/go" + + "github.com/azure/azure-dev/cli/azd/internal/agent/logging" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/azure/azure-dev/cli/azd/pkg/watch" + "github.com/fatih/color" +) + +// CopilotAgent implements the Agent interface using the GitHub Copilot SDK. +// It manages a copilot.Session for multi-turn conversations and streams +// session events for UX rendering. +type CopilotAgent struct { + session *copilot.Session + console input.Console + thoughtChan chan logging.Thought + cleanupFunc AgentCleanup + debug bool + + watchForFileChanges bool +} + +// CopilotAgentOption is a functional option for configuring a CopilotAgent. +type CopilotAgentOption func(*CopilotAgent) + +// WithCopilotDebug enables debug logging for the Copilot agent. +func WithCopilotDebug(debug bool) CopilotAgentOption { + return func(a *CopilotAgent) { a.debug = debug } +} + +// WithCopilotFileWatching enables file change detection after tool execution. +func WithCopilotFileWatching(enabled bool) CopilotAgentOption { + return func(a *CopilotAgent) { a.watchForFileChanges = enabled } +} + +// WithCopilotCleanup sets the cleanup function called on Stop(). +func WithCopilotCleanup(fn AgentCleanup) CopilotAgentOption { + return func(a *CopilotAgent) { a.cleanupFunc = fn } +} + +// WithCopilotThoughtChannel sets the channel for streaming thoughts to the UX layer. +func WithCopilotThoughtChannel(ch chan logging.Thought) CopilotAgentOption { + return func(a *CopilotAgent) { a.thoughtChan = ch } +} + +// NewCopilotAgent creates a new CopilotAgent backed by the given copilot.Session. +func NewCopilotAgent( + session *copilot.Session, + console input.Console, + opts ...CopilotAgentOption, +) *CopilotAgent { + agent := &CopilotAgent{ + session: session, + console: console, + watchForFileChanges: true, + } + + for _, opt := range opts { + opt(agent) + } + + return agent +} + +// SendMessage sends a message to the Copilot agent session and waits for a response. +func (a *CopilotAgent) SendMessage(ctx context.Context, args ...string) (string, error) { + thoughtsCtx, cancelCtx := context.WithCancel(ctx) + + var watcher watch.Watcher + if a.watchForFileChanges { + var err error + watcher, err = watch.NewWatcher(ctx) + if err != nil { + cancelCtx() + return "", fmt.Errorf("failed to start watcher: %w", err) + } + } + + cleanup, err := a.renderThoughts(thoughtsCtx) + if err != nil { + cancelCtx() + return "", err + } + + defer func() { + cancelCtx() + time.Sleep(100 * time.Millisecond) + cleanup() + if a.watchForFileChanges { + watcher.PrintChangedFiles(ctx) + } + }() + + prompt := strings.Join(args, "\n") + result, err := a.session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: prompt, + }) + if err != nil { + return "", fmt.Errorf("copilot agent error: %w", err) + } + + // Extract the final assistant message content + if result != nil && result.Data.Content != nil { + return *result.Data.Content, nil + } + + return "", nil +} + +// SendMessageWithRetry sends a message and prompts the user to retry on error. +func (a *CopilotAgent) SendMessageWithRetry(ctx context.Context, args ...string) (string, error) { + for { + agentOutput, err := a.SendMessage(ctx, args...) + if err != nil { + if agentOutput != "" { + a.console.Message(ctx, output.WithMarkdown(agentOutput)) + } + + if shouldRetry := a.handleErrorWithRetryPrompt(ctx, err); shouldRetry { + continue + } + return "", err + } + + return agentOutput, nil + } +} + +// Stop terminates the agent and performs cleanup. +func (a *CopilotAgent) Stop() error { + if a.cleanupFunc != nil { + return a.cleanupFunc() + } + return nil +} + +func (a *CopilotAgent) handleErrorWithRetryPrompt(ctx context.Context, err error) bool { + a.console.Message(ctx, "") + a.console.Message(ctx, output.WithErrorFormat("Error occurred: %s", err.Error())) + a.console.Message(ctx, "") + + retryPrompt := uxlib.NewConfirm(&uxlib.ConfirmOptions{ + Message: "Oops, my reply didn't quite fit what was needed. Want me to try again?", + DefaultValue: uxlib.Ptr(true), + HelpMessage: "Choose 'yes' to retry the current step, or 'no' to stop the initialization.", + }) + + shouldRetry, promptErr := retryPrompt.Ask(ctx) + if promptErr != nil { + return false + } + + return shouldRetry != nil && *shouldRetry +} + +// renderThoughts reuses the same UX rendering pattern as ConversationalAzdAiAgent, +// reading from the thought channel and displaying spinner + tool completion messages. +func (a *CopilotAgent) renderThoughts(ctx context.Context) (func(), error) { + if a.thoughtChan == nil { + return func() {}, nil + } + + var latestThought string + + spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ + Text: "Processing...", + }) + + canvas := uxlib.NewCanvas( + spinner, + uxlib.NewVisualElement(func(printer uxlib.Printer) error { + printer.Fprintln() + printer.Fprintln() + + if latestThought != "" { + printer.Fprintln(color.HiBlackString(latestThought)) + printer.Fprintln() + printer.Fprintln() + } + + return nil + })) + + printToolCompletion := func(action, actionInput, thought string) { + if action == "" { + return + } + + completionMsg := fmt.Sprintf("%s Ran %s", color.GreenString("✔︎"), color.MagentaString(action)) + if actionInput != "" { + completionMsg += " with " + color.HiBlackString(actionInput) + } + if thought != "" { + completionMsg += color.MagentaString("\n\n◆ agent: ") + thought + } + + canvas.Clear() + fmt.Println(completionMsg) + fmt.Println() + } + + go func() { + defer canvas.Clear() + + var latestAction string + var latestActionInput string + var spinnerText string + var toolStartTime time.Time + + for { + select { + case thought := <-a.thoughtChan: + if thought.Action != "" { + if thought.Action != latestAction || thought.ActionInput != latestActionInput { + printToolCompletion(latestAction, latestActionInput, latestThought) + } + latestAction = thought.Action + latestActionInput = thought.ActionInput + toolStartTime = time.Now() + } + if thought.Thought != "" { + latestThought = thought.Thought + } + case <-ctx.Done(): + printToolCompletion(latestAction, latestActionInput, latestThought) + return + case <-time.After(200 * time.Millisecond): + } + + if latestAction == "" { + spinnerText = "Processing..." + } else { + elapsedSeconds := int(time.Since(toolStartTime).Seconds()) + spinnerText = fmt.Sprintf("Running %s tool", color.MagentaString(latestAction)) + if latestActionInput != "" { + spinnerText += " with " + color.HiBlackString(latestActionInput) + } + spinnerText += "..." + spinnerText += color.HiBlackString(fmt.Sprintf("\n(%ds, CTRL C to exit agentic mode)", elapsedSeconds)) + } + + spinner.UpdateText(spinnerText) + canvas.Update() + } + }() + + cleanup := func() { + canvas.Clear() + canvas.Close() + } + + return cleanup, canvas.Run() +} diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go new file mode 100644 index 00000000000..c871271bd36 --- /dev/null +++ b/cli/azd/internal/agent/copilot_agent_factory.go @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "log" + + copilot "github.com/github/copilot-sdk/go" + + "github.com/azure/azure-dev/cli/azd/internal/agent/logging" + mcptools "github.com/azure/azure-dev/cli/azd/internal/agent/tools/mcp" + "github.com/azure/azure-dev/cli/azd/internal/mcp" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/llm" +) + +// CopilotAgentFactory creates CopilotAgent instances using the GitHub Copilot SDK. +// It manages the Copilot client lifecycle, MCP server configuration, and session hooks. +type CopilotAgentFactory struct { + clientManager *llm.CopilotClientManager + sessionConfigBuilder *llm.SessionConfigBuilder + console input.Console +} + +// NewCopilotAgentFactory creates a new factory for building Copilot SDK-based agents. +func NewCopilotAgentFactory( + clientManager *llm.CopilotClientManager, + sessionConfigBuilder *llm.SessionConfigBuilder, + console input.Console, +) *CopilotAgentFactory { + return &CopilotAgentFactory{ + clientManager: clientManager, + sessionConfigBuilder: sessionConfigBuilder, + console: console, + } +} + +// Create builds a new CopilotAgent with the Copilot SDK session, MCP servers, +// permission hooks, and event handlers configured. +func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...CopilotAgentOption) (Agent, error) { + cleanupTasks := map[string]func() error{} + + cleanup := func() error { + for name, task := range cleanupTasks { + if err := task(); err != nil { + log.Printf("failed to cleanup %s: %v", name, err) + } + } + return nil + } + + // Start the Copilot client (spawns copilot-agent-runtime) + if err := f.clientManager.Start(ctx); err != nil { + return nil, err + } + cleanupTasks["copilot-client"] = f.clientManager.Stop + + // Create thought channel for UX streaming + thoughtChan := make(chan logging.Thought) + cleanupTasks["thoughtChan"] = func() error { + close(thoughtChan) + return nil + } + + // Create file logger for session events + fileLogger, fileLoggerCleanup, err := logging.NewSessionFileLogger() + if err != nil { + defer cleanup() + return nil, fmt.Errorf("failed to create session file logger: %w", err) + } + cleanupTasks["fileLogger"] = fileLoggerCleanup + + // Create event logger for UX thought streaming + eventLogger := logging.NewSessionEventLogger(thoughtChan) + + // Create composite handler + compositeHandler := logging.NewCompositeEventHandler( + eventLogger.HandleEvent, + fileLogger.HandleEvent, + ) + + // Load built-in MCP server configs + builtInServers, err := loadBuiltInMCPServers() + if err != nil { + defer cleanup() + return nil, err + } + + // Build session config from azd user config + sessionConfig, err := f.sessionConfigBuilder.Build(ctx, builtInServers) + if err != nil { + defer cleanup() + return nil, fmt.Errorf("failed to build session config: %w", err) + } + + // Wire permission hooks + sessionConfig.Hooks = &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) ( + *copilot.PreToolUseHookOutput, error, + ) { + // Allow all tools by default — SDK handles its own permission model. + // In Phase 2, azd-specific security policies (path validation) will be wired here. + return &copilot.PreToolUseHookOutput{}, nil + }, + OnPostToolUse: func(input copilot.PostToolUseHookInput, inv copilot.HookInvocation) ( + *copilot.PostToolUseHookOutput, error, + ) { + return nil, nil + }, + } + + // Create session + session, err := f.clientManager.Client().CreateSession(ctx, sessionConfig) + if err != nil { + defer cleanup() + return nil, fmt.Errorf("failed to create Copilot session: %w", err) + } + + // Subscribe to session events + unsubscribe := session.On(func(event copilot.SessionEvent) { + compositeHandler.HandleEvent(event) + }) + + cleanupTasks["session-events"] = func() error { + unsubscribe() + return nil + } + + cleanupTasks["session"] = func() error { + return session.Destroy() + } + + // Build agent options + allOpts := []CopilotAgentOption{ + WithCopilotThoughtChannel(thoughtChan), + WithCopilotCleanup(cleanup), + } + allOpts = append(allOpts, opts...) + + agent := NewCopilotAgent(session, f.console, allOpts...) + + return agent, nil +} + +// loadBuiltInMCPServers loads the embedded mcp.json configuration. +func loadBuiltInMCPServers() (map[string]*mcp.ServerConfig, error) { + var mcpConfig *mcp.McpConfig + if err := json.Unmarshal([]byte(mcptools.McpJson), &mcpConfig); err != nil { + return nil, fmt.Errorf("failed parsing embedded mcp.json: %w", err) + } + return mcpConfig.Servers, nil +} diff --git a/cli/azd/internal/agent/logging/session_event_handler.go b/cli/azd/internal/agent/logging/session_event_handler.go new file mode 100644 index 00000000000..82c5ca7b9ce --- /dev/null +++ b/cli/azd/internal/agent/logging/session_event_handler.go @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package logging + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + copilot "github.com/github/copilot-sdk/go" +) + +// SessionEventLogger handles Copilot SDK session events for UX display and file logging. +type SessionEventLogger struct { + thoughtChan chan<- Thought +} + +// NewSessionEventLogger creates a new event logger that emits Thought structs +// to the provided channel based on Copilot SDK session events. +func NewSessionEventLogger(thoughtChan chan<- Thought) *SessionEventLogger { + return &SessionEventLogger{ + thoughtChan: thoughtChan, + } +} + +// HandleEvent processes a Copilot SDK SessionEvent and emits corresponding Thought structs. +func (l *SessionEventLogger) HandleEvent(event copilot.SessionEvent) { + if l.thoughtChan == nil { + return + } + + switch event.Type { + case copilot.AssistantMessage: + if event.Data.Content != nil && *event.Data.Content != "" { + content := strings.TrimSpace(*event.Data.Content) + if content != "" && !strings.Contains(strings.ToLower(content), "do i need to use a tool?") { + l.thoughtChan <- Thought{ + Thought: content, + } + } + } + + case copilot.ToolExecutionStart: + toolName := "" + if event.Data.ToolName != nil { + toolName = *event.Data.ToolName + } else if event.Data.MCPToolName != nil { + toolName = *event.Data.MCPToolName + } + if toolName == "" { + return + } + + actionInput := extractToolInputSummary(event.Data.Arguments) + l.thoughtChan <- Thought{ + Action: toolName, + ActionInput: actionInput, + } + + case copilot.AssistantReasoning: + if event.Data.ReasoningText != nil && *event.Data.ReasoningText != "" { + l.thoughtChan <- Thought{ + Thought: strings.TrimSpace(*event.Data.ReasoningText), + } + } + } +} + +// extractToolInputSummary creates a short summary of tool arguments for display. +func extractToolInputSummary(args any) string { + if args == nil { + return "" + } + + argsMap, ok := args.(map[string]any) + if !ok { + return "" + } + + // Prioritize specific param keys for display + prioritizedKeys := []string{"path", "pattern", "filename", "command"} + for _, key := range prioritizedKeys { + if val, exists := argsMap[key]; exists { + s := fmt.Sprintf("%s: %v", key, val) + return truncateString(s, 120) + } + } + + return "" +} + +// SessionFileLogger logs all Copilot SDK session events to a daily log file. +type SessionFileLogger struct { + file *os.File +} + +// NewSessionFileLogger creates a file logger that writes session events to a daily log file. +// Returns the logger and a cleanup function to close the file. +func NewSessionFileLogger() (*SessionFileLogger, func() error, error) { + logDir, err := getLogDir() + if err != nil { + return nil, func() error { return nil }, err + } + + if err := os.MkdirAll(logDir, 0o700); err != nil { + return nil, func() error { return nil }, fmt.Errorf("failed to create log directory: %w", err) + } + + logFile := filepath.Join(logDir, fmt.Sprintf("azd-agent-%s.log", time.Now().Format("2006-01-02"))) + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, func() error { return nil }, fmt.Errorf("failed to open log file: %w", err) + } + + logger := &SessionFileLogger{file: f} + cleanup := func() error { return f.Close() } + + return logger, cleanup, nil +} + +// HandleEvent writes a session event to the log file. +func (l *SessionFileLogger) HandleEvent(event copilot.SessionEvent) { + if l.file == nil { + return + } + + timestamp := time.Now().Format(time.RFC3339) + eventType := string(event.Type) + + var detail string + switch event.Type { + case copilot.ToolExecutionStart: + toolName := "" + if event.Data.ToolName != nil { + toolName = *event.Data.ToolName + } + detail = fmt.Sprintf("tool=%s", toolName) + case copilot.ToolExecutionComplete: + toolName := "" + if event.Data.ToolName != nil { + toolName = *event.Data.ToolName + } + detail = fmt.Sprintf("tool=%s", toolName) + case copilot.AssistantMessage: + content := "" + if event.Data.Content != nil { + content = truncateString(*event.Data.Content, 200) + } + detail = fmt.Sprintf("content=%s", content) + case copilot.SessionError: + msg := "" + if event.Data.Message != nil { + msg = *event.Data.Message + } + detail = fmt.Sprintf("error=%s", msg) + default: + detail = eventType + } + + line := fmt.Sprintf("[%s] %s: %s\n", timestamp, eventType, detail) + //nolint:errcheck + l.file.WriteString(line) +} + +// CompositeEventHandler chains multiple session event handlers together. +type CompositeEventHandler struct { + handlers []func(copilot.SessionEvent) +} + +// NewCompositeEventHandler creates a handler that forwards events to all provided handlers. +func NewCompositeEventHandler(handlers ...func(copilot.SessionEvent)) *CompositeEventHandler { + return &CompositeEventHandler{handlers: handlers} +} + +// HandleEvent forwards the event to all registered handlers. +func (c *CompositeEventHandler) HandleEvent(event copilot.SessionEvent) { + for _, h := range c.handlers { + h(event) + } +} + +func getLogDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".azd", "logs"), nil +} diff --git a/cli/azd/internal/agent/logging/session_event_handler_test.go b/cli/azd/internal/agent/logging/session_event_handler_test.go new file mode 100644 index 00000000000..fda54696fdd --- /dev/null +++ b/cli/azd/internal/agent/logging/session_event_handler_test.go @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package logging + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/stretchr/testify/require" +) + +func TestSessionEventLogger_HandleEvent(t *testing.T) { + t.Run("AssistantMessage", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + content := "I will analyze the project structure." + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantMessage, + Data: copilot.Data{Content: &content}, + }) + + require.Len(t, ch, 1) + thought := <-ch + require.Equal(t, "I will analyze the project structure.", thought.Thought) + require.Empty(t, thought.Action) + }) + + t.Run("ToolStart", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + toolName := "read_file" + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.ToolExecutionStart, + Data: copilot.Data{ + ToolName: &toolName, + Arguments: map[string]any{"path": "/src/main.go"}, + }, + }) + + require.Len(t, ch, 1) + thought := <-ch + require.Equal(t, "read_file", thought.Action) + require.Equal(t, "path: /src/main.go", thought.ActionInput) + }) + + t.Run("ToolStartWithMCPToolName", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + mcpToolName := "azd_plan_init" + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.ToolExecutionStart, + Data: copilot.Data{MCPToolName: &mcpToolName}, + }) + + require.Len(t, ch, 1) + thought := <-ch + require.Equal(t, "azd_plan_init", thought.Action) + }) + + t.Run("SkipsToolPromptThoughts", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + content := "Do I need to use a tool? Yes." + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantMessage, + Data: copilot.Data{Content: &content}, + }) + + require.Empty(t, ch) + }) + + t.Run("NilChannel", func(t *testing.T) { + logger := NewSessionEventLogger(nil) + content := "test" + // Should not panic + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantMessage, + Data: copilot.Data{Content: &content}, + }) + }) +} + +func TestExtractToolInputSummary(t *testing.T) { + t.Run("PathParam", func(t *testing.T) { + result := extractToolInputSummary(map[string]any{"path": "/src/main.go", "content": "data"}) + require.Equal(t, "path: /src/main.go", result) + }) + + t.Run("CommandParam", func(t *testing.T) { + result := extractToolInputSummary(map[string]any{"command": "go build ./..."}) + require.Equal(t, "command: go build ./...", result) + }) + + t.Run("NilArgs", func(t *testing.T) { + result := extractToolInputSummary(nil) + require.Empty(t, result) + }) + + t.Run("NonMapArgs", func(t *testing.T) { + result := extractToolInputSummary("not a map") + require.Empty(t, result) + }) +} + +func TestCompositeEventHandler(t *testing.T) { + var calls []string + + handler := NewCompositeEventHandler( + func(e copilot.SessionEvent) { calls = append(calls, "handler1") }, + func(e copilot.SessionEvent) { calls = append(calls, "handler2") }, + ) + + handler.HandleEvent(copilot.SessionEvent{Type: copilot.SessionStart}) + + require.Equal(t, []string{"handler1", "handler2"}, calls) +} diff --git a/cli/azd/pkg/llm/copilot_client.go b/cli/azd/pkg/llm/copilot_client.go new file mode 100644 index 00000000000..118a5d8c900 --- /dev/null +++ b/cli/azd/pkg/llm/copilot_client.go @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "fmt" + + copilot "github.com/github/copilot-sdk/go" +) + +// CopilotClientManager manages the lifecycle of a Copilot SDK client. +// It wraps copilot.Client with azd-specific configuration and error handling. +type CopilotClientManager struct { + client *copilot.Client + options *CopilotClientOptions +} + +// CopilotClientOptions configures the CopilotClientManager. +type CopilotClientOptions struct { + // LogLevel controls SDK logging verbosity (e.g., "info", "debug", "error"). + LogLevel string +} + +// NewCopilotClientManager creates a new CopilotClientManager with the given options. +// If options is nil, defaults are used. +func NewCopilotClientManager(options *CopilotClientOptions) *CopilotClientManager { + if options == nil { + options = &CopilotClientOptions{} + } + + clientOpts := &copilot.ClientOptions{} + if options.LogLevel != "" { + clientOpts.LogLevel = options.LogLevel + } + + return &CopilotClientManager{ + client: copilot.NewClient(clientOpts), + options: options, + } +} + +// Start initializes the Copilot SDK client and establishes a connection +// to the copilot-agent-runtime process. +func (m *CopilotClientManager) Start(ctx context.Context) error { + if err := m.client.Start(ctx); err != nil { + return fmt.Errorf( + "failed to start Copilot agent runtime: %w. "+ + "Ensure you have a GitHub Copilot subscription and the Copilot CLI is available", + err, + ) + } + return nil +} + +// Stop gracefully shuts down the Copilot SDK client and terminates the agent runtime process. +func (m *CopilotClientManager) Stop() error { + if m.client == nil { + return nil + } + return m.client.Stop() +} + +// Client returns the underlying copilot.Client for session creation. +func (m *CopilotClientManager) Client() *copilot.Client { + return m.client +} + +// GetAuthStatus checks whether the user is authenticated with GitHub Copilot. +func (m *CopilotClientManager) GetAuthStatus(ctx context.Context) (*copilot.GetAuthStatusResponse, error) { + status, err := m.client.GetAuthStatus(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check Copilot auth status: %w", err) + } + return status, nil +} + +// ListModels returns the list of available models from the Copilot service. +func (m *CopilotClientManager) ListModels(ctx context.Context) ([]copilot.ModelInfo, error) { + models, err := m.client.ListModels(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list Copilot models: %w", err) + } + return models, nil +} + +// State returns the current connection state of the client. +func (m *CopilotClientManager) State() copilot.ConnectionState { + return m.client.State() +} diff --git a/cli/azd/pkg/llm/copilot_client_test.go b/cli/azd/pkg/llm/copilot_client_test.go new file mode 100644 index 00000000000..000e929c879 --- /dev/null +++ b/cli/azd/pkg/llm/copilot_client_test.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewCopilotClientManager(t *testing.T) { + t.Run("NilOptions", func(t *testing.T) { + mgr := NewCopilotClientManager(nil) + require.NotNil(t, mgr) + require.NotNil(t, mgr.Client()) + }) + + t.Run("WithLogLevel", func(t *testing.T) { + mgr := NewCopilotClientManager(&CopilotClientOptions{ + LogLevel: "debug", + }) + require.NotNil(t, mgr) + require.NotNil(t, mgr.Client()) + }) +} diff --git a/cli/azd/pkg/llm/session_config.go b/cli/azd/pkg/llm/session_config.go new file mode 100644 index 00000000000..79036540ca5 --- /dev/null +++ b/cli/azd/pkg/llm/session_config.go @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "encoding/json" + + copilot "github.com/github/copilot-sdk/go" + + "github.com/azure/azure-dev/cli/azd/internal/mcp" + "github.com/azure/azure-dev/cli/azd/pkg/config" +) + +// SessionConfigBuilder builds a copilot.SessionConfig from azd user configuration. +// It reads ai.agent.* config keys and merges MCP server configurations from +// built-in, extension, and user sources. +type SessionConfigBuilder struct { + userConfigManager config.UserConfigManager +} + +// NewSessionConfigBuilder creates a new SessionConfigBuilder. +func NewSessionConfigBuilder(userConfigManager config.UserConfigManager) *SessionConfigBuilder { + return &SessionConfigBuilder{ + userConfigManager: userConfigManager, + } +} + +// Build reads azd config and produces a copilot.SessionConfig. +// Built-in MCP servers from builtInServers are merged with user-configured servers. +func (b *SessionConfigBuilder) Build( + ctx context.Context, + builtInServers map[string]*mcp.ServerConfig, +) (*copilot.SessionConfig, error) { + cfg := &copilot.SessionConfig{ + Streaming: true, + } + + userConfig, err := b.userConfigManager.Load() + if err != nil { + // Use defaults if config can't be loaded + return cfg, nil + } + + // Model selection + if model, ok := userConfig.GetString("ai.agent.model"); ok { + cfg.Model = model + } + + // System message — use "append" mode to add to default prompt + if msg, ok := userConfig.GetString("ai.agent.systemMessage"); ok && msg != "" { + cfg.SystemMessage = &copilot.SystemMessageConfig{ + Mode: "append", + Content: msg, + } + } + + // Tool control + if available := getStringSliceFromConfig(userConfig, "ai.agent.tools.available"); len(available) > 0 { + cfg.AvailableTools = available + } + if excluded := getStringSliceFromConfig(userConfig, "ai.agent.tools.excluded"); len(excluded) > 0 { + cfg.ExcludedTools = excluded + } + + // Skill control + if dirs := getStringSliceFromConfig(userConfig, "ai.agent.skills.directories"); len(dirs) > 0 { + cfg.SkillDirectories = dirs + } + if disabled := getStringSliceFromConfig(userConfig, "ai.agent.skills.disabled"); len(disabled) > 0 { + cfg.DisabledSkills = disabled + } + + // MCP servers: merge built-in + user-configured + cfg.MCPServers = b.buildMCPServers(userConfig, builtInServers) + + return cfg, nil +} + +// buildMCPServers merges built-in MCP servers with user-configured ones. +// User-configured servers with matching names override built-in servers. +func (b *SessionConfigBuilder) buildMCPServers( + userConfig config.Config, + builtInServers map[string]*mcp.ServerConfig, +) map[string]copilot.MCPServerConfig { + merged := make(map[string]copilot.MCPServerConfig) + + // Add built-in servers + for name, srv := range builtInServers { + merged[name] = convertServerConfig(srv) + } + + // Merge user-configured servers (overrides built-in on name collision) + userServers := getUserMCPServers(userConfig) + for name, srv := range userServers { + merged[name] = srv + } + + if len(merged) == 0 { + return nil + } + + return merged +} + +// convertServerConfig converts an azd mcp.ServerConfig to a copilot.MCPServerConfig. +func convertServerConfig(srv *mcp.ServerConfig) copilot.MCPServerConfig { + if srv.Type == "http" { + return copilot.MCPServerConfig{ + "type": "http", + "url": srv.Url, + } + } + + result := copilot.MCPServerConfig{ + "type": "stdio", + "command": srv.Command, + } + + if len(srv.Args) > 0 { + result["args"] = srv.Args + } + + envMap := make(map[string]string) + for _, e := range srv.Env { + if idx := indexOf(e, '='); idx > 0 { + envMap[e[:idx]] = e[idx+1:] + } + } + if len(envMap) > 0 { + result["env"] = envMap + } + + return result +} + +// getUserMCPServers reads user-configured MCP servers from the ai.agent.mcp.servers config key. +func getUserMCPServers(userConfig config.Config) map[string]copilot.MCPServerConfig { + raw, ok := userConfig.GetMap("ai.agent.mcp.servers") + if !ok || len(raw) == 0 { + return nil + } + + result := make(map[string]copilot.MCPServerConfig) + for name, v := range raw { + // Marshal/unmarshal each server entry to get typed config + data, err := json.Marshal(v) + if err != nil { + continue + } + + // Try to detect type field first + var probe struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &probe); err != nil { + continue + } + + if probe.Type == "http" { + var remote map[string]any + if err := json.Unmarshal(data, &remote); err != nil { + continue + } + result[name] = copilot.MCPServerConfig(remote) + } else { + var local map[string]any + if err := json.Unmarshal(data, &local); err != nil { + continue + } + result[name] = copilot.MCPServerConfig(local) + } + } + + return result +} + +// getStringSliceFromConfig reads a config key that may be a slice of strings. +func getStringSliceFromConfig(cfg config.Config, path string) []string { + slice, ok := cfg.GetSlice(path) + if !ok { + return nil + } + + result := make([]string, 0, len(slice)) + for _, v := range slice { + if s, ok := v.(string); ok && s != "" { + result = append(result, s) + } + } + + return result +} + +func indexOf(s string, c byte) int { + for i := range len(s) { + if s[i] == c { + return i + } + } + return -1 +} diff --git a/cli/azd/pkg/llm/session_config_test.go b/cli/azd/pkg/llm/session_config_test.go new file mode 100644 index 00000000000..a1d622dc9b7 --- /dev/null +++ b/cli/azd/pkg/llm/session_config_test.go @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal/mcp" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestSessionConfigBuilder_Build(t *testing.T) { + t.Run("EmptyConfig", func(t *testing.T) { + ucm := &mockUserConfigManager{ + config: config.NewConfig(nil), + } + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, cfg) + require.True(t, cfg.Streaming) + require.Empty(t, cfg.Model) + }) + + t.Run("ModelFromConfig", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.model", "gpt-4.1") + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.Equal(t, "gpt-4.1", cfg.Model) + }) + + t.Run("SystemMessage", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.systemMessage", "Use TypeScript") + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, cfg.SystemMessage) + require.Equal(t, "append", cfg.SystemMessage.Mode) + require.Equal(t, "Use TypeScript", cfg.SystemMessage.Content) + }) + + t.Run("ToolControl", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.tools.available", []any{"read_file", "write_file"}) + _ = c.Set("ai.agent.tools.excluded", []any{"execute_command"}) + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.Equal(t, []string{"read_file", "write_file"}, cfg.AvailableTools) + require.Equal(t, []string{"execute_command"}, cfg.ExcludedTools) + }) + + t.Run("MergesMCPServers", func(t *testing.T) { + c := config.NewConfig(nil) + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + builtIn := map[string]*mcp.ServerConfig{ + "azd": { + Type: "stdio", + Command: "azd", + Args: []string{"mcp", "start"}, + }, + } + + cfg, err := builder.Build(context.Background(), builtIn) + require.NoError(t, err) + require.Len(t, cfg.MCPServers, 1) + require.Contains(t, cfg.MCPServers, "azd") + }) + + t.Run("UserMCPServersOverrideBuiltIn", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.mcp.servers", map[string]any{ + "azd": map[string]any{ + "type": "stdio", + "command": "/custom/azd", + "args": []any{"custom-mcp"}, + }, + "custom": map[string]any{ + "type": "http", + "url": "https://mcp.example.com", + }, + }) + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + builtIn := map[string]*mcp.ServerConfig{ + "azd": { + Type: "stdio", + Command: "azd", + Args: []string{"mcp", "start"}, + }, + } + + cfg, err := builder.Build(context.Background(), builtIn) + require.NoError(t, err) + require.Len(t, cfg.MCPServers, 2) + + // User config overrides built-in "azd" + azdServer := cfg.MCPServers["azd"] + require.Equal(t, "/custom/azd", azdServer["command"]) + + // User adds new "custom" server + customServer := cfg.MCPServers["custom"] + require.Equal(t, "http", customServer["type"]) + }) +} + +func TestConvertServerConfig(t *testing.T) { + t.Run("StdioServer", func(t *testing.T) { + srv := &mcp.ServerConfig{ + Type: "stdio", + Command: "npx", + Args: []string{"-y", "@azure/mcp@latest"}, + Env: []string{"KEY=VALUE", "OTHER=test"}, + } + + result := convertServerConfig(srv) + require.Equal(t, "stdio", result["type"]) + require.Equal(t, "npx", result["command"]) + require.Equal(t, []string{"-y", "@azure/mcp@latest"}, result["args"]) + + envMap, ok := result["env"].(map[string]string) + require.True(t, ok) + require.Equal(t, "VALUE", envMap["KEY"]) + require.Equal(t, "test", envMap["OTHER"]) + }) + + t.Run("HttpServer", func(t *testing.T) { + srv := &mcp.ServerConfig{ + Type: "http", + Url: "https://example.com/mcp", + } + + result := convertServerConfig(srv) + require.Equal(t, "http", result["type"]) + require.Equal(t, "https://example.com/mcp", result["url"]) + }) +} + +type mockUserConfigManager struct { + config config.Config +} + +func (m *mockUserConfigManager) Load() (config.Config, error) { + return m.config, nil +} + +func (m *mockUserConfigManager) Save(_ config.Config) error { + return nil +} diff --git a/cli/azd/resources/config_options.yaml b/cli/azd/resources/config_options.yaml index 75309a00c00..6cfae65fea7 100644 --- a/cli/azd/resources/config_options.yaml +++ b/cli/azd/resources/config_options.yaml @@ -82,6 +82,36 @@ description: "Default AI agent model provider." type: string example: "github-copilot" +- key: ai.agent.model + description: "Default model to use for Copilot SDK agent sessions." + type: string + example: "gpt-4.1" +- key: ai.agent.mode + description: "Default agent mode for Copilot SDK sessions." + type: string + allowedValues: ["autopilot", "interactive", "plan"] + example: "interactive" +- key: ai.agent.mcp.servers + description: "Additional MCP servers to load in agent sessions. Merged with built-in servers." + type: object + example: "ai.agent.mcp.servers..type" +- key: ai.agent.tools.available + description: "Allowlist of tools available to the agent. When set, only these tools are active." + type: object + example: '["read_file", "write_file"]' +- key: ai.agent.tools.excluded + description: "Denylist of tools blocked from the agent." + type: object + example: '["execute_command"]' +- key: ai.agent.systemMessage + description: "Custom system message appended to the agent's default system prompt." + type: string + example: "Always use TypeScript for code generation." +- key: ai.agent.copilot.logLevel + description: "Log level for the Copilot SDK client." + type: string + allowedValues: ["error", "warn", "info", "debug"] + example: "info" - key: pipeline.config.applicationServiceManagementReference description: "Application Service Management Reference for Azure pipeline configuration." type: string From 23d7acd6f3d16ded27f70e2f4469208f4ebea90f Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 24 Feb 2026 16:25:30 -0800 Subject: [PATCH 2/9] Add 'copilot' as default agent provider type Register CopilotProvider as a named 'copilot' model provider in the IoC container. This makes 'copilot' the default agent type when ai.agent.model.type is not explicitly configured. Changes: - Add LlmTypeCopilot constant and CopilotProvider (copilot_provider.go) - Default GetDefaultModel() to 'copilot' when no model type is set - Register 'copilot' provider in container.go - Update init.go to set 'copilot' instead of 'github-copilot' - Update error message to list copilot as supported type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 1 + cli/azd/cmd/init.go | 2 +- cli/azd/pkg/llm/copilot_provider.go | 55 +++++++++++++++++++++++++++++ cli/azd/pkg/llm/manager.go | 9 +++-- cli/azd/pkg/llm/model_factory.go | 5 ++- 5 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 cli/azd/pkg/llm/copilot_provider.go diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index c5a373068a9..6e0ce746314 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -585,6 +585,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterScoped(consent.NewConsentManager) container.MustRegisterNamedSingleton("ollama", llm.NewOllamaModelProvider) container.MustRegisterNamedSingleton("azure", llm.NewAzureOpenAiModelProvider) + container.MustRegisterNamedSingleton("copilot", llm.NewCopilotProvider) registerGitHubCopilotProvider(container) // Agent security manager diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index e288d14f7b5..19663ef2734 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -648,7 +648,7 @@ func promptInitType( console.Message(ctx, "\nThe azd agent feature has been enabled to support this new experience."+ " To turn off in the future run `azd config unset alpha.llm`.") - err = azdConfig.Set("ai.agent.model.type", "github-copilot") + err = azdConfig.Set("ai.agent.model.type", "copilot") if err != nil { return initUnknown, fmt.Errorf("failed to set ai.agent.model.type config: %w", err) } diff --git a/cli/azd/pkg/llm/copilot_provider.go b/cli/azd/pkg/llm/copilot_provider.go new file mode 100644 index 00000000000..b747fbb803d --- /dev/null +++ b/cli/azd/pkg/llm/copilot_provider.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/config" +) + +// CopilotProvider implements ModelProvider for the Copilot SDK agent type. +// Unlike Azure OpenAI or Ollama, the Copilot SDK handles the full agent runtime — +// this provider returns a ModelContainer marker that signals the agent factory +// to use CopilotAgentFactory instead of the langchaingo-based AgentFactory. +type CopilotProvider struct { + userConfigManager config.UserConfigManager +} + +// NewCopilotProvider creates a new Copilot provider. +func NewCopilotProvider(userConfigManager config.UserConfigManager) ModelProvider { + return &CopilotProvider{ + userConfigManager: userConfigManager, + } +} + +// CreateModelContainer returns a ModelContainer for the Copilot SDK. +// The Model field is nil because the Copilot SDK manages the full agent runtime +// via copilot.Session — the container serves as a type marker for agent factory selection. +func (p *CopilotProvider) CreateModelContainer( + ctx context.Context, opts ...ModelOption, +) (*ModelContainer, error) { + container := &ModelContainer{ + Type: LlmTypeCopilot, + IsLocal: false, + Metadata: ModelMetadata{ + Name: "copilot", + Version: "latest", + }, + } + + // Read optional model name from config + userConfig, err := p.userConfigManager.Load() + if err == nil { + if model, ok := userConfig.GetString("ai.agent.model"); ok { + container.Metadata.Name = model + } + } + + for _, opt := range opts { + opt(container) + } + + return container, nil +} diff --git a/cli/azd/pkg/llm/manager.go b/cli/azd/pkg/llm/manager.go index 9c9fd542eb6..4797ae522f6 100644 --- a/cli/azd/pkg/llm/manager.go +++ b/cli/azd/pkg/llm/manager.go @@ -61,6 +61,8 @@ func (l LlmType) String() string { return "OpenAI Azure" case LlmTypeGhCp: return "GitHub Copilot" + case LlmTypeCopilot: + return "Copilot" default: return string(l) } @@ -71,8 +73,10 @@ const ( LlmTypeOpenAIAzure LlmType = "azure" // LlmTypeOllama represents the Ollama model type. LlmTypeOllama LlmType = "ollama" - // LlmTypeGhCp represents the GitHub Copilot model type. + // LlmTypeGhCp represents the GitHub Copilot model type (build-gated, legacy). LlmTypeGhCp LlmType = "github-copilot" + // LlmTypeCopilot represents the Copilot SDK model type. + LlmTypeCopilot LlmType = "copilot" ) // ModelMetadata represents a language model with its name and version information. @@ -135,8 +139,7 @@ func (m Manager) GetDefaultModel(ctx context.Context, opts ...ModelOption) (*Mod defaultModelType, ok := userConfig.GetString("ai.agent.model.type") if !ok { - return nil, fmt.Errorf("Default model type has not been set. Set the agent model type with" + - " `azd config set ai.agent.model.type github-copilot`.") + defaultModelType = string(LlmTypeCopilot) } return m.ModelFactory.CreateModelContainer(ctx, LlmType(defaultModelType), opts...) diff --git a/cli/azd/pkg/llm/model_factory.go b/cli/azd/pkg/llm/model_factory.go index 552eaf1b5b0..cb138b82b9d 100644 --- a/cli/azd/pkg/llm/model_factory.go +++ b/cli/azd/pkg/llm/model_factory.go @@ -31,7 +31,10 @@ func (f *ModelFactory) CreateModelContainer( var modelProvider ModelProvider if err := f.serviceLocator.ResolveNamed(string(modelType), &modelProvider); err != nil { return nil, &internal.ErrorWithSuggestion{ - Err: fmt.Errorf("The model type '%s' is not supported. Support types include: azure, ollama", modelType), + Err: fmt.Errorf( + "the model type '%s' is not supported. Supported types include: copilot, azure, ollama", + modelType, + ), //nolint:lll Suggestion: "Use `azd config set` to set the model type and any model specific options, such as the model name or version.", } From 33ed54d90317c44925dbcf18e314594ce585d054 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 24 Feb 2026 17:02:42 -0800 Subject: [PATCH 3/9] Add diagnostic logging to Copilot SDK agent pipeline Add [copilot] and [copilot-event] prefixed log statements throughout the Copilot SDK agent pipeline for troubleshooting: - CopilotClientManager: Start/stop state transitions - CopilotAgentFactory: MCP server count, session config details, PreToolUse/PostToolUse/ErrorOccurred hook invocations - CopilotAgent.SendMessage: prompt size, response size, errors - SessionEventLogger: every event type received, plus detail for assistant.message, tool.execution_start, and assistant.reasoning Run with AZD_DEBUG=true to see log output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/agent/copilot_agent.go | 5 +++++ cli/azd/internal/agent/copilot_agent_factory.go | 17 +++++++++++++++-- .../agent/logging/session_event_handler.go | 5 +++++ cli/azd/pkg/llm/copilot_client.go | 4 ++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index f00ce077b1f..a472c029575 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -6,6 +6,7 @@ package agent import ( "context" "fmt" + "log" "strings" "time" @@ -104,18 +105,22 @@ func (a *CopilotAgent) SendMessage(ctx context.Context, args ...string) (string, }() prompt := strings.Join(args, "\n") + log.Printf("[copilot] SendMessage: sending prompt (%d chars)...", len(prompt)) result, err := a.session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: prompt, }) if err != nil { + log.Printf("[copilot] SendMessage: error: %v", err) return "", fmt.Errorf("copilot agent error: %w", err) } // Extract the final assistant message content if result != nil && result.Data.Content != nil { + log.Printf("[copilot] SendMessage: received response (%d chars)", len(*result.Data.Content)) return *result.Data.Content, nil } + log.Println("[copilot] SendMessage: received empty response") return "", nil } diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go index c871271bd36..6654f0f8fd2 100644 --- a/cli/azd/internal/agent/copilot_agent_factory.go +++ b/cli/azd/internal/agent/copilot_agent_factory.go @@ -54,9 +54,11 @@ func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...CopilotAgentOp } // Start the Copilot client (spawns copilot-agent-runtime) + log.Println("[copilot] Starting Copilot SDK client...") if err := f.clientManager.Start(ctx); err != nil { return nil, err } + log.Printf("[copilot] Client started (state: %s)", f.clientManager.State()) cleanupTasks["copilot-client"] = f.clientManager.Stop // Create thought channel for UX streaming @@ -89,6 +91,7 @@ func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...CopilotAgentOp defer cleanup() return nil, err } + log.Printf("[copilot] Loaded %d built-in MCP servers", len(builtInServers)) // Build session config from azd user config sessionConfig, err := f.sessionConfigBuilder.Build(ctx, builtInServers) @@ -96,29 +99,39 @@ func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...CopilotAgentOp defer cleanup() return nil, fmt.Errorf("failed to build session config: %w", err) } + log.Printf("[copilot] Session config built (model=%q, mcpServers=%d, availableTools=%d, excludedTools=%d)", + sessionConfig.Model, len(sessionConfig.MCPServers), len(sessionConfig.AvailableTools), len(sessionConfig.ExcludedTools)) // Wire permission hooks sessionConfig.Hooks = &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) ( *copilot.PreToolUseHookOutput, error, ) { - // Allow all tools by default — SDK handles its own permission model. - // In Phase 2, azd-specific security policies (path validation) will be wired here. + log.Printf("[copilot] PreToolUse: tool=%s", input.ToolName) return &copilot.PreToolUseHookOutput{}, nil }, OnPostToolUse: func(input copilot.PostToolUseHookInput, inv copilot.HookInvocation) ( *copilot.PostToolUseHookOutput, error, ) { + log.Printf("[copilot] PostToolUse: tool=%s", input.ToolName) + return nil, nil + }, + OnErrorOccurred: func(input copilot.ErrorOccurredHookInput, inv copilot.HookInvocation) ( + *copilot.ErrorOccurredHookOutput, error, + ) { + log.Printf("[copilot] ErrorOccurred: error=%s recoverable=%v", input.Error, input.Recoverable) return nil, nil }, } // Create session + log.Println("[copilot] Creating session...") session, err := f.clientManager.Client().CreateSession(ctx, sessionConfig) if err != nil { defer cleanup() return nil, fmt.Errorf("failed to create Copilot session: %w", err) } + log.Println("[copilot] Session created successfully") // Subscribe to session events unsubscribe := session.On(func(event copilot.SessionEvent) { diff --git a/cli/azd/internal/agent/logging/session_event_handler.go b/cli/azd/internal/agent/logging/session_event_handler.go index 82c5ca7b9ce..c3b1afbf7af 100644 --- a/cli/azd/internal/agent/logging/session_event_handler.go +++ b/cli/azd/internal/agent/logging/session_event_handler.go @@ -5,6 +5,7 @@ package logging import ( "fmt" + "log" "os" "path/filepath" "strings" @@ -28,6 +29,8 @@ func NewSessionEventLogger(thoughtChan chan<- Thought) *SessionEventLogger { // HandleEvent processes a Copilot SDK SessionEvent and emits corresponding Thought structs. func (l *SessionEventLogger) HandleEvent(event copilot.SessionEvent) { + log.Printf("[copilot-event] type=%s", event.Type) + if l.thoughtChan == nil { return } @@ -36,6 +39,7 @@ func (l *SessionEventLogger) HandleEvent(event copilot.SessionEvent) { case copilot.AssistantMessage: if event.Data.Content != nil && *event.Data.Content != "" { content := strings.TrimSpace(*event.Data.Content) + log.Printf("[copilot-event] assistant.message: %s", truncateString(content, 200)) if content != "" && !strings.Contains(strings.ToLower(content), "do i need to use a tool?") { l.thoughtChan <- Thought{ Thought: content, @@ -50,6 +54,7 @@ func (l *SessionEventLogger) HandleEvent(event copilot.SessionEvent) { } else if event.Data.MCPToolName != nil { toolName = *event.Data.MCPToolName } + log.Printf("[copilot-event] tool.execution_start: tool=%s", toolName) if toolName == "" { return } diff --git a/cli/azd/pkg/llm/copilot_client.go b/cli/azd/pkg/llm/copilot_client.go index 118a5d8c900..85f62450dda 100644 --- a/cli/azd/pkg/llm/copilot_client.go +++ b/cli/azd/pkg/llm/copilot_client.go @@ -6,6 +6,7 @@ package llm import ( "context" "fmt" + "log" copilot "github.com/github/copilot-sdk/go" ) @@ -44,13 +45,16 @@ func NewCopilotClientManager(options *CopilotClientOptions) *CopilotClientManage // Start initializes the Copilot SDK client and establishes a connection // to the copilot-agent-runtime process. func (m *CopilotClientManager) Start(ctx context.Context) error { + log.Printf("[copilot-client] Starting client (logLevel=%q)...", m.options.LogLevel) if err := m.client.Start(ctx); err != nil { + log.Printf("[copilot-client] Start failed: %v", err) return fmt.Errorf( "failed to start Copilot agent runtime: %w. "+ "Ensure you have a GitHub Copilot subscription and the Copilot CLI is available", err, ) } + log.Printf("[copilot-client] Started successfully (state=%s)", m.client.State()) return nil } From 284d740da9fad656d8a3f06adfc9bcfc24ccefa1 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 24 Feb 2026 17:08:13 -0800 Subject: [PATCH 4/9] Wire CopilotAgentFactory into AgentFactory for automatic delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentFactory.Create() now checks the configured model type. When it's 'copilot' (the new default), it delegates to CopilotAgentFactory which creates a CopilotAgent backed by the Copilot SDK session. No call site changes needed — existing code calling AgentFactory.Create() gets the Copilot SDK agent automatically. Changes: - AgentFactory now takes CopilotAgentFactory as a dependency - Create() checks model type and delegates to CopilotAgentFactory - Register CopilotAgentFactory, CopilotClientManager, and SessionConfigBuilder in IoC container (cmd/container.go) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 5 ++++ cli/azd/internal/agent/agent_factory.go | 38 +++++++++++++++++++------ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 6e0ce746314..ca0c4b93bee 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -581,6 +581,11 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // AI & LLM components container.MustRegisterSingleton(llm.NewManager) container.MustRegisterSingleton(llm.NewModelFactory) + container.MustRegisterSingleton(llm.NewSessionConfigBuilder) + container.MustRegisterSingleton(func() *llm.CopilotClientManager { + return llm.NewCopilotClientManager(nil) + }) + container.MustRegisterScoped(agent.NewCopilotAgentFactory) container.MustRegisterScoped(agent.NewAgentFactory) container.MustRegisterScoped(consent.NewConsentManager) container.MustRegisterNamedSingleton("ollama", llm.NewOllamaModelProvider) diff --git a/cli/azd/internal/agent/agent_factory.go b/cli/azd/internal/agent/agent_factory.go index b6a23f981c0..4a2de12ade6 100644 --- a/cli/azd/internal/agent/agent_factory.go +++ b/cli/azd/internal/agent/agent_factory.go @@ -22,10 +22,11 @@ import ( // AgentFactory is responsible for creating agent instances type AgentFactory struct { - consentManager consent.ConsentManager - llmManager *llm.Manager - console input.Console - securityManager *security.Manager + consentManager consent.ConsentManager + llmManager *llm.Manager + console input.Console + securityManager *security.Manager + copilotAgentFactory *CopilotAgentFactory } // NewAgentFactory creates a new instance of AgentFactory @@ -34,17 +35,36 @@ func NewAgentFactory( console input.Console, llmManager *llm.Manager, securityManager *security.Manager, + copilotAgentFactory *CopilotAgentFactory, ) *AgentFactory { return &AgentFactory{ - consentManager: consentManager, - llmManager: llmManager, - console: console, - securityManager: securityManager, + consentManager: consentManager, + llmManager: llmManager, + console: console, + securityManager: securityManager, + copilotAgentFactory: copilotAgentFactory, } } // CreateAgent creates a new agent instance func (f *AgentFactory) Create(ctx context.Context, opts ...AgentCreateOption) (Agent, error) { + // Check if the configured model type is 'copilot' — if so, delegate to CopilotAgentFactory + defaultModelContainer, err := f.llmManager.GetDefaultModel(ctx) + if err == nil && defaultModelContainer.Type == llm.LlmTypeCopilot { + log.Println("[agent-factory] Model type is 'copilot', delegating to CopilotAgentFactory") + copilotOpts := []CopilotAgentOption{} + for _, opt := range opts { + base := &agentBase{} + opt(base) + if base.debug { + copilotOpts = append(copilotOpts, WithCopilotDebug(true)) + } + } + return f.copilotAgentFactory.Create(ctx, copilotOpts...) + } + + log.Printf("[agent-factory] Using langchaingo agent (model type: %s)", defaultModelContainer.Type) + cleanupTasks := map[string]func() error{} cleanup := func() error { @@ -77,7 +97,7 @@ func (f *AgentFactory) Create(ctx context.Context, opts ...AgentCreateOption) (A } // Default model gets the chained handler to expose the UX experience for the agent - defaultModelContainer, err := f.llmManager.GetDefaultModel(ctx, llm.WithLogger(chainedHandler)) + defaultModelContainer, err = f.llmManager.GetDefaultModel(ctx, llm.WithLogger(chainedHandler)) if err != nil { defer cleanup() return nil, err From b992c7bf17a8be80df73818ade608ef6add072ef Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 24 Feb 2026 17:17:16 -0800 Subject: [PATCH 5/9] Enable SDK debug logging to diagnose CLI process startup failure Set SDK LogLevel to 'debug' by default to surface the command and args the SDK uses when spawning the copilot CLI process. This will help diagnose the 'exit status 1' error during client.Start(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/llm/copilot_client.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/llm/copilot_client.go b/cli/azd/pkg/llm/copilot_client.go index 85f62450dda..a7812a1affd 100644 --- a/cli/azd/pkg/llm/copilot_client.go +++ b/cli/azd/pkg/llm/copilot_client.go @@ -34,6 +34,8 @@ func NewCopilotClientManager(options *CopilotClientOptions) *CopilotClientManage clientOpts := &copilot.ClientOptions{} if options.LogLevel != "" { clientOpts.LogLevel = options.LogLevel + } else { + clientOpts.LogLevel = "debug" } return &CopilotClientManager{ @@ -46,11 +48,12 @@ func NewCopilotClientManager(options *CopilotClientOptions) *CopilotClientManage // to the copilot-agent-runtime process. func (m *CopilotClientManager) Start(ctx context.Context) error { log.Printf("[copilot-client] Starting client (logLevel=%q)...", m.options.LogLevel) + log.Printf("[copilot-client] SDK will spawn copilot CLI process via stdio transport") if err := m.client.Start(ctx); err != nil { log.Printf("[copilot-client] Start failed: %v", err) + log.Printf("[copilot-client] Ensure 'copilot' CLI is in PATH and supports SDK protocol") return fmt.Errorf( - "failed to start Copilot agent runtime: %w. "+ - "Ensure you have a GitHub Copilot subscription and the Copilot CLI is available", + "failed to start Copilot agent runtime: %w", err, ) } From 010acc5dda798f78693ee1b17a8d90d3d3c68701 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 25 Feb 2026 15:22:13 -0800 Subject: [PATCH 6/9] Update copilot-sdk to v0.1.26-preview.0 CLI v0.0.419-0 now supports --headless and --stdio flags required by the SDK. Updated Go SDK from v0.1.25 to v0.1.26-preview.0 for latest compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/go.mod | 2 +- cli/azd/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/go.mod b/cli/azd/go.mod index 21c0158ecdb..60b1ddc8f75 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -109,7 +109,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/github/copilot-sdk/go v0.1.25 // indirect + github.com/github/copilot-sdk/go v0.1.26-preview.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/cli/azd/go.sum b/cli/azd/go.sum index b23142fda7b..ce6271b048f 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -196,6 +196,8 @@ github.com/getzep/zep-go v1.0.4 h1:09o26bPP2RAPKFjWuVWwUWLbtFDF/S8bfbilxzeZAAg= github.com/getzep/zep-go v1.0.4/go.mod h1:HC1Gz7oiyrzOTvzeKC4dQKUiUy87zpIJl0ZFXXdHuss= github.com/github/copilot-sdk/go v0.1.25 h1:SJ/jSoesbpjDEBcvMkoCG+xITvgvnhxnd6oJdmNQnOs= github.com/github/copilot-sdk/go v0.1.25/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= +github.com/github/copilot-sdk/go v0.1.26-preview.0 h1:UErdFjDBUXGinDmc+J8KoVlmdXJkdqcx6D6pu3Na2GE= +github.com/github/copilot-sdk/go v0.1.26-preview.0/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= From 992f5d3bea6f8a932f9c60392dc643bf11364d94 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 25 Feb 2026 16:19:28 -0800 Subject: [PATCH 7/9] Auto-discover native Copilot CLI binary from @github/copilot-sdk npm package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copilot CLI shim in PATH (from @github/copilot npm package) doesn't support --headless --stdio flags required by the Go SDK. However, the @github/copilot-sdk npm package bundles a newer native binary at node_modules/@github/copilot-{platform}/copilot[.exe] that does. CopilotClientManager now auto-discovers this binary with resolution order: 1. COPILOT_CLI_PATH environment variable 2. Native binary from @github/copilot-sdk npm package (platform-specific) 3. Falls back to 'copilot' in PATH (SDK default) Also adds a passing e2e test (TestCopilotSDK_E2E) that validates the full SDK lifecycle: client start → auth check → list models → create session → send prompt → receive response → cleanup. Pure native binary, no Node.js. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/llm/copilot_client.go | 75 +++++++++ cli/azd/pkg/llm/copilot_sdk_e2e_test.go | 203 ++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 cli/azd/pkg/llm/copilot_sdk_e2e_test.go diff --git a/cli/azd/pkg/llm/copilot_client.go b/cli/azd/pkg/llm/copilot_client.go index a7812a1affd..5a9c9c7ea84 100644 --- a/cli/azd/pkg/llm/copilot_client.go +++ b/cli/azd/pkg/llm/copilot_client.go @@ -7,6 +7,9 @@ import ( "context" "fmt" "log" + "os" + "path/filepath" + "runtime" copilot "github.com/github/copilot-sdk/go" ) @@ -22,6 +25,9 @@ type CopilotClientManager struct { type CopilotClientOptions struct { // LogLevel controls SDK logging verbosity (e.g., "info", "debug", "error"). LogLevel string + // CLIPath overrides the path to the Copilot CLI binary. + // If empty, auto-discovered from @github/copilot-sdk npm package or COPILOT_CLI_PATH env. + CLIPath string } // NewCopilotClientManager creates a new CopilotClientManager with the given options. @@ -38,6 +44,16 @@ func NewCopilotClientManager(options *CopilotClientOptions) *CopilotClientManage clientOpts.LogLevel = "debug" } + // Resolve CLI path: explicit option > env var > auto-discover from npm + cliPath := options.CLIPath + if cliPath == "" { + cliPath = discoverCopilotCLIPath() + } + if cliPath != "" { + clientOpts.CLIPath = cliPath + log.Printf("[copilot-client] Using CLI binary: %s", cliPath) + } + return &CopilotClientManager{ client: copilot.NewClient(clientOpts), options: options, @@ -96,3 +112,62 @@ func (m *CopilotClientManager) ListModels(ctx context.Context) ([]copilot.ModelI func (m *CopilotClientManager) State() copilot.ConnectionState { return m.client.State() } + +// discoverCopilotCLIPath finds the native Copilot CLI binary that supports +// the --headless --stdio flags required by the SDK. +// +// Resolution order: +// 1. COPILOT_CLI_PATH environment variable +// 2. Native binary bundled in @github/copilot-sdk npm package +// 3. Empty string (SDK will fall back to "copilot" in PATH) +func discoverCopilotCLIPath() string { + if p := os.Getenv("COPILOT_CLI_PATH"); p != "" { + return p + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + // Map Go arch to npm platform naming + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "x64" + case "386": + arch = "ia32" + } + + var platformPkg, binaryName string + switch runtime.GOOS { + case "windows": + platformPkg = fmt.Sprintf("copilot-win32-%s", arch) + binaryName = "copilot.exe" + case "darwin": + platformPkg = fmt.Sprintf("copilot-darwin-%s", arch) + binaryName = "copilot" + case "linux": + platformPkg = fmt.Sprintf("copilot-linux-%s", arch) + binaryName = "copilot" + default: + return "" + } + + // Search common npm global node_modules locations + candidates := []string{ + filepath.Join(home, "AppData", "Roaming", "npm", "node_modules"), + filepath.Join(home, ".npm-global", "lib", "node_modules"), + "/usr/local/lib/node_modules", + "/usr/lib/node_modules", + } + + for _, c := range candidates { + p := filepath.Join(c, "@github", "copilot-sdk", "node_modules", "@github", platformPkg, binaryName) + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} diff --git a/cli/azd/pkg/llm/copilot_sdk_e2e_test.go b/cli/azd/pkg/llm/copilot_sdk_e2e_test.go new file mode 100644 index 00000000000..edf65a57efc --- /dev/null +++ b/cli/azd/pkg/llm/copilot_sdk_e2e_test.go @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/stretchr/testify/require" +) + +// TestCopilotSDK_E2E validates the Copilot SDK client lifecycle end-to-end: +// client start → session create → send message → receive response → cleanup. +// +// Requires: copilot CLI in PATH (v0.0.419+), GitHub Copilot subscription. +// Skip with: go test -short +func TestCopilotSDK_E2E(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + if os.Getenv("SKIP_COPILOT_E2E") == "1" { + t.Skip("SKIP_COPILOT_E2E is set") + } + + // The Go SDK spawns copilot with --headless --stdio flags. + // The native copilot binary doesn't support these — we need to point + // CLIPath to the JS SDK entry point bundled in @github/copilot-sdk. + cliPath := findCopilotSDKCLIPath() + if cliPath == "" { + t.Skip("copilot SDK CLI path not found — install @github/copilot-sdk globally via npm") + } + t.Logf("Using CLI path: %s", cliPath) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // 1. Create and start client + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + LogLevel: "error", + }) + + err := client.Start(ctx) + require.NoError(t, err, "client.Start failed — is copilot CLI installed and authenticated?") + defer func() { + stopErr := client.Stop() + if stopErr != nil { + t.Logf("client.Stop error: %v", stopErr) + } + }() + + t.Logf("Client started, state: %s", client.State()) + require.Equal(t, copilot.StateConnected, client.State()) + + // 2. Check auth + auth, err := client.GetAuthStatus(ctx) + require.NoError(t, err) + t.Logf("Auth: authenticated=%v, login=%v", auth.IsAuthenticated, auth.Login) + require.True(t, auth.IsAuthenticated, "not authenticated with GitHub Copilot") + + // 3. List models + models, err := client.ListModels(ctx) + require.NoError(t, err) + require.NotEmpty(t, models, "no models available") + t.Logf("Available models: %d", len(models)) + for i, m := range models { + if i < 5 { + t.Logf(" - %s (id=%s)", m.Name, m.ID) + } + } + + // 4. Create session + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a helpful assistant. Answer concisely in one sentence.", + }, + }) + require.NoError(t, err, "CreateSession failed") + t.Logf("Session created: %s", session.WorkspacePath()) + defer func() { + if destroyErr := session.Destroy(); destroyErr != nil { + t.Logf("session.Destroy error: %v", destroyErr) + } + }() + + // 5. Collect events + var events []copilot.SessionEvent + unsubscribe := session.On(func(event copilot.SessionEvent) { + events = append(events, event) + t.Logf("Event: type=%s", event.Type) + }) + defer unsubscribe() + + // 6. Send message and wait for response + t.Log("Sending prompt...") + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is 2+2? Reply with just the number.", + }) + require.NoError(t, err, "SendAndWait failed") + + // 7. Validate response + t.Logf("Received %d events total", len(events)) + if response != nil && response.Data.Content != nil { + t.Logf("Response content: %s", *response.Data.Content) + require.Contains(t, *response.Data.Content, "4", + "expected response to contain '4'") + } else { + // If SendAndWait returned nil, check events for assistant message + var found bool + for _, e := range events { + if e.Type == copilot.AssistantMessage && e.Data.Content != nil { + t.Logf("Found assistant message in events: %s", *e.Data.Content) + found = true + break + } + } + if !found { + // Log all event types for debugging + for _, e := range events { + detail := "" + if e.Data.Content != nil { + detail = fmt.Sprintf(" content=%s", truncateForLog(*e.Data.Content, 100)) + } + t.Logf(" event: type=%s%s", e.Type, detail) + } + t.Fatal("no assistant message received") + } + } +} + +func truncateForLog(s string, max int) string { + if len(s) > max { + return s[:max] + "..." + } + return s +} + +// findCopilotSDKCLIPath locates the native Copilot CLI binary bundled in the +// @github/copilot-sdk npm package. This binary supports --headless --stdio +// required by the Go SDK, unlike the copilot shim installed in PATH. +func findCopilotSDKCLIPath() string { + if p := os.Getenv("COPILOT_CLI_PATH"); p != "" { + return p + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + // Map Go arch to npm platform arch naming + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "x64" + case "386": + arch = "ia32" + } + + // Platform-specific binary package name + var platformPkg string + switch runtime.GOOS { + case "windows": + platformPkg = fmt.Sprintf("copilot-win32-%s", arch) + case "darwin": + platformPkg = fmt.Sprintf("copilot-darwin-%s", arch) + case "linux": + platformPkg = fmt.Sprintf("copilot-linux-%s", arch) + default: + return "" + } + + binaryName := "copilot" + if runtime.GOOS == "windows" { + binaryName = "copilot.exe" + } + + // Search common npm global node_modules locations + candidates := []string{ + filepath.Join(home, "AppData", "Roaming", "npm", "node_modules"), + filepath.Join(home, ".npm-global", "lib", "node_modules"), + "/usr/local/lib/node_modules", + "/usr/lib/node_modules", + } + + for _, c := range candidates { + p := filepath.Join(c, "@github", "copilot-sdk", "node_modules", "@github", platformPkg, binaryName) + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} From 850c359d3abb5dea6a94f641f5504b343df76992 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 25 Feb 2026 16:56:40 -0800 Subject: [PATCH 8/9] Fix: explicitly allow tools in PreToolUse hook The empty PreToolUseHookOutput{} was interpreted as deny by the SDK, blocking all tool calls. Set PermissionDecision to 'allow' explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/agent/copilot_agent_factory.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go index 6654f0f8fd2..da724dd8ae0 100644 --- a/cli/azd/internal/agent/copilot_agent_factory.go +++ b/cli/azd/internal/agent/copilot_agent_factory.go @@ -108,7 +108,9 @@ func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...CopilotAgentOp *copilot.PreToolUseHookOutput, error, ) { log.Printf("[copilot] PreToolUse: tool=%s", input.ToolName) - return &copilot.PreToolUseHookOutput{}, nil + return &copilot.PreToolUseHookOutput{ + PermissionDecision: "allow", + }, nil }, OnPostToolUse: func(input copilot.PostToolUseHookInput, inv copilot.HookInvocation) ( *copilot.PostToolUseHookOutput, error, From c3e044e0a47a5ae8b4a093759a0c1b99681ba51b Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 25 Feb 2026 17:21:46 -0800 Subject: [PATCH 9/9] Fix: add OnPermissionRequest handler to approve tool permissions The SDK has two separate permission mechanisms: 1. PreToolUse hooks (lifecycle interception) - already set to 'allow' 2. OnPermissionRequest handler (CLI permission prompts) - was NOT set Without OnPermissionRequest, the CLI's permission requests go unanswered and default to deny, blocking all tool calls. Use the SDK's built-in PermissionHandler.ApproveAll to approve all requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/agent/copilot_agent_factory.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go index da724dd8ae0..82eb8915222 100644 --- a/cli/azd/internal/agent/copilot_agent_factory.go +++ b/cli/azd/internal/agent/copilot_agent_factory.go @@ -102,7 +102,10 @@ func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...CopilotAgentOp log.Printf("[copilot] Session config built (model=%q, mcpServers=%d, availableTools=%d, excludedTools=%d)", sessionConfig.Model, len(sessionConfig.MCPServers), len(sessionConfig.AvailableTools), len(sessionConfig.ExcludedTools)) - // Wire permission hooks + // Wire permission handler — approve all tool permission requests + sessionConfig.OnPermissionRequest = copilot.PermissionHandler.ApproveAll + + // Wire lifecycle hooks sessionConfig.Hooks = &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) ( *copilot.PreToolUseHookOutput, error,