diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index bc191ba4b6d..dd03c21ef42 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -942,6 +942,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(grpcserver.NewServiceTargetService) container.MustRegisterSingleton(grpcserver.NewFrameworkService) container.MustRegisterSingleton(grpcserver.NewAiModelService) + container.MustRegisterScoped(grpcserver.NewCopilotService) // Required for nested actions called from composite actions like 'up' registerAction[*cmd.ProvisionAction](container, "azd-provision-action") diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index de21e5df07d..6ff67bf16d4 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -139,7 +139,7 @@ type initAction struct { featuresManager *alpha.FeatureManager extensionsManager *extensions.Manager azd workflow.AzdCommandRunner - agentFactory *agent.CopilotAgentFactory + agentFactory agent.AgentFactory consentManager consent.ConsentManager configManager config.UserConfigManager } @@ -156,7 +156,7 @@ func newInitAction( featuresManager *alpha.FeatureManager, extensionsManager *extensions.Manager, azd workflow.AzdCommandRunner, - agentFactory *agent.CopilotAgentFactory, + agentFactory agent.AgentFactory, consentManager consent.ConsentManager, configManager config.UserConfigManager, ) actions.Action { @@ -522,7 +522,7 @@ When complete, provide a brief summary of what was accomplished.` i.console.Message(ctx, color.MagentaString("Preparing application for Azure deployment...")) - result, err := copilotAgent.SendMessageWithRetry(ctx, prompt, opts...) + _, err = copilotAgent.SendMessageWithRetry(ctx, prompt, opts...) if err != nil { return err } @@ -532,10 +532,10 @@ When complete, provide a brief summary of what was accomplished.` _ = azdCtx.ClearCopilotSession() } - // Show usage - if usage := result.Usage.Format(); usage != "" { + // Show session metrics (usage + file changes) + if metrics := copilotAgent.GetMetrics().String(); metrics != "" { i.console.Message(ctx, "") - i.console.Message(ctx, usage) + i.console.Message(ctx, metrics) } i.console.Message(ctx, "") diff --git a/cli/azd/cmd/middleware/error.go b/cli/azd/cmd/middleware/error.go index 60b1c67d6c1..3504695586f 100644 --- a/cli/azd/cmd/middleware/error.go +++ b/cli/azd/cmd/middleware/error.go @@ -57,7 +57,7 @@ var ( type ErrorMiddleware struct { options *Options console input.Console - agentFactory *agent.CopilotAgentFactory + agentFactory agent.AgentFactory global *internal.GlobalCommandOptions featuresManager *alpha.FeatureManager userConfigManager config.UserConfigManager @@ -147,7 +147,7 @@ func shouldSkipErrorAnalysis(err error) bool { func NewErrorMiddleware( options *Options, console input.Console, - agentFactory *agent.CopilotAgentFactory, + agentFactory agent.AgentFactory, global *internal.GlobalCommandOptions, featuresManager *alpha.FeatureManager, userConfigManager config.UserConfigManager, @@ -275,7 +275,7 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action // Display usage metrics if available if agentResult != nil && agentResult.Usage.TotalTokens() > 0 { e.console.Message(ctx, "") - e.console.Message(ctx, agentResult.Usage.Format()) + e.console.Message(ctx, agentResult.Usage.String()) } // Ask user if the agent applied a fix and they want to retry the command diff --git a/cli/azd/extensions/microsoft.azd.demo/extension.yaml b/cli/azd/extensions/microsoft.azd.demo/extension.yaml index e2ca08f6b66..05e570efb95 100644 --- a/cli/azd/extensions/microsoft.azd.demo/extension.yaml +++ b/cli/azd/extensions/microsoft.azd.demo/extension.yaml @@ -36,3 +36,6 @@ examples: - name: mcp description: Start MCP server with demo tools. usage: azd demo mcp start + - name: copilot + description: Interactive Copilot chat loop demonstrating CopilotService gRPC API. + usage: azd demo copilot [--model ] [--resume] diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go new file mode 100644 index 00000000000..086b9122349 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go @@ -0,0 +1,354 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func newCopilotCommand() *cobra.Command { + var model string + var reasoningEffort string + var systemMessage string + var mode string + var resume bool + + cmd := &cobra.Command{ + Use: "copilot", + Short: "Interactive Copilot chat loop demonstrating the CopilotService gRPC API.", + Long: `Demonstrates the CopilotService gRPC integration by running an interactive +chat loop. Sessions are created lazily on the first message. Usage metrics +and file changes accumulate across turns and are displayed on exit. + +Use --resume to list and resume a previous session.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + return runCopilotChat(ctx, azdClient, copilotFlags{ + model: model, + reasoningEffort: reasoningEffort, + systemMessage: systemMessage, + mode: mode, + resume: resume, + }) + }, + } + + cmd.Flags().StringVar(&model, "model", "", "Model to use (empty = default)") + cmd.Flags().StringVar( + &reasoningEffort, "reasoning-effort", "", "Reasoning effort level (low, medium, high)", + ) + cmd.Flags().StringVar(&systemMessage, "system-message", "", "Custom system message") + cmd.Flags().StringVar(&mode, "mode", "autopilot", "Agent mode (autopilot, interactive, plan)") + cmd.Flags().BoolVar(&resume, "resume", false, "Resume an existing session") + + return cmd +} + +type copilotFlags struct { + model string + reasoningEffort string + systemMessage string + mode string + resume bool +} + +func runCopilotChat(ctx context.Context, client *azdext.AzdClient, flags copilotFlags) error { + copilot := client.Copilot() + promptSvc := client.Prompt() + + fmt.Println() + fmt.Println(color.HiCyanString("╔══════════════════════════════════════╗")) + fmt.Println(color.HiCyanString("║") + + color.HiWhiteString(" Copilot Chat — Demo Extension ") + + color.HiCyanString(" ║")) + fmt.Println(color.HiCyanString("╚══════════════════════════════════════╝")) + fmt.Println() + + // Optional: Initialize to warm up client and show resolved config + initResp, err := copilot.Initialize(ctx, &azdext.InitializeCopilotRequest{ + Model: flags.model, + ReasoningEffort: flags.reasoningEffort, + }) + if err != nil { + fmt.Printf(" %s Initialize warning: %v\n", color.YellowString("⚠"), err) + } else { + if initResp.Model != "" { + fmt.Printf(" %s Model: %s\n", color.HiBlackString("•"), initResp.Model) + } + if initResp.ReasoningEffort != "" { + fmt.Printf(" %s Reasoning: %s\n", + color.HiBlackString("•"), initResp.ReasoningEffort) + } + if initResp.IsFirstRun { + fmt.Printf(" %s First-time configuration applied\n", color.HiBlackString("•")) + } + } + + // Determine starting session ID (empty = new, or from --resume) + var sessionID string + if flags.resume { + sessionID = pickExistingSession(ctx, copilot, promptSvc) + } + + fmt.Println() + fmt.Println(color.HiBlackString(" Type your message and press Enter. Type 'exit' or 'quit' to stop.")) + fmt.Println() + + // Chat loop — SendMessage handles session creation/resumption on first call + sessionID, err = chatLoop(ctx, copilot, promptSvc, sessionID, flags) + if err != nil { + return err + } + + // Show cumulative metrics and file changes + if sessionID != "" { + showCumulativeMetrics(ctx, copilot, sessionID) + showFileChanges(ctx, copilot, sessionID) + + // Stop the session + if _, stopErr := copilot.StopSession(ctx, &azdext.StopCopilotSessionRequest{ + SessionId: sessionID, + }); stopErr != nil { + fmt.Printf(" %s Failed to stop session: %v\n", color.RedString("✗"), stopErr) + } else { + fmt.Println() + fmt.Printf(" %s Session stopped\n", color.GreenString("✓")) + } + } + + fmt.Println() + return nil +} + +// pickExistingSession lists sessions and lets the user pick one to resume. +// Returns the SDK session ID to resume, or empty for a new session. +func pickExistingSession( + ctx context.Context, + copilot azdext.CopilotServiceClient, + promptSvc azdext.PromptServiceClient, +) string { + fmt.Println(color.HiYellowString(" Looking for existing sessions...")) + + listResp, err := copilot.ListSessions(ctx, &azdext.ListCopilotSessionsRequest{}) + if err != nil { + fmt.Printf(" %s Could not list sessions: %v\n", color.YellowString("⚠"), err) + return "" + } + + if len(listResp.Sessions) == 0 { + fmt.Println(color.HiBlackString(" No existing sessions found.")) + return "" + } + + choices := []*azdext.SelectChoice{ + {Value: "__new__", Label: "Start a new session"}, + } + for _, s := range listResp.Sessions { + label := fmt.Sprintf("Resume: %s", s.SessionId) + if s.Summary != "" { + summary := s.Summary + if len(summary) > 60 { + summary = summary[:57] + "..." + } + label += fmt.Sprintf(" — %s", summary) + } + if s.ModifiedTime != "" { + label += fmt.Sprintf(" (%s)", s.ModifiedTime) + } + choices = append(choices, &azdext.SelectChoice{ + Value: s.SessionId, + Label: label, + }) + } + + selectResp, err := promptSvc.Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a session", + Choices: choices, + }, + }) + if err != nil { + return "" + } + + idx := int(selectResp.GetValue()) + if idx < 0 || idx >= len(choices) || choices[idx].Value == "__new__" { + return "" + } + + sdkSessionID := choices[idx].Value + fmt.Printf(" %s Will resume session: %s\n", + color.GreenString("✓"), color.CyanString(sdkSessionID)) + + return sdkSessionID +} + +// chatLoop runs the interactive prompt → send → display cycle. +// Returns the session ID assigned by the server (from the first SendMessage response). +func chatLoop( + ctx context.Context, + copilot azdext.CopilotServiceClient, + promptSvc azdext.PromptServiceClient, + sessionID string, + flags copilotFlags, +) (string, error) { + for { + resp, err := promptSvc.Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "You", + Placeholder: "Type a message...", + IgnoreHintKeys: true, + }, + }) + if err != nil { + return sessionID, nil // user cancelled + } + + input := strings.TrimSpace(resp.Value) + if input == "" || strings.EqualFold(input, "exit") || strings.EqualFold(input, "quit") { + fmt.Println() + fmt.Println(color.HiBlackString(" Ending chat session...")) + break + } + + // SendMessage creates the session on the first call, reuses it after + sendReq := &azdext.SendCopilotMessageRequest{ + Prompt: input, + } + if sessionID != "" { + sendReq.SessionId = sessionID + } else { + // First call — include config options + sendReq.Model = flags.model + sendReq.ReasoningEffort = flags.reasoningEffort + sendReq.SystemMessage = flags.systemMessage + sendReq.Mode = flags.mode + } + + sendResp, err := copilot.SendMessage(ctx, sendReq) + if err != nil { + fmt.Printf(" %s Error: %v\n", color.RedString("✗"), err) + fmt.Println() + continue + } + + // Capture the session ID from the response for reuse + sessionID = sendResp.SessionId + + fmt.Println() + } + + return sessionID, nil +} + +// showCumulativeMetrics displays cumulative session usage. +func showCumulativeMetrics( + ctx context.Context, + copilot azdext.CopilotServiceClient, + sessionID string, +) { + metricsResp, err := copilot.GetUsageMetrics(ctx, &azdext.GetCopilotUsageMetricsRequest{ + SessionId: sessionID, + }) + if err != nil { + fmt.Printf(" %s Could not retrieve metrics: %v\n", color.YellowString("⚠"), err) + return + } + + usage := metricsResp.Usage + if usage == nil || (usage.InputTokens == 0 && usage.OutputTokens == 0) { + return + } + + fmt.Println() + fmt.Println(color.HiWhiteString(" ── Session Usage ──")) + if usage.Model != "" { + fmt.Printf(" %s Model: %s\n", color.HiBlackString("•"), usage.Model) + } + fmt.Printf(" %s Input tokens: %s\n", + color.HiBlackString("•"), formatTokens(usage.InputTokens)) + fmt.Printf(" %s Output tokens: %s\n", + color.HiBlackString("•"), formatTokens(usage.OutputTokens)) + fmt.Printf(" %s Total tokens: %s\n", + color.HiBlackString("•"), formatTokens(usage.TotalTokens)) + if usage.BillingRate > 0 { + fmt.Printf(" %s Billing rate: %.0fx per request\n", + color.HiBlackString("•"), usage.BillingRate) + } + fmt.Printf(" %s Premium requests: %.0f\n", + color.HiBlackString("•"), usage.PremiumRequests) + if usage.DurationMs > 0 { + fmt.Printf(" %s API duration: %s\n", + color.HiBlackString("•"), formatDuration(usage.DurationMs)) + } +} + +// showFileChanges displays accumulated file changes from the session. +func showFileChanges( + ctx context.Context, + copilot azdext.CopilotServiceClient, + sessionID string, +) { + changesResp, err := copilot.GetFileChanges(ctx, &azdext.GetCopilotFileChangesRequest{ + SessionId: sessionID, + }) + if err != nil || len(changesResp.FileChanges) == 0 { + return + } + + fmt.Println() + fmt.Println(color.HiWhiteString(" ── File Changes ──")) + + for _, change := range changesResp.FileChanges { + switch change.ChangeType { + case azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_CREATED: + fmt.Printf(" %s %s\n", color.GreenString("+ Created "), change.Path) + case azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_MODIFIED: + fmt.Printf(" %s %s\n", color.YellowString("± Modified"), change.Path) + case azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_DELETED: + fmt.Printf(" %s %s\n", color.RedString("- Deleted "), change.Path) + default: + fmt.Printf(" %s %s\n", color.HiBlackString("? Unknown "), change.Path) + } + } +} + +func formatTokens(tokens float64) string { + if tokens >= 1_000_000 { + return fmt.Sprintf("%.1fM", tokens/1_000_000) + } + if tokens >= 1_000 { + return fmt.Sprintf("%.1fK", tokens/1_000) + } + return fmt.Sprintf("%.0f", tokens) +} + +func formatDuration(ms float64) string { + seconds := ms / 1000 + if seconds >= 60 { + return fmt.Sprintf("%.0fm %.0fs", seconds/60, float64(int(seconds)%60)) + } + return fmt.Sprintf("%.1fs", seconds) +} diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go index 12bdcb3b37d..90965bca4d6 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go @@ -31,6 +31,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newGhUrlParseCommand()) rootCmd.AddCommand(newMetadataCommand()) rootCmd.AddCommand(newAiCommand()) + rootCmd.AddCommand(newCopilotCommand()) return rootCmd } diff --git a/cli/azd/grpc/proto/copilot.proto b/cli/azd/grpc/proto/copilot.proto new file mode 100644 index 00000000000..196536e66cf --- /dev/null +++ b/cli/azd/grpc/proto/copilot.proto @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +syntax = "proto3"; + +package azdext; + +option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; + +import "models.proto"; +import "include/google/protobuf/struct.proto"; + +// CopilotService provides Copilot agent capabilities to extensions. +// Sessions are created lazily on the first SendMessage call and can be +// reused across multiple calls. Sessions run in headless/autopilot mode +// by default when invoked via gRPC, suppressing all console output. +service CopilotService { + // Initialize starts the Copilot client, verifies authentication, and + // resolves model/reasoning configuration. Call before SendMessage to + // warm up the client and validate settings. Idempotent. + rpc Initialize(InitializeCopilotRequest) returns (InitializeCopilotResponse); + + // ListSessions returns available Copilot sessions for a working directory. + rpc ListSessions(ListCopilotSessionsRequest) returns (ListCopilotSessionsResponse); + + // SendMessage sends a prompt to the Copilot agent. On the first call, + // a new session is created using the provided configuration. If session_id + // is set, that existing session is resumed instead. Subsequent calls with + // the returned session_id reuse the same session without re-bootstrapping. + rpc SendMessage(SendCopilotMessageRequest) returns (SendCopilotMessageResponse); + + // GetUsageMetrics returns cumulative usage metrics cached for a session. + rpc GetUsageMetrics(GetCopilotUsageMetricsRequest) returns (GetCopilotUsageMetricsResponse); + + // GetFileChanges returns files created, modified, or deleted during a session. + rpc GetFileChanges(GetCopilotFileChangesRequest) returns (GetCopilotFileChangesResponse); + + // StopSession stops and cleans up a Copilot agent session. + rpc StopSession(StopCopilotSessionRequest) returns (EmptyResponse); + + // GetMessages returns the session event log from the Copilot SDK. + // Each event contains a type, timestamp, and dynamic data as a Struct. + rpc GetMessages(GetCopilotMessagesRequest) returns (GetCopilotMessagesResponse); +} + +// --- Initialize messages --- + +// InitializeCopilotRequest configures and starts the Copilot client. +message InitializeCopilotRequest { + string model = 1; // Model to configure (empty = use existing/default). + string reasoning_effort = 2; // Reasoning effort level (low, medium, high). +} + +// InitializeCopilotResponse returns the resolved configuration. +message InitializeCopilotResponse { + string model = 1; // Resolved model name. + string reasoning_effort = 2; // Resolved reasoning effort level. + bool is_first_run = 3; // True if this was the first configuration. +} + +// --- ListSessions messages --- + +// ListCopilotSessionsRequest requests available sessions for a directory. +message ListCopilotSessionsRequest { + string working_directory = 1; // Directory to list sessions for (empty = cwd). +} + +// ListCopilotSessionsResponse contains available sessions. +message ListCopilotSessionsResponse { + repeated CopilotSessionMetadata sessions = 1; // Available sessions. +} + +// CopilotSessionMetadata describes a previous Copilot session. +message CopilotSessionMetadata { + string session_id = 1; // Unique session identifier. + string modified_time = 2; // Last modified time (RFC3339 format). + string summary = 3; // Optional session summary. +} + +// --- SendMessage messages --- + +// SendCopilotMessageRequest sends a prompt to the agent. +// On the first call, include session configuration fields. If session_id +// is provided, the existing session is resumed. If omitted, a new session +// is created with the provided configuration. +message SendCopilotMessageRequest { + string prompt = 1; // The prompt/message to send. + string session_id = 2; // Optional: session to reuse or resume. + string model = 3; // Optional: model override (first call or resume). + string reasoning_effort = 4; // Optional: reasoning effort (low, medium, high). + string system_message = 5; // Optional: custom system message appended to default. + string mode = 6; // Optional: agent mode (autopilot, interactive, plan). + bool debug = 7; // Optional: enable debug logging. + bool headless = 8; // Set to true for headless mode (suppresses console output). +} + +// SendCopilotMessageResponse contains the result of the message. +message SendCopilotMessageResponse { + string session_id = 1; // Session ID — use in subsequent calls to reuse. + CopilotUsageMetrics usage = 2; // Usage metrics for this message turn. + repeated CopilotFileChange file_changes = 3; // Files changed during this message turn. +} + +// --- Metrics messages --- + +// GetCopilotUsageMetricsRequest requests cached usage metrics for a session. +message GetCopilotUsageMetricsRequest { + string session_id = 1; // Session to get metrics for. +} + +// GetCopilotUsageMetricsResponse contains cumulative usage metrics. +message GetCopilotUsageMetricsResponse { + CopilotUsageMetrics usage = 1; // Cumulative usage metrics for the session. +} + +// CopilotUsageMetrics tracks resource consumption for a Copilot session. +message CopilotUsageMetrics { + string model = 1; // Model used. + double input_tokens = 2; // Total input tokens consumed. + double output_tokens = 3; // Total output tokens consumed. + double total_tokens = 4; // Sum of input + output tokens. + double billing_rate = 5; // Per-request cost multiplier (e.g., 1.0x, 2.0x). + double premium_requests = 6; // Number of premium requests used. + double duration_ms = 7; // Total API duration in milliseconds. +} + +// --- File change messages --- + +// GetCopilotFileChangesRequest requests cached file changes for a session. +message GetCopilotFileChangesRequest { + string session_id = 1; // Session to get file changes for. +} + +// GetCopilotFileChangesResponse contains file changes tracked during the session. +message GetCopilotFileChangesResponse { + repeated CopilotFileChange file_changes = 1; // Files changed during the session. +} + +// CopilotFileChange describes a single file change. +message CopilotFileChange { + string path = 1; // File path (relative to working directory). + CopilotFileChangeType change_type = 2; // Type of change. +} + +// CopilotFileChangeType enumerates the types of file changes. +enum CopilotFileChangeType { + COPILOT_FILE_CHANGE_TYPE_UNSPECIFIED = 0; // Unknown change type. + COPILOT_FILE_CHANGE_TYPE_CREATED = 1; // File was created. + COPILOT_FILE_CHANGE_TYPE_MODIFIED = 2; // File was modified. + COPILOT_FILE_CHANGE_TYPE_DELETED = 3; // File was deleted. +} + +// --- Session cleanup messages --- + +// StopCopilotSessionRequest stops a session and releases resources. +message StopCopilotSessionRequest { + string session_id = 1; // Session to stop. +} + +// --- Session log messages --- + +// GetCopilotMessagesRequest requests the session event log. +message GetCopilotMessagesRequest { + string session_id = 1; // Session to get messages for. +} + +// GetCopilotMessagesResponse contains the session event log. +message GetCopilotMessagesResponse { + repeated CopilotSessionEvent events = 1; // Session events in chronological order. +} + +// CopilotSessionEvent represents a single event from the Copilot session log. +// The data field uses google.protobuf.Struct to carry dynamic event data +// without requiring schema updates when the SDK adds new event types or fields. +message CopilotSessionEvent { + string type = 1; // Event type (e.g., "assistant.message", "tool.execution_complete"). + string timestamp = 2; // ISO 8601 timestamp. + google.protobuf.Struct data = 3; // Full event data as a dynamic struct. +} diff --git a/cli/azd/internal/agent/copilot/cli.go b/cli/azd/internal/agent/copilot/cli.go index 53bbf784b12..75c3d63579f 100644 --- a/cli/azd/internal/agent/copilot/cli.go +++ b/cli/azd/internal/agent/copilot/cli.go @@ -7,6 +7,7 @@ import ( "archive/tar" "compress/gzip" "context" + "encoding/json" "errors" "fmt" "io" @@ -61,6 +62,9 @@ func (c *CopilotCLI) CheckInstalled(ctx context.Context) error { } // ListPlugins returns a map of installed plugin names. +// First tries "copilot plugin list" CLI command. If that returns no results +// (known issue: CLI may not detect plugins installed by a different version), +// falls back to scanning the plugin directory on disk. func (c *CopilotCLI) ListPlugins(ctx context.Context) (map[string]bool, error) { result, err := c.runCommand(ctx, "plugin", "list") if err != nil { @@ -84,9 +88,78 @@ func (c *CopilotCLI) ListPlugins(ctx context.Context) (map[string]bool, error) { } } } + + // WORKAROUND: "copilot plugin list" does not reliably detect plugins + // installed by a different CLI version. When the CLI explicitly reports + // "No plugins installed" but the plugin directory exists on disk, + // fall back to scanning the directory directly. + // TODO: Remove this fallback once the CLI plugin detection is fixed. + if len(installed) == 0 && strings.Contains(result.Stdout, "No plugins installed") { + log.Printf("[copilot-cli] CLI returned no plugins, falling back to directory scan") + return detectPluginsByDirectory(), nil + } + return installed, nil } +// detectPluginsByDirectory scans ~/.copilot/installed-plugins/ for plugin folders +// as a fallback when "copilot plugin list" doesn't detect them. +func detectPluginsByDirectory() map[string]bool { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + + installed := make(map[string]bool) + pluginsRoot := filepath.Join(home, ".copilot", "installed-plugins") + + // Check _direct/ subdirectory + directDir := filepath.Join(pluginsRoot, "_direct") + if entries, err := os.ReadDir(directDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if name := readPluginName(filepath.Join(directDir, entry.Name())); name != "" { + installed[name] = true + } + } + } + + // Check flat layout + if entries, err := os.ReadDir(pluginsRoot); err == nil { + for _, entry := range entries { + if entry.IsDir() && entry.Name() != "_direct" { + installed[entry.Name()] = true + } + } + } + + log.Printf("[copilot-cli] Detected plugins via directory scan: %v", installed) + return installed +} + +// readPluginName reads the "name" field from a plugin's metadata file. +func readPluginName(pluginDir string) string { + for _, metaPath := range []string{ + filepath.Join(pluginDir, ".plugin", "plugin.json"), + filepath.Join(pluginDir, ".claude-plugin", "plugin.json"), + filepath.Join(pluginDir, "plugin.json"), + } { + data, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var meta struct { + Name string `json:"name"` + } + if json.Unmarshal(data, &meta) == nil && meta.Name != "" { + return meta.Name + } + } + return "" +} + // InstallPlugin installs a plugin by source reference. func (c *CopilotCLI) InstallPlugin(ctx context.Context, source string) error { result, err := c.runCommand(ctx, "plugin", "install", source) diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index d2095e68f15..ebe30b0e694 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -12,6 +12,8 @@ import ( "os/exec" "path/filepath" "strings" + "sync" + "sync/atomic" "time" copilot "github.com/github/copilot-sdk/go" @@ -43,14 +45,18 @@ type CopilotAgent struct { systemMessageOverride string mode AgentMode debug bool + headless bool onSessionStarted func(sessionID string) // Runtime state - clientStarted bool - session *copilot.Session - sessionID string - sessionCtx context.Context - display *AgentDisplay // last display for usage metrics + clientStarted bool + session *copilot.Session + sessionID string + activeCtx atomic.Pointer[context.Context] // current SendMessage context for SDK callbacks + display *AgentDisplay // last display for usage metrics (interactive mode) + mu sync.Mutex // guards cumulative metrics and file changes + cumulativeUsage UsageMetrics // cumulative metrics across multiple SendMessage calls + accumulatedFileChanges watch.FileChanges // accumulated file changes across all SendMessage calls // Cleanup — ordered slice for deterministic teardown cleanupTasks []cleanupTask @@ -272,12 +278,37 @@ func (a *CopilotAgent) SendMessage(ctx context.Context, prompt string, opts ...S opt(options) } + // Update active context so SDK callbacks use this call's context + a.activeCtx.Store(&ctx) + // Ensure session exists if err := a.ensureSession(ctx, options.sessionID); err != nil { return nil, err } - // Create display for this message turn + // Determine mode — headless defaults to autopilot + mode := a.mode + if mode == "" { + if a.headless { + mode = AgentModeAutopilot + } else { + mode = AgentModeInteractive + } + } + + log.Printf("[copilot] SendMessage: sending prompt (%d chars, headless=%v)...", len(prompt), a.headless) + + if a.headless { + return a.sendMessageHeadless(ctx, prompt, mode) + } + + return a.sendMessageInteractive(ctx, prompt, mode) +} + +// sendMessageInteractive sends a message with full interactive display and file watcher. +func (a *CopilotAgent) sendMessageInteractive( + ctx context.Context, prompt string, mode AgentMode, +) (*AgentResult, error) { display := NewAgentDisplay(a.console) a.display = display displayCtx, displayCancel := context.WithCancel(ctx) @@ -288,32 +319,64 @@ func (a *CopilotAgent) SendMessage(ctx context.Context, prompt string, opts ...S return nil, err } - var watcher watch.Watcher - watcher, _ = watch.NewWatcher(ctx) + watchCtx, watchCancel := context.WithCancel(ctx) + watcher, watchErr := watch.NewWatcher(watchCtx) + if watchErr != nil { + log.Printf("[copilot] file watcher unavailable: %v", watchErr) + } defer func() { + watchCancel() displayCancel() time.Sleep(100 * time.Millisecond) cleanup() - if watcher != nil { - watcher.PrintChangedFiles(ctx) - } }() - // Subscribe display to session events unsubscribe := a.session.On(display.HandleEvent) defer unsubscribe() - log.Printf("[copilot] SendMessage: sending prompt (%d chars)...", len(prompt)) + _, err = a.session.Send(ctx, copilot.MessageOptions{ + Prompt: prompt, + Mode: string(mode), + }) + if err != nil { + return nil, fmt.Errorf("copilot agent error: %w", err) + } - // Determine mode - mode := a.mode - if mode == "" { - mode = AgentModeInteractive + if err := display.WaitForIdle(ctx); err != nil { + return nil, err } - // Send prompt (non-blocking) - _, err = a.session.Send(ctx, copilot.MessageOptions{ + turnUsage := display.GetUsageMetrics() + a.mu.Lock() + a.accumulateUsage(turnUsage) + turnFileChanges := a.collectFileChanges(watcher) + a.mu.Unlock() + + return &AgentResult{ + SessionID: a.sessionID, + Usage: turnUsage, + FileChanges: turnFileChanges, + }, nil +} + +// sendMessageHeadless sends a message silently using the HeadlessCollector. +func (a *CopilotAgent) sendMessageHeadless( + ctx context.Context, prompt string, mode AgentMode, +) (*AgentResult, error) { + collector := NewHeadlessCollector() + + watchCtx, watchCancel := context.WithCancel(ctx) + watcher, watchErr := watch.NewWatcher(watchCtx) + if watchErr != nil { + log.Printf("[copilot] file watcher unavailable: %v", watchErr) + } + defer watchCancel() + + unsubscribe := a.session.On(collector.HandleEvent) + defer unsubscribe() + + _, err := a.session.Send(ctx, copilot.MessageOptions{ Prompt: prompt, Mode: string(mode), }) @@ -321,17 +384,73 @@ func (a *CopilotAgent) SendMessage(ctx context.Context, prompt string, opts ...S return nil, fmt.Errorf("copilot agent error: %w", err) } - // Wait for idle — display handles all UX rendering - if err := display.WaitForIdle(ctx); err != nil { + if err := collector.WaitForIdle(ctx); err != nil { return nil, err } + turnUsage := collector.GetUsageMetrics() + a.mu.Lock() + a.accumulateUsage(turnUsage) + turnFileChanges := a.collectFileChanges(watcher) + a.mu.Unlock() + return &AgentResult{ - SessionID: a.sessionID, - Usage: display.GetUsageMetrics(), + SessionID: a.sessionID, + Usage: turnUsage, + FileChanges: turnFileChanges, }, nil } +// accumulateUsage adds turn usage to the cumulative total. +func (a *CopilotAgent) accumulateUsage(turn UsageMetrics) { + a.cumulativeUsage.InputTokens += turn.InputTokens + a.cumulativeUsage.OutputTokens += turn.OutputTokens + a.cumulativeUsage.DurationMS += turn.DurationMS + a.cumulativeUsage.PremiumRequests += turn.PremiumRequests + // These are per-request values, not cumulative — use latest + if turn.Model != "" { + a.cumulativeUsage.Model = turn.Model + } + if turn.BillingRate > 0 { + a.cumulativeUsage.BillingRate = turn.BillingRate + } +} + +// GetMetrics returns cumulative session metrics (usage + file changes). +func (a *CopilotAgent) GetMetrics() AgentMetrics { + a.mu.Lock() + defer a.mu.Unlock() + + return AgentMetrics{ + Usage: a.cumulativeUsage, + FileChanges: a.accumulatedFileChanges, + } +} + +// GetMessages returns the session event log from the Copilot SDK. +// Returns an error if no session exists. +func (a *CopilotAgent) GetMessages(ctx context.Context) ([]SessionEvent, error) { + if a.session == nil { + return nil, fmt.Errorf("no active session") + } + + return a.session.GetMessages(ctx) +} + +// collectFileChanges stops the watcher, collects its changes, and appends them +// to the accumulated list. Returns the per-turn changes. +func (a *CopilotAgent) collectFileChanges(watcher watch.Watcher) watch.FileChanges { + if watcher == nil { + return nil + } + + turnChanges := watcher.GetFileChanges() + if len(turnChanges) > 0 { + a.accumulatedFileChanges = append(a.accumulatedFileChanges, turnChanges...) + } + return turnChanges +} + // SendMessageWithRetry wraps SendMessage with interactive retry-on-error UX. func (a *CopilotAgent) SendMessageWithRetry(ctx context.Context, prompt string, opts ...SendOption) (*AgentResult, error) { for { @@ -348,15 +467,24 @@ func (a *CopilotAgent) SendMessageWithRetry(ctx context.Context, prompt string, } } -// Stop terminates the agent and performs cleanup in reverse order. +// Stop terminates the agent, cleans up the Copilot SDK client and runtime process. +// Cleanup tasks run in reverse registration order. Safe to call multiple times. func (a *CopilotAgent) Stop() error { - for i := len(a.cleanupTasks) - 1; i >= 0; i-- { - task := a.cleanupTasks[i] + tasks := a.cleanupTasks + a.cleanupTasks = nil + + var firstErr error + for i := len(tasks) - 1; i >= 0; i-- { + task := tasks[i] if err := task.fn(); err != nil { log.Printf("failed to cleanup %s: %v", task.name, err) + if firstErr == nil { + firstErr = err + } } } - return nil + + return firstErr } // SessionID returns the current session ID, or empty if no session exists. @@ -364,10 +492,11 @@ func (a *CopilotAgent) SessionID() string { return a.sessionID } -// ctx returns the session context for use in permission/consent handlers. -func (a *CopilotAgent) ctx() context.Context { - if a.sessionCtx != nil { - return a.sessionCtx +// activeContext returns the context from the currently executing SendMessage call. +// Used by SDK callbacks (permission, user input) that don't receive a context parameter. +func (a *CopilotAgent) activeContext() context.Context { + if p := a.activeCtx.Load(); p != nil { + return *p } return context.Background() } @@ -391,26 +520,45 @@ func (a *CopilotAgent) ensureClientStarted(ctx context.Context) error { return nil } +// EnsureStarted starts the Copilot SDK client and verifies authentication. +// Call this eagerly to catch startup errors (missing binary, auth failures) +// before entering a chat loop. Idempotent — safe to call multiple times. +func (a *CopilotAgent) EnsureStarted(ctx context.Context) error { + if err := a.ensureClientStarted(ctx); err != nil { + return fmt.Errorf("starting copilot client: %w", err) + } + + if err := a.ensureAuthenticated(ctx); err != nil { + return fmt.Errorf("copilot authentication: %w", err) + } + + return nil +} + // ensureSession creates or resumes a Copilot session if one doesn't exist. +// Uses context.WithoutCancel to prevent the SDK session from being torn down +// when a per-request context (e.g., gRPC) is cancelled between calls. func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string) error { if a.session != nil { return nil } - a.sessionCtx = ctx + // Detach from the caller's cancellation so the client and session + // outlive individual requests (e.g., gRPC calls). + sessionCtx := context.WithoutCancel(ctx) // Start client (extracts bundled CLI to cache if needed) - if err := a.ensureClientStarted(ctx); err != nil { + if err := a.ensureClientStarted(sessionCtx); err != nil { return err } // Check authentication — prompt to sign in if needed - if err := a.ensureAuthenticated(ctx); err != nil { + if err := a.ensureAuthenticated(sessionCtx); err != nil { return err } // Ensure plugins — must run after Start() so the bundled CLI is extracted - a.ensurePlugins(ctx) + a.ensurePlugins(sessionCtx) // Load built-in MCP server configs builtInServers, err := loadBuiltInMCPServers() @@ -419,7 +567,7 @@ func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string } // Build session config - sessionConfig, err := a.sessionConfigBuilder.Build(ctx, builtInServers) + sessionConfig, err := a.sessionConfigBuilder.Build(sessionCtx, builtInServers) if err != nil { return fmt.Errorf("failed to build session config: %w", err) } @@ -454,12 +602,12 @@ func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string SkillDirectories: sessionConfig.SkillDirectories, DisabledSkills: sessionConfig.DisabledSkills, OnPermissionRequest: a.createPermissionHandler(), - OnUserInputRequest: a.createUserInputHandler(ctx), - Hooks: a.createHooks(ctx), + OnUserInputRequest: a.createUserInputHandler(sessionCtx), + Hooks: a.createHooks(sessionCtx), } log.Printf("[copilot] Resuming session %s...", resumeSessionID) - session, err := a.clientManager.Client().ResumeSession(ctx, resumeSessionID, resumeConfig) + session, err := a.clientManager.Client().ResumeSession(sessionCtx, resumeSessionID, resumeConfig) if err != nil { return fmt.Errorf("failed to resume session: %w", err) } @@ -469,13 +617,13 @@ func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string } else { // Create new session sessionConfig.OnPermissionRequest = a.createPermissionHandler() - sessionConfig.OnUserInputRequest = a.createUserInputHandler(ctx) - sessionConfig.Hooks = a.createHooks(ctx) + sessionConfig.OnUserInputRequest = a.createUserInputHandler(sessionCtx) + sessionConfig.Hooks = a.createHooks(sessionCtx) log.Println("[copilot] Creating session...") - session, err := a.clientManager.Client().CreateSession(ctx, sessionConfig) + session, err := a.clientManager.Client().CreateSession(sessionCtx, sessionConfig) if err != nil { - return fmt.Errorf("failed to create session: %w", err) + return fmt.Errorf("creating copilot session: %w", err) } a.session = session a.sessionID = session.SessionID @@ -490,11 +638,18 @@ func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string } // createPermissionHandler builds the OnPermissionRequest handler. -// Routes all permission kinds through the consent manager for unified access control. +// In headless mode, auto-approves all requests. Otherwise routes +// through the consent manager for unified access control. func (a *CopilotAgent) createPermissionHandler() copilot.PermissionHandlerFunc { return func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) ( copilot.PermissionRequestResult, error, ) { + // In headless mode, auto-approve all permission requests + if a.headless { + log.Printf("[copilot] PermissionRequest (headless auto-approve): kind=%s", req.Kind) + return copilot.PermissionRequestResult{Kind: "approved"}, nil + } + server, tool, readOnly := permissionToConsentTarget(req) toolID := fmt.Sprintf("%s/%s", server, tool) @@ -509,7 +664,7 @@ func (a *CopilotAgent) createPermissionHandler() copilot.PermissionHandlerFunc { }, } - decision, err := a.consentManager.CheckConsent(a.ctx(), consentReq) + decision, err := a.consentManager.CheckConsent(a.activeContext(), consentReq) if err != nil { log.Printf("[copilot] Consent check error for %s: %v, denying", toolID, err) return copilot.PermissionRequestResult{Kind: "denied-by-rules"}, nil @@ -532,7 +687,7 @@ func (a *CopilotAgent) createPermissionHandler() copilot.PermissionHandlerFunc { description := buildPermissionDescription(req) promptErr := checker.PromptAndGrantConsent( - a.ctx(), tool, displayName, description, mcp.ToolAnnotation{ReadOnlyHint: &readOnly}, + a.activeContext(), tool, displayName, description, mcp.ToolAnnotation{ReadOnlyHint: &readOnly}, ) if promptErr != nil { @@ -884,8 +1039,12 @@ func (a *CopilotAgent) ensureAuthenticated(ctx context.Context) error { } func (a *CopilotAgent) ensurePlugins(ctx context.Context) { + // Skip plugin management in headless mode — plugins are managed externally + if a.headless { + return + } + // Plugin management requires "copilot" CLI in PATH (the npm-installed version). - // The managed native binary only supports SDK headless mode, not CLI subcommands. if _, err := exec.LookPath("copilot"); err != nil { log.Printf("[copilot] 'copilot' CLI not found in PATH — skipping plugin management") a.console.Message(ctx, output.WithWarningFormat( @@ -906,7 +1065,6 @@ func (a *CopilotAgent) ensurePlugins(ctx context.Context) { continue } - // Prompt user before installing shouldInstall, err := a.promptPluginInstall(ctx, plugin) if err != nil { log.Printf("[copilot] Plugin install prompt failed: %v", err) diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go index ad7d25420cb..02389825631 100644 --- a/cli/azd/internal/agent/copilot_agent_factory.go +++ b/cli/azd/internal/agent/copilot_agent_factory.go @@ -47,7 +47,7 @@ func NewCopilotAgentFactory( consentManager consent.ConsentManager, console input.Console, configManager config.UserConfigManager, -) *CopilotAgentFactory { +) AgentFactory { return &CopilotAgentFactory{ clientManager: clientManager, sessionConfigBuilder: sessionConfigBuilder, @@ -60,7 +60,7 @@ func NewCopilotAgentFactory( // Create builds a new CopilotAgent with all dependencies wired. // Use AgentOption functions to override model, reasoning, mode, etc. -func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...AgentOption) (*CopilotAgent, error) { +func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...AgentOption) (Agent, error) { agent := &CopilotAgent{ clientManager: f.clientManager, sessionConfigBuilder: f.sessionConfigBuilder, diff --git a/cli/azd/internal/agent/headless_collector.go b/cli/azd/internal/agent/headless_collector.go new file mode 100644 index 00000000000..83472474223 --- /dev/null +++ b/cli/azd/internal/agent/headless_collector.go @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent + +import ( + "context" + "log" + "sync" + + copilot "github.com/github/copilot-sdk/go" +) + +// HeadlessCollector silently collects Copilot SDK session events without +// producing any console output. It tracks usage metrics and signals +// completion, making it suitable for gRPC/headless agent sessions. +type HeadlessCollector struct { + mu sync.Mutex + + // Usage metrics — accumulated from assistant.usage events + totalInputTokens float64 + totalOutputTokens float64 + billingRate float64 + totalDurationMS float64 + premiumRequests float64 + lastModel string + + // Lifecycle + messageReceived bool + pendingIdle bool + idleCh chan struct{} +} + +// NewHeadlessCollector creates a new HeadlessCollector. +func NewHeadlessCollector() *HeadlessCollector { + return &HeadlessCollector{ + idleCh: make(chan struct{}, 1), + } +} + +// HandleEvent processes a Copilot session event silently, collecting +// usage metrics and tracking completion state. +func (h *HeadlessCollector) HandleEvent(event copilot.SessionEvent) { + switch event.Type { + case copilot.AssistantTurnStart: + h.mu.Lock() + h.messageReceived = false + h.pendingIdle = false + h.mu.Unlock() + + case copilot.AssistantMessage: + h.mu.Lock() + h.messageReceived = true + wasPendingIdle := h.pendingIdle + h.pendingIdle = false + h.mu.Unlock() + + if wasPendingIdle { + h.signalIdle() + } + + case copilot.AssistantUsage: + h.mu.Lock() + if event.Data.InputTokens != nil { + h.totalInputTokens += *event.Data.InputTokens + } + if event.Data.OutputTokens != nil { + h.totalOutputTokens += *event.Data.OutputTokens + } + if event.Data.Cost != nil { + h.billingRate = *event.Data.Cost + } + if event.Data.Duration != nil { + h.totalDurationMS += *event.Data.Duration + } + if event.Data.Model != nil { + h.lastModel = *event.Data.Model + } + h.mu.Unlock() + + case copilot.SessionUsageInfo, copilot.SessionShutdown: + h.mu.Lock() + if event.Data.TotalPremiumRequests != nil { + h.premiumRequests = *event.Data.TotalPremiumRequests + } + h.mu.Unlock() + + case copilot.SessionIdle: + h.mu.Lock() + hasMessage := h.messageReceived + if !hasMessage { + h.pendingIdle = true + } + h.mu.Unlock() + + log.Printf("[copilot-headless] session.idle (hasMessage=%v)", hasMessage) + + if hasMessage { + h.signalIdle() + } + + case copilot.SessionTaskComplete: + log.Printf("[copilot-headless] %s received, signaling completion", event.Type) + h.signalIdle() + } +} + +// WaitForIdle blocks until the session becomes idle or the context is cancelled. +func (h *HeadlessCollector) WaitForIdle(ctx context.Context) error { + select { + case <-h.idleCh: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// GetUsageMetrics returns the accumulated usage metrics. +func (h *HeadlessCollector) GetUsageMetrics() UsageMetrics { + h.mu.Lock() + defer h.mu.Unlock() + + return UsageMetrics{ + Model: h.lastModel, + InputTokens: h.totalInputTokens, + OutputTokens: h.totalOutputTokens, + BillingRate: h.billingRate, + PremiumRequests: h.premiumRequests, + DurationMS: h.totalDurationMS, + } +} + +func (h *HeadlessCollector) signalIdle() { + select { + case h.idleCh <- struct{}{}: + default: + } +} diff --git a/cli/azd/internal/agent/headless_collector_test.go b/cli/azd/internal/agent/headless_collector_test.go new file mode 100644 index 00000000000..852beac1e96 --- /dev/null +++ b/cli/azd/internal/agent/headless_collector_test.go @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/stretchr/testify/require" +) + +func TestHeadlessCollector_HandleEvent_Usage(t *testing.T) { + t.Parallel() + collector := NewHeadlessCollector() + + inputTokens := float64(100) + outputTokens := float64(50) + cost := float64(1.5) + duration := float64(1000) + model := "gpt-4o" + + collector.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantUsage, + Data: copilot.Data{ + InputTokens: &inputTokens, + OutputTokens: &outputTokens, + Cost: &cost, + Duration: &duration, + Model: &model, + }, + }) + + usage := collector.GetUsageMetrics() + require.Equal(t, float64(100), usage.InputTokens) + require.Equal(t, float64(50), usage.OutputTokens) + require.Equal(t, float64(1.5), usage.BillingRate) + require.Equal(t, float64(1000), usage.DurationMS) + require.Equal(t, "gpt-4o", usage.Model) +} + +func TestHeadlessCollector_HandleEvent_AccumulatesUsage(t *testing.T) { + t.Parallel() + collector := NewHeadlessCollector() + + tokens1 := float64(100) + tokens2 := float64(200) + + collector.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantUsage, + Data: copilot.Data{InputTokens: &tokens1}, + }) + collector.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantUsage, + Data: copilot.Data{InputTokens: &tokens2}, + }) + + usage := collector.GetUsageMetrics() + require.Equal(t, float64(300), usage.InputTokens) +} + +func TestHeadlessCollector_WaitForIdle_WithMessage(t *testing.T) { + t.Parallel() + collector := NewHeadlessCollector() + + // Simulate turn start → message → idle + collector.HandleEvent(copilot.SessionEvent{Type: copilot.AssistantTurnStart}) + collector.HandleEvent(copilot.SessionEvent{Type: copilot.AssistantMessage, Data: copilot.Data{}}) + collector.HandleEvent(copilot.SessionEvent{Type: copilot.SessionIdle}) + + ctx := t.Context() + err := collector.WaitForIdle(ctx) + require.NoError(t, err) +} + +func TestHeadlessCollector_WaitForIdle_TaskComplete(t *testing.T) { + t.Parallel() + collector := NewHeadlessCollector() + + collector.HandleEvent(copilot.SessionEvent{Type: copilot.SessionTaskComplete}) + + ctx := t.Context() + err := collector.WaitForIdle(ctx) + require.NoError(t, err) +} + +func TestHeadlessCollector_WaitForIdle_DeferredIdle(t *testing.T) { + t.Parallel() + collector := NewHeadlessCollector() + + // Idle arrives before message → should be deferred + collector.HandleEvent(copilot.SessionEvent{Type: copilot.AssistantTurnStart}) + collector.HandleEvent(copilot.SessionEvent{Type: copilot.SessionIdle}) + + // Message arrives → should flush deferred idle + collector.HandleEvent(copilot.SessionEvent{Type: copilot.AssistantMessage, Data: copilot.Data{}}) + + ctx := t.Context() + err := collector.WaitForIdle(ctx) + require.NoError(t, err) +} + +func TestHeadlessCollector_PremiumRequests(t *testing.T) { + t.Parallel() + collector := NewHeadlessCollector() + + premium := float64(5) + collector.HandleEvent(copilot.SessionEvent{ + Type: copilot.SessionUsageInfo, + Data: copilot.Data{TotalPremiumRequests: &premium}, + }) + + usage := collector.GetUsageMetrics() + require.Equal(t, float64(5), usage.PremiumRequests) +} diff --git a/cli/azd/internal/agent/types.go b/cli/azd/internal/agent/types.go index a90d26648fa..18a2c9544b7 100644 --- a/cli/azd/internal/agent/types.go +++ b/cli/azd/internal/agent/types.go @@ -4,20 +4,24 @@ package agent import ( + "context" "fmt" "strings" copilot "github.com/github/copilot-sdk/go" "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/watch" ) // AgentResult is returned by SendMessage with session and usage metadata. type AgentResult struct { // SessionID is the session identifier for resuming later. SessionID string - // Usage contains token and cost metrics for the session. + // Usage contains token and cost metrics for this turn. Usage UsageMetrics + // FileChanges contains files created/modified/deleted during this turn. + FileChanges watch.FileChanges } // UsageMetrics tracks resource consumption for an agent session. @@ -35,8 +39,8 @@ func (u UsageMetrics) TotalTokens() float64 { return u.InputTokens + u.OutputTokens } -// Format returns a multi-line formatted string for display. -func (u UsageMetrics) Format() string { +// String returns a multi-line formatted string for display. +func (u UsageMetrics) String() string { if u.InputTokens == 0 && u.OutputTokens == 0 { return "" } @@ -96,6 +100,26 @@ type InitResult struct { IsFirstRun bool } +// AgentMetrics contains cumulative session metrics for usage and file changes. +type AgentMetrics struct { + // Usage contains cumulative token and cost metrics. + Usage UsageMetrics + // FileChanges contains accumulated file changes across all SendMessage calls. + FileChanges watch.FileChanges +} + +// String returns a formatted display of file changes followed by usage metrics. +func (m AgentMetrics) String() string { + var parts []string + if s := m.FileChanges.String(); s != "" { + parts = append(parts, s) + } + if s := m.Usage.String(); s != "" { + parts = append(parts, s) + } + return strings.Join(parts, "\n") +} + // AgentMode represents the operating mode for the agent. type AgentMode string @@ -136,6 +160,13 @@ func WithDebug(debug bool) AgentOption { return func(a *CopilotAgent) { a.debug = debug } } +// WithHeadless enables headless mode, suppressing all console output. +// In headless mode, the agent uses a silent collector instead of the +// interactive display, and defaults to autopilot mode. +func WithHeadless(headless bool) AgentOption { + return func(a *CopilotAgent) { a.headless = headless } +} + // OnSessionStarted registers a callback that fires when a session is created or resumed. func OnSessionStarted(fn func(sessionID string)) AgentOption { return func(a *CopilotAgent) { a.onSessionStarted = fn } @@ -167,3 +198,33 @@ func WithSessionID(id string) SendOption { // SessionMetadata contains metadata about a previous session. type SessionMetadata = copilot.SessionMetadata + +// SessionEvent represents a single event from the Copilot session log. +type SessionEvent = copilot.SessionEvent + +// Agent defines the interface for Copilot agent operations. +// Used by the gRPC service layer to decouple from the concrete CopilotAgent implementation. +type Agent interface { + // Initialize handles first-run configuration (model/reasoning setup). + Initialize(ctx context.Context, opts ...InitOption) (*InitResult, error) + // SendMessage sends a prompt and returns per-turn results. + SendMessage(ctx context.Context, prompt string, opts ...SendOption) (*AgentResult, error) + // SendMessageWithRetry wraps SendMessage with interactive retry-on-error UX. + SendMessageWithRetry(ctx context.Context, prompt string, opts ...SendOption) (*AgentResult, error) + // ListSessions returns previous sessions for the given working directory. + ListSessions(ctx context.Context, cwd string) ([]SessionMetadata, error) + // GetMetrics returns cumulative session metrics (usage + file changes). + GetMetrics() AgentMetrics + // GetMessages returns the session event log from the Copilot SDK. + GetMessages(ctx context.Context) ([]SessionEvent, error) + // SessionID returns the current session ID, or empty if no session exists. + SessionID() string + // Stop terminates the agent and releases resources. + Stop() error +} + +// AgentFactory creates Agent instances with all dependencies wired. +type AgentFactory interface { + // Create builds a new Agent with the given options. + Create(ctx context.Context, opts ...AgentOption) (Agent, error) +} diff --git a/cli/azd/internal/agent/types_test.go b/cli/azd/internal/agent/types_test.go index 21ec88f5619..690025f89bb 100644 --- a/cli/azd/internal/agent/types_test.go +++ b/cli/azd/internal/agent/types_test.go @@ -13,7 +13,7 @@ import ( func TestUsageMetrics_Format(t *testing.T) { t.Run("Empty", func(t *testing.T) { u := UsageMetrics{} - require.Empty(t, u.Format()) + require.Empty(t, u.String()) }) t.Run("BasicTokens", func(t *testing.T) { @@ -21,7 +21,7 @@ func TestUsageMetrics_Format(t *testing.T) { InputTokens: 1500, OutputTokens: 500, } - result := u.Format() + result := u.String() require.Contains(t, result, "1.5K") require.Contains(t, result, "500") require.Contains(t, result, "2.0K") @@ -33,7 +33,7 @@ func TestUsageMetrics_Format(t *testing.T) { InputTokens: 10000, OutputTokens: 5000, } - result := u.Format() + result := u.String() require.Contains(t, result, "claude-sonnet-4.5") }) @@ -44,7 +44,7 @@ func TestUsageMetrics_Format(t *testing.T) { BillingRate: 2.0, PremiumRequests: 15, } - result := u.Format() + result := u.String() require.Contains(t, result, "2x per request") require.Contains(t, result, "15") }) @@ -55,7 +55,7 @@ func TestUsageMetrics_Format(t *testing.T) { OutputTokens: 50, DurationMS: 45000, } - result := u.Format() + result := u.String() require.Contains(t, result, "45.0s") }) @@ -65,7 +65,7 @@ func TestUsageMetrics_Format(t *testing.T) { OutputTokens: 50, DurationMS: 125000, } - result := u.Format() + result := u.String() require.Contains(t, result, "2m") }) } diff --git a/cli/azd/internal/grpcserver/copilot_service.go b/cli/azd/internal/grpcserver/copilot_service.go new file mode 100644 index 00000000000..3180d38e48f --- /dev/null +++ b/cli/azd/internal/grpcserver/copilot_service.go @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "encoding/json" + "log" + "os" + "path/filepath" + "sync" + + "github.com/azure/azure-dev/cli/azd/internal/agent" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/watch" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +// copilotService implements the CopilotServiceServer gRPC interface. +// It is a thin routing layer that maps session IDs to CopilotAgent instances. +// All metrics and file change state lives on the agent itself. +type copilotService struct { + azdext.UnimplementedCopilotServiceServer + + agentFactory agent.AgentFactory + + mu sync.RWMutex + sessions map[string]agent.Agent +} + +// NewCopilotService creates a new CopilotService gRPC server. +func NewCopilotService(agentFactory agent.AgentFactory) azdext.CopilotServiceServer { + return &copilotService{ + agentFactory: agentFactory, + sessions: make(map[string]agent.Agent), + } +} + +// Initialize starts the Copilot client, verifies authentication, and resolves +// model/reasoning configuration. Does not create a session. Idempotent. +func (s *copilotService) Initialize( + ctx context.Context, req *azdext.InitializeCopilotRequest, +) (*azdext.InitializeCopilotResponse, error) { + var opts []agent.AgentOption + if req.Model != "" { + opts = append(opts, agent.WithModel(req.Model)) + } + if req.ReasoningEffort != "" { + opts = append(opts, agent.WithReasoningEffort(req.ReasoningEffort)) + } + + tempAgent, err := s.agentFactory.Create(ctx, opts...) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create agent: %v", err) + } + defer tempAgent.Stop() + + result, err := tempAgent.Initialize(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to initialize copilot: %v", err) + } + + return &azdext.InitializeCopilotResponse{ + Model: result.Model, + ReasoningEffort: result.ReasoningEffort, + IsFirstRun: result.IsFirstRun, + }, nil +} + +// ListSessions returns available Copilot sessions for a working directory. +func (s *copilotService) ListSessions( + ctx context.Context, req *azdext.ListCopilotSessionsRequest, +) (*azdext.ListCopilotSessionsResponse, error) { + cwd := req.WorkingDirectory + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return nil, status.Errorf(codes.Internal, + "failed to get working directory: %v", err) + } + } + + tempAgent, err := s.agentFactory.Create(ctx, agent.WithHeadless(true)) + if err != nil { + return nil, status.Errorf(codes.Internal, + "failed to create agent for listing sessions: %v", err) + } + defer tempAgent.Stop() + + sessions, err := tempAgent.ListSessions(ctx, cwd) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list sessions: %v", err) + } + + protoSessions := make([]*azdext.CopilotSessionMetadata, len(sessions)) + for i, session := range sessions { + summary := "" + if session.Summary != nil { + summary = *session.Summary + } + protoSessions[i] = &azdext.CopilotSessionMetadata{ + SessionId: session.SessionID, + ModifiedTime: session.ModifiedTime, + Summary: summary, + } + } + + return &azdext.ListCopilotSessionsResponse{Sessions: protoSessions}, nil +} + +// SendMessage sends a prompt to the Copilot agent. On the first call a new +// session is created using the provided configuration. If session_id is set +// and matches a managed session, that session is reused. If session_id is set +// but not found, it is treated as an SDK session ID to resume. +func (s *copilotService) SendMessage( + ctx context.Context, req *azdext.SendCopilotMessageRequest, +) (*azdext.SendCopilotMessageResponse, error) { + if req.Prompt == "" { + return nil, status.Error(codes.InvalidArgument, "prompt cannot be empty") + } + + copilotAgent, isNew, isResume, err := s.resolveOrCreateAgent(ctx, req) + if err != nil { + return nil, err + } + + var sendOpts []agent.SendOption + if isResume { + sendOpts = append(sendOpts, agent.WithSessionID(req.SessionId)) + } + + result, err := copilotAgent.SendMessage(ctx, req.Prompt, sendOpts...) + if err != nil { + // Clean up newly created agents that failed on first message + if isNew { + copilotAgent.Stop() + } + return nil, status.Errorf(codes.Internal, "copilot agent error: %v", err) + } + + // Store the agent by its SDK session ID for reuse + sessionID := result.SessionID + s.mu.Lock() + s.sessions[sessionID] = copilotAgent + s.mu.Unlock() + + return &azdext.SendCopilotMessageResponse{ + SessionId: sessionID, + Usage: convertUsageMetrics(result.Usage), + FileChanges: convertFileChanges(result.FileChanges), + }, nil +} + +// resolveOrCreateAgent finds an existing managed agent, creates a new one, +// or prepares one for SDK session resumption. +func (s *copilotService) resolveOrCreateAgent( + ctx context.Context, req *azdext.SendCopilotMessageRequest, +) (copilotAgent agent.Agent, isNew bool, isResume bool, err error) { + if req.SessionId != "" { + // Try to reuse an existing managed session + s.mu.RLock() + existing, ok := s.sessions[req.SessionId] + s.mu.RUnlock() + + if ok { + return existing, false, false, nil + } + + // Not in our map — treat as an SDK session ID to resume + isResume = true + } + + // Create a new agent + opts := buildAgentOptions( + req.Model, req.ReasoningEffort, req.SystemMessage, + req.Mode, req.Debug, req.Headless, + ) + + copilotAgent, err = s.agentFactory.Create(ctx, opts...) + if err != nil { + return nil, false, false, status.Errorf(codes.Internal, + "failed to create copilot agent: %v", err) + } + + return copilotAgent, true, isResume, nil +} + +// GetUsageMetrics returns cumulative usage metrics cached on the agent. +func (s *copilotService) GetUsageMetrics( + ctx context.Context, req *azdext.GetCopilotUsageMetricsRequest, +) (*azdext.GetCopilotUsageMetricsResponse, error) { + copilotAgent, err := s.getAgent(req.SessionId) + if err != nil { + return nil, err + } + + metrics := copilotAgent.GetMetrics() + return &azdext.GetCopilotUsageMetricsResponse{ + Usage: convertUsageMetrics(metrics.Usage), + }, nil +} + +// GetFileChanges returns accumulated file changes cached on the agent. +func (s *copilotService) GetFileChanges( + ctx context.Context, req *azdext.GetCopilotFileChangesRequest, +) (*azdext.GetCopilotFileChangesResponse, error) { + copilotAgent, err := s.getAgent(req.SessionId) + if err != nil { + return nil, err + } + + metrics := copilotAgent.GetMetrics() + return &azdext.GetCopilotFileChangesResponse{ + FileChanges: convertFileChanges(metrics.FileChanges), + }, nil +} + +// GetMessages returns the session event log from the Copilot SDK. +func (s *copilotService) GetMessages( + ctx context.Context, req *azdext.GetCopilotMessagesRequest, +) (*azdext.GetCopilotMessagesResponse, error) { + copilotAgent, err := s.getAgent(req.SessionId) + if err != nil { + return nil, err + } + + events, err := copilotAgent.GetMessages(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get messages: %v", err) + } + + protoEvents := make([]*azdext.CopilotSessionEvent, len(events)) + for i, event := range events { + protoEvents[i] = convertSessionEvent(event) + } + + return &azdext.GetCopilotMessagesResponse{Events: protoEvents}, nil +} + +// StopSession stops and cleans up a Copilot agent session. +func (s *copilotService) StopSession( + ctx context.Context, req *azdext.StopCopilotSessionRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + copilotAgent, ok := s.sessions[req.SessionId] + if ok { + delete(s.sessions, req.SessionId) + } + s.mu.Unlock() + + if !ok { + return nil, status.Errorf(codes.NotFound, "session %q not found", req.SessionId) + } + + if err := copilotAgent.Stop(); err != nil { + log.Printf("[copilot-service] session %q stop error: %v", req.SessionId, err) + } + return &azdext.EmptyResponse{}, nil +} + +// getAgent retrieves a managed agent by session ID. +func (s *copilotService) getAgent(sessionID string) (agent.Agent, error) { + if sessionID == "" { + return nil, status.Error(codes.InvalidArgument, "session_id is required") + } + + s.mu.RLock() + defer s.mu.RUnlock() + + copilotAgent, ok := s.sessions[sessionID] + if !ok { + return nil, status.Errorf(codes.NotFound, "session %q not found", sessionID) + } + + return copilotAgent, nil +} + +// buildAgentOptions constructs AgentOption slice from request fields. +func buildAgentOptions( + model, reasoningEffort, systemMessage, mode string, debug, headless bool, +) []agent.AgentOption { + opts := []agent.AgentOption{ + agent.WithHeadless(headless), + } + if model != "" { + opts = append(opts, agent.WithModel(model)) + } + if reasoningEffort != "" { + opts = append(opts, agent.WithReasoningEffort(reasoningEffort)) + } + if systemMessage != "" { + opts = append(opts, agent.WithSystemMessage(systemMessage)) + } + if mode != "" { + opts = append(opts, agent.WithMode(agent.AgentMode(mode))) + } + if debug { + opts = append(opts, agent.WithDebug(true)) + } + return opts +} + +// convertUsageMetrics converts internal UsageMetrics to the proto representation. +func convertUsageMetrics(usage agent.UsageMetrics) *azdext.CopilotUsageMetrics { + return &azdext.CopilotUsageMetrics{ + Model: usage.Model, + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens(), + BillingRate: usage.BillingRate, + PremiumRequests: usage.PremiumRequests, + DurationMs: usage.DurationMS, + } +} + +// convertFileChanges converts internal FileChanges to the proto representation. +func convertFileChanges(changes []watch.FileChange) []*azdext.CopilotFileChange { + if len(changes) == 0 { + return nil + } + + cwd, _ := os.Getwd() + protoChanges := make([]*azdext.CopilotFileChange, len(changes)) + + for i, change := range changes { + path := change.Path + if cwd != "" { + if rel, err := filepath.Rel(cwd, change.Path); err == nil { + path = rel + } + } + protoChanges[i] = &azdext.CopilotFileChange{ + Path: path, + ChangeType: convertFileChangeType(change.ChangeType), + } + } + + return protoChanges +} + +// convertFileChangeType converts internal FileChangeType to the proto enum. +func convertFileChangeType(ct watch.FileChangeType) azdext.CopilotFileChangeType { + switch ct { + case watch.FileCreated: + return azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_CREATED + case watch.FileModified: + return azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_MODIFIED + case watch.FileDeleted: + return azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_DELETED + default: + return azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_UNSPECIFIED + } +} + +// convertSessionEvent converts a Copilot SDK SessionEvent to the proto representation. +// Event data is marshaled to JSON then converted to google.protobuf.Struct for +// dynamic, schema-free transport. +func convertSessionEvent(event agent.SessionEvent) *azdext.CopilotSessionEvent { + protoEvent := &azdext.CopilotSessionEvent{ + Type: string(event.Type), + Timestamp: event.Timestamp.Format("2006-01-02T15:04:05.000Z"), + } + + // Marshal event.Data to JSON, then to protobuf Struct + jsonBytes, err := json.Marshal(event.Data) + if err != nil { + log.Printf("[copilot-service] failed to marshal event data: %v", err) + return protoEvent + } + + var dataMap map[string]any + if err := json.Unmarshal(jsonBytes, &dataMap); err != nil { + log.Printf("[copilot-service] failed to unmarshal event data to map: %v", err) + return protoEvent + } + + protoStruct, err := structpb.NewStruct(dataMap) + if err != nil { + log.Printf("[copilot-service] failed to create protobuf struct: %v", err) + return protoEvent + } + + protoEvent.Data = protoStruct + return protoEvent +} diff --git a/cli/azd/internal/grpcserver/copilot_service_test.go b/cli/azd/internal/grpcserver/copilot_service_test.go new file mode 100644 index 00000000000..4e2f63bc05d --- /dev/null +++ b/cli/azd/internal/grpcserver/copilot_service_test.go @@ -0,0 +1,548 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/internal/agent" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/watch" + copilot "github.com/github/copilot-sdk/go" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" +) + +// --- Mocks --- + +type MockAgent struct { + mock.Mock +} + +func (m *MockAgent) Initialize(ctx context.Context, opts ...agent.InitOption) (*agent.InitResult, error) { + args := m.Called(ctx, opts) + if result := args.Get(0); result != nil { + return result.(*agent.InitResult), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockAgent) SendMessage( + ctx context.Context, prompt string, opts ...agent.SendOption, +) (*agent.AgentResult, error) { + args := m.Called(ctx, prompt, opts) + if result := args.Get(0); result != nil { + return result.(*agent.AgentResult), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockAgent) SendMessageWithRetry( + ctx context.Context, prompt string, opts ...agent.SendOption, +) (*agent.AgentResult, error) { + args := m.Called(ctx, prompt, opts) + if result := args.Get(0); result != nil { + return result.(*agent.AgentResult), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockAgent) ListSessions(ctx context.Context, cwd string) ([]agent.SessionMetadata, error) { + args := m.Called(ctx, cwd) + if result := args.Get(0); result != nil { + return result.([]agent.SessionMetadata), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockAgent) GetMetrics() agent.AgentMetrics { + args := m.Called() + return args.Get(0).(agent.AgentMetrics) +} + +func (m *MockAgent) GetMessages(ctx context.Context) ([]agent.SessionEvent, error) { + args := m.Called(ctx) + if result := args.Get(0); result != nil { + return result.([]agent.SessionEvent), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockAgent) SessionID() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAgent) Stop() error { + args := m.Called() + return args.Error(0) +} + +type MockAgentFactory struct { + mock.Mock +} + +func (m *MockAgentFactory) Create(ctx context.Context, opts ...agent.AgentOption) (agent.Agent, error) { + args := m.Called(ctx, opts) + if result := args.Get(0); result != nil { + return result.(agent.Agent), args.Error(1) + } + return nil, args.Error(1) +} + +// --- Tests --- + +func TestCopilotService_SendMessage_NewSession(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("SendMessage", mock.Anything, "hello", mock.Anything).Return(&agent.AgentResult{ + SessionID: "sdk-session-123", + Usage: agent.UsageMetrics{Model: "gpt-4o", InputTokens: 100, OutputTokens: 50}, + }, nil) + + svc := NewCopilotService(factory) + ctx := t.Context() + + resp, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "hello", + Headless: true, + }) + + require.NoError(t, err) + require.Equal(t, "sdk-session-123", resp.SessionId) + require.Equal(t, "gpt-4o", resp.Usage.Model) + require.Equal(t, float64(100), resp.Usage.InputTokens) + require.Equal(t, float64(50), resp.Usage.OutputTokens) + require.Equal(t, float64(150), resp.Usage.TotalTokens) + + factory.AssertExpectations(t) + mockAgent.AssertExpectations(t) +} + +func TestCopilotService_SendMessage_ReuseSession(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil).Once() + mockAgent.On("SendMessage", mock.Anything, "first", mock.Anything).Return(&agent.AgentResult{ + SessionID: "sdk-session-456", + Usage: agent.UsageMetrics{InputTokens: 50}, + }, nil).Once() + mockAgent.On("SendMessage", mock.Anything, "second", mock.Anything).Return(&agent.AgentResult{ + SessionID: "sdk-session-456", + Usage: agent.UsageMetrics{InputTokens: 75}, + }, nil).Once() + + svc := NewCopilotService(factory) + ctx := t.Context() + + // First call — creates session + resp1, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "first", + }) + require.NoError(t, err) + require.Equal(t, "sdk-session-456", resp1.SessionId) + + // Second call — reuses session (no new Create call) + resp2, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "second", + SessionId: "sdk-session-456", + }) + require.NoError(t, err) + require.Equal(t, "sdk-session-456", resp2.SessionId) + + // Factory.Create should only be called once + factory.AssertNumberOfCalls(t, "Create", 1) +} + +func TestCopilotService_SendMessage_ResumeSDKSession(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("SendMessage", mock.Anything, "resuming", mock.Anything).Return(&agent.AgentResult{ + SessionID: "external-sdk-id", + Usage: agent.UsageMetrics{InputTokens: 200}, + }, nil) + + svc := NewCopilotService(factory) + ctx := t.Context() + + // Pass an unknown session_id — treated as SDK session to resume + resp, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "resuming", + SessionId: "external-sdk-id", + }) + + require.NoError(t, err) + require.Equal(t, "external-sdk-id", resp.SessionId) + factory.AssertExpectations(t) +} + +func TestCopilotService_SendMessage_EmptyPrompt(t *testing.T) { + t.Parallel() + svc := NewCopilotService(&MockAgentFactory{}) + ctx := t.Context() + + _, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "prompt cannot be empty") +} + +func TestCopilotService_GetUsageMetrics_ValidSession(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("SendMessage", mock.Anything, "test", mock.Anything).Return(&agent.AgentResult{ + SessionID: "metrics-session", + Usage: agent.UsageMetrics{InputTokens: 100}, + }, nil) + mockAgent.On("GetMetrics").Return(agent.AgentMetrics{ + Usage: agent.UsageMetrics{ + Model: "gpt-4o", InputTokens: 500, OutputTokens: 250, DurationMS: 3000, + }, + }) + + svc := NewCopilotService(factory) + ctx := t.Context() + + // Create a session first + _, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{Prompt: "test"}) + require.NoError(t, err) + + // Get metrics + resp, err := svc.GetUsageMetrics(ctx, &azdext.GetCopilotUsageMetricsRequest{ + SessionId: "metrics-session", + }) + + require.NoError(t, err) + require.Equal(t, "gpt-4o", resp.Usage.Model) + require.Equal(t, float64(500), resp.Usage.InputTokens) + require.Equal(t, float64(250), resp.Usage.OutputTokens) + require.Equal(t, float64(3000), resp.Usage.DurationMs) +} + +func TestCopilotService_GetUsageMetrics_UnknownSession(t *testing.T) { + t.Parallel() + svc := NewCopilotService(&MockAgentFactory{}) + ctx := t.Context() + + _, err := svc.GetUsageMetrics(ctx, &azdext.GetCopilotUsageMetricsRequest{ + SessionId: "nonexistent", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +func TestCopilotService_GetFileChanges_ValidSession(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("SendMessage", mock.Anything, "test", mock.Anything).Return(&agent.AgentResult{ + SessionID: "files-session", + }, nil) + mockAgent.On("GetMetrics").Return(agent.AgentMetrics{ + FileChanges: watch.FileChanges{ + {Path: "main.go", ChangeType: watch.FileModified}, + {Path: "new.txt", ChangeType: watch.FileCreated}, + }, + }) + + svc := NewCopilotService(factory) + ctx := t.Context() + + _, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{Prompt: "test"}) + require.NoError(t, err) + + resp, err := svc.GetFileChanges(ctx, &azdext.GetCopilotFileChangesRequest{ + SessionId: "files-session", + }) + + require.NoError(t, err) + require.Len(t, resp.FileChanges, 2) + require.Equal(t, azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_MODIFIED, + resp.FileChanges[0].ChangeType) + require.Equal(t, azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_CREATED, + resp.FileChanges[1].ChangeType) +} + +func TestCopilotService_StopSession_Valid(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("SendMessage", mock.Anything, "test", mock.Anything).Return(&agent.AgentResult{ + SessionID: "stop-session", + }, nil) + mockAgent.On("Stop").Return(nil) + + svc := NewCopilotService(factory) + ctx := t.Context() + + _, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{Prompt: "test"}) + require.NoError(t, err) + + _, err = svc.StopSession(ctx, &azdext.StopCopilotSessionRequest{ + SessionId: "stop-session", + }) + + require.NoError(t, err) + mockAgent.AssertCalled(t, "Stop") + + // Session should be gone now + _, err = svc.GetUsageMetrics(ctx, &azdext.GetCopilotUsageMetricsRequest{ + SessionId: "stop-session", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +func TestCopilotService_StopSession_UnknownSession(t *testing.T) { + t.Parallel() + svc := NewCopilotService(&MockAgentFactory{}) + ctx := t.Context() + + _, err := svc.StopSession(ctx, &azdext.StopCopilotSessionRequest{ + SessionId: "nonexistent", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +func TestCopilotService_Initialize(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("Initialize", mock.Anything, mock.Anything).Return(&agent.InitResult{ + Model: "gpt-4o", + ReasoningEffort: "medium", + IsFirstRun: true, + }, nil) + mockAgent.On("Stop").Return(nil) + + svc := NewCopilotService(factory) + ctx := t.Context() + + resp, err := svc.Initialize(ctx, &azdext.InitializeCopilotRequest{ + Model: "gpt-4o", + ReasoningEffort: "medium", + }) + + require.NoError(t, err) + require.Equal(t, "gpt-4o", resp.Model) + require.Equal(t, "medium", resp.ReasoningEffort) + require.True(t, resp.IsFirstRun) +} + +func TestCopilotService_GetUsageMetrics_EmptySessionId(t *testing.T) { + t.Parallel() + svc := NewCopilotService(&MockAgentFactory{}) + ctx := t.Context() + + _, err := svc.GetUsageMetrics(ctx, &azdext.GetCopilotUsageMetricsRequest{ + SessionId: "", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "session_id is required") +} + +func TestCopilotService_SendMessage_WithFileChanges(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("SendMessage", mock.Anything, "make changes", mock.Anything).Return(&agent.AgentResult{ + SessionID: "fc-session", + Usage: agent.UsageMetrics{InputTokens: 100}, + FileChanges: []watch.FileChange{ + {Path: "created.go", ChangeType: watch.FileCreated}, + {Path: "deleted.go", ChangeType: watch.FileDeleted}, + }, + }, nil) + + svc := NewCopilotService(factory) + ctx := t.Context() + + resp, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + Prompt: "make changes", + }) + + require.NoError(t, err) + require.Len(t, resp.FileChanges, 2) + require.Equal(t, azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_CREATED, + resp.FileChanges[0].ChangeType) + require.Equal(t, azdext.CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_DELETED, + resp.FileChanges[1].ChangeType) +} + +func TestCopilotService_GetMessages_ValidSession(t *testing.T) { + t.Parallel() + factory := &MockAgentFactory{} + mockAgent := &MockAgent{} + + now := time.Now() + content := "Hello, I can help with that." + toolName := "read_file" + + factory.On("Create", mock.Anything, mock.Anything).Return(mockAgent, nil) + mockAgent.On("SendMessage", mock.Anything, "test", mock.Anything).Return(&agent.AgentResult{ + SessionID: "msg-session", + }, nil) + mockAgent.On("GetMessages", mock.Anything).Return([]agent.SessionEvent{ + { + Type: copilot.AssistantMessage, + Timestamp: now, + Data: copilot.Data{Content: &content}, + }, + { + Type: copilot.ToolExecutionStart, + Timestamp: now.Add(time.Second), + Data: copilot.Data{ToolName: &toolName}, + }, + }, nil) + + svc := NewCopilotService(factory) + ctx := t.Context() + + _, err := svc.SendMessage(ctx, &azdext.SendCopilotMessageRequest{Prompt: "test"}) + require.NoError(t, err) + + resp, err := svc.GetMessages(ctx, &azdext.GetCopilotMessagesRequest{ + SessionId: "msg-session", + }) + + require.NoError(t, err) + require.Len(t, resp.Events, 2) + require.Equal(t, "assistant.message", resp.Events[0].Type) + require.Equal(t, "tool.execution_start", resp.Events[1].Type) + + // Verify data struct contains the content field + contentVal := resp.Events[0].Data.Fields["content"].GetStringValue() + require.Equal(t, "Hello, I can help with that.", contentVal) + + toolVal := resp.Events[1].Data.Fields["toolName"].GetStringValue() + require.Equal(t, "read_file", toolVal) +} + +func TestCopilotService_GetMessages_UnknownSession(t *testing.T) { + t.Parallel() + svc := NewCopilotService(&MockAgentFactory{}) + ctx := t.Context() + + _, err := svc.GetMessages(ctx, &azdext.GetCopilotMessagesRequest{ + SessionId: "nonexistent", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +func TestCopilotService_GetMessages_RoundTrip(t *testing.T) { + t.Parallel() + + // Build realistic SDK events with multiple data fields + content := "I'll create the infrastructure files for your app." + model := "gpt-4o" + inputTokens := float64(500) + outputTokens := float64(200) + toolName := "write" + filePath := "infra/main.bicep" + intent := "Creating infrastructure" + + originalEvents := []agent.SessionEvent{ + { + ID: "evt-1", + Type: copilot.AssistantMessage, + Timestamp: time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC), + Data: copilot.Data{Content: &content}, + }, + { + ID: "evt-2", + Type: copilot.AssistantUsage, + Timestamp: time.Date(2026, 3, 18, 12, 0, 1, 0, time.UTC), + Data: copilot.Data{ + Model: &model, + InputTokens: &inputTokens, + OutputTokens: &outputTokens, + }, + }, + { + ID: "evt-3", + Type: copilot.ToolExecutionStart, + Timestamp: time.Date(2026, 3, 18, 12, 0, 2, 0, time.UTC), + Data: copilot.Data{ + ToolName: &toolName, + Path: &filePath, + Intent: &intent, + }, + }, + } + + // Convert to proto (simulating gRPC transport) + protoEvents := make([]*azdext.CopilotSessionEvent, len(originalEvents)) + for i, event := range originalEvents { + protoEvents[i] = convertSessionEvent(event) + } + + // Verify proto types and timestamps + require.Equal(t, "assistant.message", protoEvents[0].Type) + require.Equal(t, "assistant.usage", protoEvents[1].Type) + require.Equal(t, "tool.execution_start", protoEvents[2].Type) + + // Round-trip: convert proto Struct back to SDK Data type + for i, protoEvent := range protoEvents { + // Marshal proto Struct to JSON + jsonBytes, err := protojson.Marshal(protoEvent.Data) + require.NoError(t, err, "failed to marshal proto struct for event %d", i) + + // Unmarshal JSON into SDK Data type + var roundTripped copilot.Data + err = json.Unmarshal(jsonBytes, &roundTripped) + require.NoError(t, err, "failed to unmarshal to SDK Data for event %d", i) + + // Verify the round-tripped data matches the original + switch originalEvents[i].Type { + case copilot.AssistantMessage: + require.NotNil(t, roundTripped.Content) + require.Equal(t, *originalEvents[i].Data.Content, *roundTripped.Content) + + case copilot.AssistantUsage: + require.NotNil(t, roundTripped.Model) + require.Equal(t, *originalEvents[i].Data.Model, *roundTripped.Model) + require.NotNil(t, roundTripped.InputTokens) + require.Equal(t, *originalEvents[i].Data.InputTokens, *roundTripped.InputTokens) + require.NotNil(t, roundTripped.OutputTokens) + require.Equal(t, *originalEvents[i].Data.OutputTokens, *roundTripped.OutputTokens) + + case copilot.ToolExecutionStart: + require.NotNil(t, roundTripped.ToolName) + require.Equal(t, *originalEvents[i].Data.ToolName, *roundTripped.ToolName) + require.NotNil(t, roundTripped.Path) + require.Equal(t, *originalEvents[i].Data.Path, *roundTripped.Path) + } + } +} diff --git a/cli/azd/internal/grpcserver/prompt_service_test.go b/cli/azd/internal/grpcserver/prompt_service_test.go index 5062e8f7801..b59c7391ae2 100644 --- a/cli/azd/internal/grpcserver/prompt_service_test.go +++ b/cli/azd/internal/grpcserver/prompt_service_test.go @@ -564,6 +564,7 @@ func setupTestServer(t *testing.T, promptSvc azdext.PromptServiceServer) ( azdext.UnimplementedContainerServiceServer{}, azdext.UnimplementedAccountServiceServer{}, azdext.UnimplementedAiModelServiceServer{}, + azdext.UnimplementedCopilotServiceServer{}, ) serverInfo, err := server.Start() diff --git a/cli/azd/internal/grpcserver/server.go b/cli/azd/internal/grpcserver/server.go index ef3965683ba..9df462f2d39 100644 --- a/cli/azd/internal/grpcserver/server.go +++ b/cli/azd/internal/grpcserver/server.go @@ -43,6 +43,7 @@ type Server struct { containerService azdext.ContainerServiceServer accountService azdext.AccountServiceServer aiModelService azdext.AiModelServiceServer + copilotService azdext.CopilotServiceServer } func NewServer( @@ -60,6 +61,7 @@ func NewServer( containerService azdext.ContainerServiceServer, accountService azdext.AccountServiceServer, aiModelService azdext.AiModelServiceServer, + copilotService azdext.CopilotServiceServer, ) *Server { return &Server{ projectService: projectService, @@ -76,6 +78,7 @@ func NewServer( containerService: containerService, accountService: accountService, aiModelService: aiModelService, + copilotService: copilotService, } } @@ -122,6 +125,7 @@ func (s *Server) Start() (*ServerInfo, error) { azdext.RegisterContainerServiceServer(s.grpcServer, s.containerService) azdext.RegisterAccountServiceServer(s.grpcServer, s.accountService) azdext.RegisterAiModelServiceServer(s.grpcServer, s.aiModelService) + azdext.RegisterCopilotServiceServer(s.grpcServer, s.copilotService) serverInfo.Address = fmt.Sprintf("localhost:%d", randomPort) serverInfo.Port = randomPort diff --git a/cli/azd/internal/grpcserver/server_test.go b/cli/azd/internal/grpcserver/server_test.go index 80758d7ac6e..8416d3a603b 100644 --- a/cli/azd/internal/grpcserver/server_test.go +++ b/cli/azd/internal/grpcserver/server_test.go @@ -40,6 +40,7 @@ func Test_Server_Start(t *testing.T) { azdext.UnimplementedContainerServiceServer{}, azdext.UnimplementedAccountServiceServer{}, azdext.UnimplementedAiModelServiceServer{}, + azdext.UnimplementedCopilotServiceServer{}, ) serverInfo, err := server.Start() @@ -126,6 +127,7 @@ func Test_Server_StreamInterceptor(t *testing.T) { azdext.UnimplementedContainerServiceServer{}, azdext.UnimplementedAccountServiceServer{}, azdext.UnimplementedAiModelServiceServer{}, + azdext.UnimplementedCopilotServiceServer{}, ) serverInfo, err := server.Start() diff --git a/cli/azd/pkg/azdext/azd_client.go b/cli/azd/pkg/azdext/azd_client.go index 93ff3dd23c5..12425799322 100644 --- a/cli/azd/pkg/azdext/azd_client.go +++ b/cli/azd/pkg/azdext/azd_client.go @@ -33,6 +33,7 @@ type AzdClient struct { containerClient ContainerServiceClient accountClient AccountServiceClient aiClient AiModelServiceClient + copilotClient CopilotServiceClient } // WithAddress sets the address of the `azd` gRPC server. @@ -231,3 +232,12 @@ func (c *AzdClient) Ai() AiModelServiceClient { return c.aiClient } + +// Copilot returns the Copilot agent service client. +func (c *AzdClient) Copilot() CopilotServiceClient { + if c.copilotClient == nil { + c.copilotClient = NewCopilotServiceClient(c.connection) + } + + return c.copilotClient +} diff --git a/cli/azd/pkg/azdext/copilot.pb.go b/cli/azd/pkg/azdext/copilot.pb.go new file mode 100644 index 00000000000..94063e6d908 --- /dev/null +++ b/cli/azd/pkg/azdext/copilot.pb.go @@ -0,0 +1,1212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v6.32.1 +// source: copilot.proto + +package azdext + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// CopilotFileChangeType enumerates the types of file changes. +type CopilotFileChangeType int32 + +const ( + CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_UNSPECIFIED CopilotFileChangeType = 0 // Unknown change type. + CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_CREATED CopilotFileChangeType = 1 // File was created. + CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_MODIFIED CopilotFileChangeType = 2 // File was modified. + CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_DELETED CopilotFileChangeType = 3 // File was deleted. +) + +// Enum value maps for CopilotFileChangeType. +var ( + CopilotFileChangeType_name = map[int32]string{ + 0: "COPILOT_FILE_CHANGE_TYPE_UNSPECIFIED", + 1: "COPILOT_FILE_CHANGE_TYPE_CREATED", + 2: "COPILOT_FILE_CHANGE_TYPE_MODIFIED", + 3: "COPILOT_FILE_CHANGE_TYPE_DELETED", + } + CopilotFileChangeType_value = map[string]int32{ + "COPILOT_FILE_CHANGE_TYPE_UNSPECIFIED": 0, + "COPILOT_FILE_CHANGE_TYPE_CREATED": 1, + "COPILOT_FILE_CHANGE_TYPE_MODIFIED": 2, + "COPILOT_FILE_CHANGE_TYPE_DELETED": 3, + } +) + +func (x CopilotFileChangeType) Enum() *CopilotFileChangeType { + p := new(CopilotFileChangeType) + *p = x + return p +} + +func (x CopilotFileChangeType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CopilotFileChangeType) Descriptor() protoreflect.EnumDescriptor { + return file_copilot_proto_enumTypes[0].Descriptor() +} + +func (CopilotFileChangeType) Type() protoreflect.EnumType { + return &file_copilot_proto_enumTypes[0] +} + +func (x CopilotFileChangeType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CopilotFileChangeType.Descriptor instead. +func (CopilotFileChangeType) EnumDescriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{0} +} + +// InitializeCopilotRequest configures and starts the Copilot client. +type InitializeCopilotRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` // Model to configure (empty = use existing/default). + ReasoningEffort string `protobuf:"bytes,2,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Reasoning effort level (low, medium, high). + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InitializeCopilotRequest) Reset() { + *x = InitializeCopilotRequest{} + mi := &file_copilot_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InitializeCopilotRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InitializeCopilotRequest) ProtoMessage() {} + +func (x *InitializeCopilotRequest) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InitializeCopilotRequest.ProtoReflect.Descriptor instead. +func (*InitializeCopilotRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{0} +} + +func (x *InitializeCopilotRequest) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *InitializeCopilotRequest) GetReasoningEffort() string { + if x != nil { + return x.ReasoningEffort + } + return "" +} + +// InitializeCopilotResponse returns the resolved configuration. +type InitializeCopilotResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` // Resolved model name. + ReasoningEffort string `protobuf:"bytes,2,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Resolved reasoning effort level. + IsFirstRun bool `protobuf:"varint,3,opt,name=is_first_run,json=isFirstRun,proto3" json:"is_first_run,omitempty"` // True if this was the first configuration. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InitializeCopilotResponse) Reset() { + *x = InitializeCopilotResponse{} + mi := &file_copilot_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InitializeCopilotResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InitializeCopilotResponse) ProtoMessage() {} + +func (x *InitializeCopilotResponse) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InitializeCopilotResponse.ProtoReflect.Descriptor instead. +func (*InitializeCopilotResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{1} +} + +func (x *InitializeCopilotResponse) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *InitializeCopilotResponse) GetReasoningEffort() string { + if x != nil { + return x.ReasoningEffort + } + return "" +} + +func (x *InitializeCopilotResponse) GetIsFirstRun() bool { + if x != nil { + return x.IsFirstRun + } + return false +} + +// ListCopilotSessionsRequest requests available sessions for a directory. +type ListCopilotSessionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkingDirectory string `protobuf:"bytes,1,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` // Directory to list sessions for (empty = cwd). + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListCopilotSessionsRequest) Reset() { + *x = ListCopilotSessionsRequest{} + mi := &file_copilot_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListCopilotSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCopilotSessionsRequest) ProtoMessage() {} + +func (x *ListCopilotSessionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCopilotSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListCopilotSessionsRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{2} +} + +func (x *ListCopilotSessionsRequest) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +// ListCopilotSessionsResponse contains available sessions. +type ListCopilotSessionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sessions []*CopilotSessionMetadata `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` // Available sessions. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListCopilotSessionsResponse) Reset() { + *x = ListCopilotSessionsResponse{} + mi := &file_copilot_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListCopilotSessionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCopilotSessionsResponse) ProtoMessage() {} + +func (x *ListCopilotSessionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCopilotSessionsResponse.ProtoReflect.Descriptor instead. +func (*ListCopilotSessionsResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{3} +} + +func (x *ListCopilotSessionsResponse) GetSessions() []*CopilotSessionMetadata { + if x != nil { + return x.Sessions + } + return nil +} + +// CopilotSessionMetadata describes a previous Copilot session. +type CopilotSessionMetadata struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Unique session identifier. + ModifiedTime string `protobuf:"bytes,2,opt,name=modified_time,json=modifiedTime,proto3" json:"modified_time,omitempty"` // Last modified time (RFC3339 format). + Summary string `protobuf:"bytes,3,opt,name=summary,proto3" json:"summary,omitempty"` // Optional session summary. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopilotSessionMetadata) Reset() { + *x = CopilotSessionMetadata{} + mi := &file_copilot_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopilotSessionMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopilotSessionMetadata) ProtoMessage() {} + +func (x *CopilotSessionMetadata) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CopilotSessionMetadata.ProtoReflect.Descriptor instead. +func (*CopilotSessionMetadata) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{4} +} + +func (x *CopilotSessionMetadata) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *CopilotSessionMetadata) GetModifiedTime() string { + if x != nil { + return x.ModifiedTime + } + return "" +} + +func (x *CopilotSessionMetadata) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +// SendCopilotMessageRequest sends a prompt to the agent. +// On the first call, include session configuration fields. If session_id +// is provided, the existing session is resumed. If omitted, a new session +// is created with the provided configuration. +type SendCopilotMessageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Prompt string `protobuf:"bytes,1,opt,name=prompt,proto3" json:"prompt,omitempty"` // The prompt/message to send. + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Optional: session to reuse or resume. + Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"` // Optional: model override (first call or resume). + ReasoningEffort string `protobuf:"bytes,4,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Optional: reasoning effort (low, medium, high). + SystemMessage string `protobuf:"bytes,5,opt,name=system_message,json=systemMessage,proto3" json:"system_message,omitempty"` // Optional: custom system message appended to default. + Mode string `protobuf:"bytes,6,opt,name=mode,proto3" json:"mode,omitempty"` // Optional: agent mode (autopilot, interactive, plan). + Debug bool `protobuf:"varint,7,opt,name=debug,proto3" json:"debug,omitempty"` // Optional: enable debug logging. + Headless bool `protobuf:"varint,8,opt,name=headless,proto3" json:"headless,omitempty"` // Set to true for headless mode (suppresses console output). + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendCopilotMessageRequest) Reset() { + *x = SendCopilotMessageRequest{} + mi := &file_copilot_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendCopilotMessageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendCopilotMessageRequest) ProtoMessage() {} + +func (x *SendCopilotMessageRequest) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendCopilotMessageRequest.ProtoReflect.Descriptor instead. +func (*SendCopilotMessageRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{5} +} + +func (x *SendCopilotMessageRequest) GetPrompt() string { + if x != nil { + return x.Prompt + } + return "" +} + +func (x *SendCopilotMessageRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SendCopilotMessageRequest) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *SendCopilotMessageRequest) GetReasoningEffort() string { + if x != nil { + return x.ReasoningEffort + } + return "" +} + +func (x *SendCopilotMessageRequest) GetSystemMessage() string { + if x != nil { + return x.SystemMessage + } + return "" +} + +func (x *SendCopilotMessageRequest) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *SendCopilotMessageRequest) GetDebug() bool { + if x != nil { + return x.Debug + } + return false +} + +func (x *SendCopilotMessageRequest) GetHeadless() bool { + if x != nil { + return x.Headless + } + return false +} + +// SendCopilotMessageResponse contains the result of the message. +type SendCopilotMessageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session ID — use in subsequent calls to reuse. + Usage *CopilotUsageMetrics `protobuf:"bytes,2,opt,name=usage,proto3" json:"usage,omitempty"` // Usage metrics for this message turn. + FileChanges []*CopilotFileChange `protobuf:"bytes,3,rep,name=file_changes,json=fileChanges,proto3" json:"file_changes,omitempty"` // Files changed during this message turn. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendCopilotMessageResponse) Reset() { + *x = SendCopilotMessageResponse{} + mi := &file_copilot_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendCopilotMessageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendCopilotMessageResponse) ProtoMessage() {} + +func (x *SendCopilotMessageResponse) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendCopilotMessageResponse.ProtoReflect.Descriptor instead. +func (*SendCopilotMessageResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{6} +} + +func (x *SendCopilotMessageResponse) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SendCopilotMessageResponse) GetUsage() *CopilotUsageMetrics { + if x != nil { + return x.Usage + } + return nil +} + +func (x *SendCopilotMessageResponse) GetFileChanges() []*CopilotFileChange { + if x != nil { + return x.FileChanges + } + return nil +} + +// GetCopilotUsageMetricsRequest requests cached usage metrics for a session. +type GetCopilotUsageMetricsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session to get metrics for. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCopilotUsageMetricsRequest) Reset() { + *x = GetCopilotUsageMetricsRequest{} + mi := &file_copilot_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCopilotUsageMetricsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCopilotUsageMetricsRequest) ProtoMessage() {} + +func (x *GetCopilotUsageMetricsRequest) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCopilotUsageMetricsRequest.ProtoReflect.Descriptor instead. +func (*GetCopilotUsageMetricsRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{7} +} + +func (x *GetCopilotUsageMetricsRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// GetCopilotUsageMetricsResponse contains cumulative usage metrics. +type GetCopilotUsageMetricsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Usage *CopilotUsageMetrics `protobuf:"bytes,1,opt,name=usage,proto3" json:"usage,omitempty"` // Cumulative usage metrics for the session. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCopilotUsageMetricsResponse) Reset() { + *x = GetCopilotUsageMetricsResponse{} + mi := &file_copilot_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCopilotUsageMetricsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCopilotUsageMetricsResponse) ProtoMessage() {} + +func (x *GetCopilotUsageMetricsResponse) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCopilotUsageMetricsResponse.ProtoReflect.Descriptor instead. +func (*GetCopilotUsageMetricsResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{8} +} + +func (x *GetCopilotUsageMetricsResponse) GetUsage() *CopilotUsageMetrics { + if x != nil { + return x.Usage + } + return nil +} + +// CopilotUsageMetrics tracks resource consumption for a Copilot session. +type CopilotUsageMetrics struct { + state protoimpl.MessageState `protogen:"open.v1"` + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` // Model used. + InputTokens float64 `protobuf:"fixed64,2,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` // Total input tokens consumed. + OutputTokens float64 `protobuf:"fixed64,3,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` // Total output tokens consumed. + TotalTokens float64 `protobuf:"fixed64,4,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` // Sum of input + output tokens. + BillingRate float64 `protobuf:"fixed64,5,opt,name=billing_rate,json=billingRate,proto3" json:"billing_rate,omitempty"` // Per-request cost multiplier (e.g., 1.0x, 2.0x). + PremiumRequests float64 `protobuf:"fixed64,6,opt,name=premium_requests,json=premiumRequests,proto3" json:"premium_requests,omitempty"` // Number of premium requests used. + DurationMs float64 `protobuf:"fixed64,7,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` // Total API duration in milliseconds. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopilotUsageMetrics) Reset() { + *x = CopilotUsageMetrics{} + mi := &file_copilot_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopilotUsageMetrics) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopilotUsageMetrics) ProtoMessage() {} + +func (x *CopilotUsageMetrics) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CopilotUsageMetrics.ProtoReflect.Descriptor instead. +func (*CopilotUsageMetrics) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{9} +} + +func (x *CopilotUsageMetrics) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *CopilotUsageMetrics) GetInputTokens() float64 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *CopilotUsageMetrics) GetOutputTokens() float64 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *CopilotUsageMetrics) GetTotalTokens() float64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *CopilotUsageMetrics) GetBillingRate() float64 { + if x != nil { + return x.BillingRate + } + return 0 +} + +func (x *CopilotUsageMetrics) GetPremiumRequests() float64 { + if x != nil { + return x.PremiumRequests + } + return 0 +} + +func (x *CopilotUsageMetrics) GetDurationMs() float64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +// GetCopilotFileChangesRequest requests cached file changes for a session. +type GetCopilotFileChangesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session to get file changes for. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCopilotFileChangesRequest) Reset() { + *x = GetCopilotFileChangesRequest{} + mi := &file_copilot_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCopilotFileChangesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCopilotFileChangesRequest) ProtoMessage() {} + +func (x *GetCopilotFileChangesRequest) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCopilotFileChangesRequest.ProtoReflect.Descriptor instead. +func (*GetCopilotFileChangesRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{10} +} + +func (x *GetCopilotFileChangesRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// GetCopilotFileChangesResponse contains file changes tracked during the session. +type GetCopilotFileChangesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FileChanges []*CopilotFileChange `protobuf:"bytes,1,rep,name=file_changes,json=fileChanges,proto3" json:"file_changes,omitempty"` // Files changed during the session. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCopilotFileChangesResponse) Reset() { + *x = GetCopilotFileChangesResponse{} + mi := &file_copilot_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCopilotFileChangesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCopilotFileChangesResponse) ProtoMessage() {} + +func (x *GetCopilotFileChangesResponse) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCopilotFileChangesResponse.ProtoReflect.Descriptor instead. +func (*GetCopilotFileChangesResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{11} +} + +func (x *GetCopilotFileChangesResponse) GetFileChanges() []*CopilotFileChange { + if x != nil { + return x.FileChanges + } + return nil +} + +// CopilotFileChange describes a single file change. +type CopilotFileChange struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // File path (relative to working directory). + ChangeType CopilotFileChangeType `protobuf:"varint,2,opt,name=change_type,json=changeType,proto3,enum=azdext.CopilotFileChangeType" json:"change_type,omitempty"` // Type of change. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopilotFileChange) Reset() { + *x = CopilotFileChange{} + mi := &file_copilot_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopilotFileChange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopilotFileChange) ProtoMessage() {} + +func (x *CopilotFileChange) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CopilotFileChange.ProtoReflect.Descriptor instead. +func (*CopilotFileChange) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{12} +} + +func (x *CopilotFileChange) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *CopilotFileChange) GetChangeType() CopilotFileChangeType { + if x != nil { + return x.ChangeType + } + return CopilotFileChangeType_COPILOT_FILE_CHANGE_TYPE_UNSPECIFIED +} + +// StopCopilotSessionRequest stops a session and releases resources. +type StopCopilotSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session to stop. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopCopilotSessionRequest) Reset() { + *x = StopCopilotSessionRequest{} + mi := &file_copilot_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopCopilotSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopCopilotSessionRequest) ProtoMessage() {} + +func (x *StopCopilotSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StopCopilotSessionRequest.ProtoReflect.Descriptor instead. +func (*StopCopilotSessionRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{13} +} + +func (x *StopCopilotSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// GetCopilotMessagesRequest requests the session event log. +type GetCopilotMessagesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session to get messages for. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCopilotMessagesRequest) Reset() { + *x = GetCopilotMessagesRequest{} + mi := &file_copilot_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCopilotMessagesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCopilotMessagesRequest) ProtoMessage() {} + +func (x *GetCopilotMessagesRequest) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCopilotMessagesRequest.ProtoReflect.Descriptor instead. +func (*GetCopilotMessagesRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{14} +} + +func (x *GetCopilotMessagesRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// GetCopilotMessagesResponse contains the session event log. +type GetCopilotMessagesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*CopilotSessionEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` // Session events in chronological order. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCopilotMessagesResponse) Reset() { + *x = GetCopilotMessagesResponse{} + mi := &file_copilot_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCopilotMessagesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCopilotMessagesResponse) ProtoMessage() {} + +func (x *GetCopilotMessagesResponse) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCopilotMessagesResponse.ProtoReflect.Descriptor instead. +func (*GetCopilotMessagesResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{15} +} + +func (x *GetCopilotMessagesResponse) GetEvents() []*CopilotSessionEvent { + if x != nil { + return x.Events + } + return nil +} + +// CopilotSessionEvent represents a single event from the Copilot session log. +// The data field uses google.protobuf.Struct to carry dynamic event data +// without requiring schema updates when the SDK adds new event types or fields. +type CopilotSessionEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` // Event type (e.g., "assistant.message", "tool.execution_complete"). + Timestamp string `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // ISO 8601 timestamp. + Data *structpb.Struct `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` // Full event data as a dynamic struct. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopilotSessionEvent) Reset() { + *x = CopilotSessionEvent{} + mi := &file_copilot_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopilotSessionEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopilotSessionEvent) ProtoMessage() {} + +func (x *CopilotSessionEvent) ProtoReflect() protoreflect.Message { + mi := &file_copilot_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CopilotSessionEvent.ProtoReflect.Descriptor instead. +func (*CopilotSessionEvent) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{16} +} + +func (x *CopilotSessionEvent) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *CopilotSessionEvent) GetTimestamp() string { + if x != nil { + return x.Timestamp + } + return "" +} + +func (x *CopilotSessionEvent) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +var File_copilot_proto protoreflect.FileDescriptor + +const file_copilot_proto_rawDesc = "" + + "\n" + + "\rcopilot.proto\x12\x06azdext\x1a\fmodels.proto\x1a$include/google/protobuf/struct.proto\"[\n" + + "\x18InitializeCopilotRequest\x12\x14\n" + + "\x05model\x18\x01 \x01(\tR\x05model\x12)\n" + + "\x10reasoning_effort\x18\x02 \x01(\tR\x0freasoningEffort\"~\n" + + "\x19InitializeCopilotResponse\x12\x14\n" + + "\x05model\x18\x01 \x01(\tR\x05model\x12)\n" + + "\x10reasoning_effort\x18\x02 \x01(\tR\x0freasoningEffort\x12 \n" + + "\fis_first_run\x18\x03 \x01(\bR\n" + + "isFirstRun\"I\n" + + "\x1aListCopilotSessionsRequest\x12+\n" + + "\x11working_directory\x18\x01 \x01(\tR\x10workingDirectory\"Y\n" + + "\x1bListCopilotSessionsResponse\x12:\n" + + "\bsessions\x18\x01 \x03(\v2\x1e.azdext.CopilotSessionMetadataR\bsessions\"v\n" + + "\x16CopilotSessionMetadata\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12#\n" + + "\rmodified_time\x18\x02 \x01(\tR\fmodifiedTime\x12\x18\n" + + "\asummary\x18\x03 \x01(\tR\asummary\"\x80\x02\n" + + "\x19SendCopilotMessageRequest\x12\x16\n" + + "\x06prompt\x18\x01 \x01(\tR\x06prompt\x12\x1d\n" + + "\n" + + "session_id\x18\x02 \x01(\tR\tsessionId\x12\x14\n" + + "\x05model\x18\x03 \x01(\tR\x05model\x12)\n" + + "\x10reasoning_effort\x18\x04 \x01(\tR\x0freasoningEffort\x12%\n" + + "\x0esystem_message\x18\x05 \x01(\tR\rsystemMessage\x12\x12\n" + + "\x04mode\x18\x06 \x01(\tR\x04mode\x12\x14\n" + + "\x05debug\x18\a \x01(\bR\x05debug\x12\x1a\n" + + "\bheadless\x18\b \x01(\bR\bheadless\"\xac\x01\n" + + "\x1aSendCopilotMessageResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x121\n" + + "\x05usage\x18\x02 \x01(\v2\x1b.azdext.CopilotUsageMetricsR\x05usage\x12<\n" + + "\ffile_changes\x18\x03 \x03(\v2\x19.azdext.CopilotFileChangeR\vfileChanges\">\n" + + "\x1dGetCopilotUsageMetricsRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"S\n" + + "\x1eGetCopilotUsageMetricsResponse\x121\n" + + "\x05usage\x18\x01 \x01(\v2\x1b.azdext.CopilotUsageMetricsR\x05usage\"\x85\x02\n" + + "\x13CopilotUsageMetrics\x12\x14\n" + + "\x05model\x18\x01 \x01(\tR\x05model\x12!\n" + + "\finput_tokens\x18\x02 \x01(\x01R\vinputTokens\x12#\n" + + "\routput_tokens\x18\x03 \x01(\x01R\foutputTokens\x12!\n" + + "\ftotal_tokens\x18\x04 \x01(\x01R\vtotalTokens\x12!\n" + + "\fbilling_rate\x18\x05 \x01(\x01R\vbillingRate\x12)\n" + + "\x10premium_requests\x18\x06 \x01(\x01R\x0fpremiumRequests\x12\x1f\n" + + "\vduration_ms\x18\a \x01(\x01R\n" + + "durationMs\"=\n" + + "\x1cGetCopilotFileChangesRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"]\n" + + "\x1dGetCopilotFileChangesResponse\x12<\n" + + "\ffile_changes\x18\x01 \x03(\v2\x19.azdext.CopilotFileChangeR\vfileChanges\"g\n" + + "\x11CopilotFileChange\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12>\n" + + "\vchange_type\x18\x02 \x01(\x0e2\x1d.azdext.CopilotFileChangeTypeR\n" + + "changeType\":\n" + + "\x19StopCopilotSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\":\n" + + "\x19GetCopilotMessagesRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"Q\n" + + "\x1aGetCopilotMessagesResponse\x123\n" + + "\x06events\x18\x01 \x03(\v2\x1b.azdext.CopilotSessionEventR\x06events\"t\n" + + "\x13CopilotSessionEvent\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\tR\ttimestamp\x12+\n" + + "\x04data\x18\x03 \x01(\v2\x17.google.protobuf.StructR\x04data*\xb4\x01\n" + + "\x15CopilotFileChangeType\x12(\n" + + "$COPILOT_FILE_CHANGE_TYPE_UNSPECIFIED\x10\x00\x12$\n" + + " COPILOT_FILE_CHANGE_TYPE_CREATED\x10\x01\x12%\n" + + "!COPILOT_FILE_CHANGE_TYPE_MODIFIED\x10\x02\x12$\n" + + " COPILOT_FILE_CHANGE_TYPE_DELETED\x10\x032\xf2\x04\n" + + "\x0eCopilotService\x12Q\n" + + "\n" + + "Initialize\x12 .azdext.InitializeCopilotRequest\x1a!.azdext.InitializeCopilotResponse\x12W\n" + + "\fListSessions\x12\".azdext.ListCopilotSessionsRequest\x1a#.azdext.ListCopilotSessionsResponse\x12T\n" + + "\vSendMessage\x12!.azdext.SendCopilotMessageRequest\x1a\".azdext.SendCopilotMessageResponse\x12`\n" + + "\x0fGetUsageMetrics\x12%.azdext.GetCopilotUsageMetricsRequest\x1a&.azdext.GetCopilotUsageMetricsResponse\x12]\n" + + "\x0eGetFileChanges\x12$.azdext.GetCopilotFileChangesRequest\x1a%.azdext.GetCopilotFileChangesResponse\x12G\n" + + "\vStopSession\x12!.azdext.StopCopilotSessionRequest\x1a\x15.azdext.EmptyResponse\x12T\n" + + "\vGetMessages\x12!.azdext.GetCopilotMessagesRequest\x1a\".azdext.GetCopilotMessagesResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + +var ( + file_copilot_proto_rawDescOnce sync.Once + file_copilot_proto_rawDescData []byte +) + +func file_copilot_proto_rawDescGZIP() []byte { + file_copilot_proto_rawDescOnce.Do(func() { + file_copilot_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_copilot_proto_rawDesc), len(file_copilot_proto_rawDesc))) + }) + return file_copilot_proto_rawDescData +} + +var file_copilot_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_copilot_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_copilot_proto_goTypes = []any{ + (CopilotFileChangeType)(0), // 0: azdext.CopilotFileChangeType + (*InitializeCopilotRequest)(nil), // 1: azdext.InitializeCopilotRequest + (*InitializeCopilotResponse)(nil), // 2: azdext.InitializeCopilotResponse + (*ListCopilotSessionsRequest)(nil), // 3: azdext.ListCopilotSessionsRequest + (*ListCopilotSessionsResponse)(nil), // 4: azdext.ListCopilotSessionsResponse + (*CopilotSessionMetadata)(nil), // 5: azdext.CopilotSessionMetadata + (*SendCopilotMessageRequest)(nil), // 6: azdext.SendCopilotMessageRequest + (*SendCopilotMessageResponse)(nil), // 7: azdext.SendCopilotMessageResponse + (*GetCopilotUsageMetricsRequest)(nil), // 8: azdext.GetCopilotUsageMetricsRequest + (*GetCopilotUsageMetricsResponse)(nil), // 9: azdext.GetCopilotUsageMetricsResponse + (*CopilotUsageMetrics)(nil), // 10: azdext.CopilotUsageMetrics + (*GetCopilotFileChangesRequest)(nil), // 11: azdext.GetCopilotFileChangesRequest + (*GetCopilotFileChangesResponse)(nil), // 12: azdext.GetCopilotFileChangesResponse + (*CopilotFileChange)(nil), // 13: azdext.CopilotFileChange + (*StopCopilotSessionRequest)(nil), // 14: azdext.StopCopilotSessionRequest + (*GetCopilotMessagesRequest)(nil), // 15: azdext.GetCopilotMessagesRequest + (*GetCopilotMessagesResponse)(nil), // 16: azdext.GetCopilotMessagesResponse + (*CopilotSessionEvent)(nil), // 17: azdext.CopilotSessionEvent + (*structpb.Struct)(nil), // 18: google.protobuf.Struct + (*EmptyResponse)(nil), // 19: azdext.EmptyResponse +} +var file_copilot_proto_depIdxs = []int32{ + 5, // 0: azdext.ListCopilotSessionsResponse.sessions:type_name -> azdext.CopilotSessionMetadata + 10, // 1: azdext.SendCopilotMessageResponse.usage:type_name -> azdext.CopilotUsageMetrics + 13, // 2: azdext.SendCopilotMessageResponse.file_changes:type_name -> azdext.CopilotFileChange + 10, // 3: azdext.GetCopilotUsageMetricsResponse.usage:type_name -> azdext.CopilotUsageMetrics + 13, // 4: azdext.GetCopilotFileChangesResponse.file_changes:type_name -> azdext.CopilotFileChange + 0, // 5: azdext.CopilotFileChange.change_type:type_name -> azdext.CopilotFileChangeType + 17, // 6: azdext.GetCopilotMessagesResponse.events:type_name -> azdext.CopilotSessionEvent + 18, // 7: azdext.CopilotSessionEvent.data:type_name -> google.protobuf.Struct + 1, // 8: azdext.CopilotService.Initialize:input_type -> azdext.InitializeCopilotRequest + 3, // 9: azdext.CopilotService.ListSessions:input_type -> azdext.ListCopilotSessionsRequest + 6, // 10: azdext.CopilotService.SendMessage:input_type -> azdext.SendCopilotMessageRequest + 8, // 11: azdext.CopilotService.GetUsageMetrics:input_type -> azdext.GetCopilotUsageMetricsRequest + 11, // 12: azdext.CopilotService.GetFileChanges:input_type -> azdext.GetCopilotFileChangesRequest + 14, // 13: azdext.CopilotService.StopSession:input_type -> azdext.StopCopilotSessionRequest + 15, // 14: azdext.CopilotService.GetMessages:input_type -> azdext.GetCopilotMessagesRequest + 2, // 15: azdext.CopilotService.Initialize:output_type -> azdext.InitializeCopilotResponse + 4, // 16: azdext.CopilotService.ListSessions:output_type -> azdext.ListCopilotSessionsResponse + 7, // 17: azdext.CopilotService.SendMessage:output_type -> azdext.SendCopilotMessageResponse + 9, // 18: azdext.CopilotService.GetUsageMetrics:output_type -> azdext.GetCopilotUsageMetricsResponse + 12, // 19: azdext.CopilotService.GetFileChanges:output_type -> azdext.GetCopilotFileChangesResponse + 19, // 20: azdext.CopilotService.StopSession:output_type -> azdext.EmptyResponse + 16, // 21: azdext.CopilotService.GetMessages:output_type -> azdext.GetCopilotMessagesResponse + 15, // [15:22] is the sub-list for method output_type + 8, // [8:15] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_copilot_proto_init() } +func file_copilot_proto_init() { + if File_copilot_proto != nil { + return + } + file_models_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_copilot_proto_rawDesc), len(file_copilot_proto_rawDesc)), + NumEnums: 1, + NumMessages: 17, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_copilot_proto_goTypes, + DependencyIndexes: file_copilot_proto_depIdxs, + EnumInfos: file_copilot_proto_enumTypes, + MessageInfos: file_copilot_proto_msgTypes, + }.Build() + File_copilot_proto = out.File + file_copilot_proto_goTypes = nil + file_copilot_proto_depIdxs = nil +} diff --git a/cli/azd/pkg/azdext/copilot_grpc.pb.go b/cli/azd/pkg/azdext/copilot_grpc.pb.go new file mode 100644 index 00000000000..b837b05ca8f --- /dev/null +++ b/cli/azd/pkg/azdext/copilot_grpc.pb.go @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v6.32.1 +// source: copilot.proto + +package azdext + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + CopilotService_Initialize_FullMethodName = "/azdext.CopilotService/Initialize" + CopilotService_ListSessions_FullMethodName = "/azdext.CopilotService/ListSessions" + CopilotService_SendMessage_FullMethodName = "/azdext.CopilotService/SendMessage" + CopilotService_GetUsageMetrics_FullMethodName = "/azdext.CopilotService/GetUsageMetrics" + CopilotService_GetFileChanges_FullMethodName = "/azdext.CopilotService/GetFileChanges" + CopilotService_StopSession_FullMethodName = "/azdext.CopilotService/StopSession" + CopilotService_GetMessages_FullMethodName = "/azdext.CopilotService/GetMessages" +) + +// CopilotServiceClient is the client API for CopilotService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// CopilotService provides Copilot agent capabilities to extensions. +// Sessions are created lazily on the first SendMessage call and can be +// reused across multiple calls. Sessions run in headless/autopilot mode +// by default when invoked via gRPC, suppressing all console output. +type CopilotServiceClient interface { + // Initialize starts the Copilot client, verifies authentication, and + // resolves model/reasoning configuration. Call before SendMessage to + // warm up the client and validate settings. Idempotent. + Initialize(ctx context.Context, in *InitializeCopilotRequest, opts ...grpc.CallOption) (*InitializeCopilotResponse, error) + // ListSessions returns available Copilot sessions for a working directory. + ListSessions(ctx context.Context, in *ListCopilotSessionsRequest, opts ...grpc.CallOption) (*ListCopilotSessionsResponse, error) + // SendMessage sends a prompt to the Copilot agent. On the first call, + // a new session is created using the provided configuration. If session_id + // is set, that existing session is resumed instead. Subsequent calls with + // the returned session_id reuse the same session without re-bootstrapping. + SendMessage(ctx context.Context, in *SendCopilotMessageRequest, opts ...grpc.CallOption) (*SendCopilotMessageResponse, error) + // GetUsageMetrics returns cumulative usage metrics cached for a session. + GetUsageMetrics(ctx context.Context, in *GetCopilotUsageMetricsRequest, opts ...grpc.CallOption) (*GetCopilotUsageMetricsResponse, error) + // GetFileChanges returns files created, modified, or deleted during a session. + GetFileChanges(ctx context.Context, in *GetCopilotFileChangesRequest, opts ...grpc.CallOption) (*GetCopilotFileChangesResponse, error) + // StopSession stops and cleans up a Copilot agent session. + StopSession(ctx context.Context, in *StopCopilotSessionRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // GetMessages returns the session event log from the Copilot SDK. + // Each event contains a type, timestamp, and dynamic data as a Struct. + GetMessages(ctx context.Context, in *GetCopilotMessagesRequest, opts ...grpc.CallOption) (*GetCopilotMessagesResponse, error) +} + +type copilotServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewCopilotServiceClient(cc grpc.ClientConnInterface) CopilotServiceClient { + return &copilotServiceClient{cc} +} + +func (c *copilotServiceClient) Initialize(ctx context.Context, in *InitializeCopilotRequest, opts ...grpc.CallOption) (*InitializeCopilotResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InitializeCopilotResponse) + err := c.cc.Invoke(ctx, CopilotService_Initialize_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *copilotServiceClient) ListSessions(ctx context.Context, in *ListCopilotSessionsRequest, opts ...grpc.CallOption) (*ListCopilotSessionsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListCopilotSessionsResponse) + err := c.cc.Invoke(ctx, CopilotService_ListSessions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *copilotServiceClient) SendMessage(ctx context.Context, in *SendCopilotMessageRequest, opts ...grpc.CallOption) (*SendCopilotMessageResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendCopilotMessageResponse) + err := c.cc.Invoke(ctx, CopilotService_SendMessage_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *copilotServiceClient) GetUsageMetrics(ctx context.Context, in *GetCopilotUsageMetricsRequest, opts ...grpc.CallOption) (*GetCopilotUsageMetricsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCopilotUsageMetricsResponse) + err := c.cc.Invoke(ctx, CopilotService_GetUsageMetrics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *copilotServiceClient) GetFileChanges(ctx context.Context, in *GetCopilotFileChangesRequest, opts ...grpc.CallOption) (*GetCopilotFileChangesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCopilotFileChangesResponse) + err := c.cc.Invoke(ctx, CopilotService_GetFileChanges_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *copilotServiceClient) StopSession(ctx context.Context, in *StopCopilotSessionRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, CopilotService_StopSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *copilotServiceClient) GetMessages(ctx context.Context, in *GetCopilotMessagesRequest, opts ...grpc.CallOption) (*GetCopilotMessagesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCopilotMessagesResponse) + err := c.cc.Invoke(ctx, CopilotService_GetMessages_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CopilotServiceServer is the server API for CopilotService service. +// All implementations must embed UnimplementedCopilotServiceServer +// for forward compatibility. +// +// CopilotService provides Copilot agent capabilities to extensions. +// Sessions are created lazily on the first SendMessage call and can be +// reused across multiple calls. Sessions run in headless/autopilot mode +// by default when invoked via gRPC, suppressing all console output. +type CopilotServiceServer interface { + // Initialize starts the Copilot client, verifies authentication, and + // resolves model/reasoning configuration. Call before SendMessage to + // warm up the client and validate settings. Idempotent. + Initialize(context.Context, *InitializeCopilotRequest) (*InitializeCopilotResponse, error) + // ListSessions returns available Copilot sessions for a working directory. + ListSessions(context.Context, *ListCopilotSessionsRequest) (*ListCopilotSessionsResponse, error) + // SendMessage sends a prompt to the Copilot agent. On the first call, + // a new session is created using the provided configuration. If session_id + // is set, that existing session is resumed instead. Subsequent calls with + // the returned session_id reuse the same session without re-bootstrapping. + SendMessage(context.Context, *SendCopilotMessageRequest) (*SendCopilotMessageResponse, error) + // GetUsageMetrics returns cumulative usage metrics cached for a session. + GetUsageMetrics(context.Context, *GetCopilotUsageMetricsRequest) (*GetCopilotUsageMetricsResponse, error) + // GetFileChanges returns files created, modified, or deleted during a session. + GetFileChanges(context.Context, *GetCopilotFileChangesRequest) (*GetCopilotFileChangesResponse, error) + // StopSession stops and cleans up a Copilot agent session. + StopSession(context.Context, *StopCopilotSessionRequest) (*EmptyResponse, error) + // GetMessages returns the session event log from the Copilot SDK. + // Each event contains a type, timestamp, and dynamic data as a Struct. + GetMessages(context.Context, *GetCopilotMessagesRequest) (*GetCopilotMessagesResponse, error) + mustEmbedUnimplementedCopilotServiceServer() +} + +// UnimplementedCopilotServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedCopilotServiceServer struct{} + +func (UnimplementedCopilotServiceServer) Initialize(context.Context, *InitializeCopilotRequest) (*InitializeCopilotResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Initialize not implemented") +} +func (UnimplementedCopilotServiceServer) ListSessions(context.Context, *ListCopilotSessionsRequest) (*ListCopilotSessionsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListSessions not implemented") +} +func (UnimplementedCopilotServiceServer) SendMessage(context.Context, *SendCopilotMessageRequest) (*SendCopilotMessageResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendMessage not implemented") +} +func (UnimplementedCopilotServiceServer) GetUsageMetrics(context.Context, *GetCopilotUsageMetricsRequest) (*GetCopilotUsageMetricsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetUsageMetrics not implemented") +} +func (UnimplementedCopilotServiceServer) GetFileChanges(context.Context, *GetCopilotFileChangesRequest) (*GetCopilotFileChangesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetFileChanges not implemented") +} +func (UnimplementedCopilotServiceServer) StopSession(context.Context, *StopCopilotSessionRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method StopSession not implemented") +} +func (UnimplementedCopilotServiceServer) GetMessages(context.Context, *GetCopilotMessagesRequest) (*GetCopilotMessagesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetMessages not implemented") +} +func (UnimplementedCopilotServiceServer) mustEmbedUnimplementedCopilotServiceServer() {} +func (UnimplementedCopilotServiceServer) testEmbeddedByValue() {} + +// UnsafeCopilotServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CopilotServiceServer will +// result in compilation errors. +type UnsafeCopilotServiceServer interface { + mustEmbedUnimplementedCopilotServiceServer() +} + +func RegisterCopilotServiceServer(s grpc.ServiceRegistrar, srv CopilotServiceServer) { + // If the following call pancis, it indicates UnimplementedCopilotServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&CopilotService_ServiceDesc, srv) +} + +func _CopilotService_Initialize_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InitializeCopilotRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).Initialize(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_Initialize_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).Initialize(ctx, req.(*InitializeCopilotRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CopilotService_ListSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListCopilotSessionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).ListSessions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_ListSessions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).ListSessions(ctx, req.(*ListCopilotSessionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CopilotService_SendMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendCopilotMessageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).SendMessage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_SendMessage_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).SendMessage(ctx, req.(*SendCopilotMessageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CopilotService_GetUsageMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCopilotUsageMetricsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).GetUsageMetrics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_GetUsageMetrics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).GetUsageMetrics(ctx, req.(*GetCopilotUsageMetricsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CopilotService_GetFileChanges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCopilotFileChangesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).GetFileChanges(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_GetFileChanges_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).GetFileChanges(ctx, req.(*GetCopilotFileChangesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CopilotService_StopSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StopCopilotSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).StopSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_StopSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).StopSession(ctx, req.(*StopCopilotSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CopilotService_GetMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCopilotMessagesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).GetMessages(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_GetMessages_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).GetMessages(ctx, req.(*GetCopilotMessagesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CopilotService_ServiceDesc is the grpc.ServiceDesc for CopilotService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CopilotService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "azdext.CopilotService", + HandlerType: (*CopilotServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Initialize", + Handler: _CopilotService_Initialize_Handler, + }, + { + MethodName: "ListSessions", + Handler: _CopilotService_ListSessions_Handler, + }, + { + MethodName: "SendMessage", + Handler: _CopilotService_SendMessage_Handler, + }, + { + MethodName: "GetUsageMetrics", + Handler: _CopilotService_GetUsageMetrics_Handler, + }, + { + MethodName: "GetFileChanges", + Handler: _CopilotService_GetFileChanges_Handler, + }, + { + MethodName: "StopSession", + Handler: _CopilotService_StopSession_Handler, + }, + { + MethodName: "GetMessages", + Handler: _CopilotService_GetMessages_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "copilot.proto", +} diff --git a/cli/azd/pkg/watch/watch.go b/cli/azd/pkg/watch/watch.go index 0e18043598e..fe46cc0333f 100644 --- a/cli/azd/pkg/watch/watch.go +++ b/cli/azd/pkg/watch/watch.go @@ -4,11 +4,14 @@ package watch import ( + "cmp" "context" "fmt" "log" "os" "path/filepath" + "slices" + "strings" "sync" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -18,7 +21,9 @@ import ( ) type Watcher interface { + // Deprecated: Use GetFileChanges().String() instead. PrintChangedFiles(ctx context.Context) + GetFileChanges() FileChanges } type fileWatcher struct { @@ -204,3 +209,95 @@ func (fw *fileWatcher) PrintChangedFiles(ctx context.Context) { } } } + +// FileChangeType enumerates the types of file changes. +type FileChangeType int + +const ( + // FileCreated indicates a new file was created. + FileCreated FileChangeType = iota + // FileModified indicates an existing file was modified. + FileModified + // FileDeleted indicates a file was deleted. + FileDeleted +) + +// FileChange describes a single file change with its path and type. +type FileChange struct { + Path string + ChangeType FileChangeType +} + +// String returns a formatted display string for a single file change. +func (fc FileChange) String() string { + cwd, cwdErr := os.Getwd() + path := fc.Path + if cwdErr == nil { + if rel, err := filepath.Rel(cwd, fc.Path); err == nil { + path = rel + } + } + + switch fc.ChangeType { + case FileCreated: + return fmt.Sprintf("%s %s %s", + output.WithGrayFormat("|"), + color.GreenString("+ Created "), + path) + case FileModified: + return fmt.Sprintf("%s %s %s", + output.WithGrayFormat("|"), + color.YellowString("± Modified "), + path) + case FileDeleted: + return fmt.Sprintf("%s %s %s", + output.WithGrayFormat("|"), + color.RedString("- Deleted "), + path) + default: + return fmt.Sprintf("%s %s", output.WithGrayFormat("|"), path) + } +} + +// FileChanges is a collection of file changes with formatted output support. +type FileChanges []FileChange + +// String returns a formatted display of all file changes. +func (fc FileChanges) String() string { + if len(fc) == 0 { + return "" + } + + var b strings.Builder + b.WriteString(output.WithGrayFormat("| Files changed:")) + for _, change := range fc { + b.WriteString("\n") + b.WriteString(change.String()) + } + return b.String() +} + +// GetFileChanges returns all file changes tracked by the watcher, sorted by path. +func (fw *fileWatcher) GetFileChanges() FileChanges { + fw.mu.Lock() + defer fw.mu.Unlock() + + changes := make(FileChanges, 0, + len(fw.fileChanges.Created)+len(fw.fileChanges.Modified)+len(fw.fileChanges.Deleted)) + + for file := range fw.fileChanges.Created { + changes = append(changes, FileChange{Path: file, ChangeType: FileCreated}) + } + for file := range fw.fileChanges.Modified { + changes = append(changes, FileChange{Path: file, ChangeType: FileModified}) + } + for file := range fw.fileChanges.Deleted { + changes = append(changes, FileChange{Path: file, ChangeType: FileDeleted}) + } + + slices.SortFunc(changes, func(a, b FileChange) int { + return cmp.Compare(a.Path, b.Path) + }) + + return changes +} diff --git a/cli/azd/pkg/watch/watch_test.go b/cli/azd/pkg/watch/watch_test.go new file mode 100644 index 00000000000..7d9c372686e --- /dev/null +++ b/cli/azd/pkg/watch/watch_test.go @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package watch + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGetFileChanges_Empty(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + changes := watcher.GetFileChanges() + require.Empty(t, changes) +} + +func TestGetFileChanges_CreatedFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + testFile := filepath.Join(dir, "test.txt") + err = os.WriteFile(testFile, []byte("hello"), 0600) + require.NoError(t, err) + + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "test.txt" && c.ChangeType == FileCreated { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected created file test.txt") +} + +func TestGetFileChanges_ModifiedFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + testFile := filepath.Join(dir, "existing.txt") + err := os.WriteFile(testFile, []byte("original"), 0600) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + err = os.WriteFile(testFile, []byte("modified"), 0600) + require.NoError(t, err) + + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "existing.txt" && c.ChangeType == FileModified { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected modified file existing.txt") +} + +func TestGetFileChanges_DeletedFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + testFile := filepath.Join(dir, "deleteme.txt") + err := os.WriteFile(testFile, []byte("delete me"), 0600) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + watcher, err := NewWatcher(ctx) + require.NoError(t, err) + + err = os.Remove(testFile) + require.NoError(t, err) + + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "deleteme.txt" && c.ChangeType == FileDeleted { + return true + } + } + return false + }, 2*time.Second, 50*time.Millisecond, "expected deleted file deleteme.txt") +} + +func TestFileChangeType_Values(t *testing.T) { + require.Equal(t, FileChangeType(0), FileCreated) + require.Equal(t, FileChangeType(1), FileModified) + require.Equal(t, FileChangeType(2), FileDeleted) +}