From 89b561ac97faca2506deee0e5d5c44f8bb7307aa Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 17 Mar 2026 12:37:07 -0700 Subject: [PATCH 01/11] feat: add CopilotService gRPC service for extension framework Add a new CopilotService gRPC service that exposes Copilot agent capabilities to extensions through 8 RPCs: - CreateSession: Create agent sessions with full configuration (model, reasoning effort, system message, mode, headless) - ResumeSession: Resume existing sessions by ID - ListSessions: List available sessions for a working directory - Initialize: Non-interactive first-run configuration - SendMessage: Send prompts and block until completion - GetUsageMetrics: Cumulative usage metrics across calls - GetFileChanges: Track files created/modified/deleted - StopSession: Clean up session resources Core agent changes: - Add WithHeadless option for silent operation via gRPC - Add HeadlessCollector as display replacement for headless mode - Add cumulative usage metrics tracking (GetCumulativeUsage) - Auto-approve permissions in headless mode - Add GetFileChanges() to watch.Watcher interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 1 + cli/azd/grpc/proto/copilot.proto | 176 +++ cli/azd/internal/agent/copilot_agent.go | 109 +- cli/azd/internal/agent/headless_collector.go | 138 ++ .../internal/agent/headless_collector_test.go | 115 ++ cli/azd/internal/agent/types.go | 7 + .../internal/grpcserver/copilot_service.go | 379 +++++ .../grpcserver/prompt_service_test.go | 1 + cli/azd/internal/grpcserver/server.go | 4 + cli/azd/internal/grpcserver/server_test.go | 1 + cli/azd/pkg/azdext/azd_client.go | 10 + cli/azd/pkg/azdext/copilot.pb.go | 1292 +++++++++++++++++ cli/azd/pkg/azdext/copilot_grpc.pb.go | 420 ++++++ cli/azd/pkg/watch/watch.go | 40 + cli/azd/pkg/watch/watch_test.go | 135 ++ 15 files changed, 2810 insertions(+), 18 deletions(-) create mode 100644 cli/azd/grpc/proto/copilot.proto create mode 100644 cli/azd/internal/agent/headless_collector.go create mode 100644 cli/azd/internal/agent/headless_collector_test.go create mode 100644 cli/azd/internal/grpcserver/copilot_service.go create mode 100644 cli/azd/pkg/azdext/copilot.pb.go create mode 100644 cli/azd/pkg/azdext/copilot_grpc.pb.go create mode 100644 cli/azd/pkg/watch/watch_test.go 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/grpc/proto/copilot.proto b/cli/azd/grpc/proto/copilot.proto new file mode 100644 index 00000000000..13c32c67e95 --- /dev/null +++ b/cli/azd/grpc/proto/copilot.proto @@ -0,0 +1,176 @@ +// 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"; + +// CopilotService provides Copilot agent capabilities to extensions. +// Extensions can create sessions, send messages, track file changes, and collect +// usage metrics. Sessions run in headless/autopilot mode by default when invoked +// via gRPC, suppressing all console output. +service CopilotService { + // CreateSession creates a new Copilot agent session with the given configuration. + // The session can be reused for multiple SendMessage calls without re-bootstrapping. + rpc CreateSession(CreateCopilotSessionRequest) returns (CreateCopilotSessionResponse); + + // ResumeSession resumes an existing Copilot session by its session ID. + rpc ResumeSession(ResumeCopilotSessionRequest) returns (ResumeCopilotSessionResponse); + + // ListSessions returns available Copilot sessions for a working directory. + rpc ListSessions(ListCopilotSessionsRequest) returns (ListCopilotSessionsResponse); + + // Initialize performs first-run configuration (model/reasoning setup). + // When called through gRPC, uses provided config values rather than interactive prompts. + rpc Initialize(InitializeCopilotRequest) returns (InitializeCopilotResponse); + + // SendMessage sends a prompt to the agent and blocks until processing completes. + rpc SendMessage(SendCopilotMessageRequest) returns (SendCopilotMessageResponse); + + // GetUsageMetrics returns cumulative usage metrics for a session. + rpc GetUsageMetrics(GetCopilotUsageMetricsRequest) returns (GetCopilotUsageMetricsResponse); + + // GetFileChanges returns files created, modified, or deleted during the session. + rpc GetFileChanges(GetCopilotFileChangesRequest) returns (GetCopilotFileChangesResponse); + + // StopSession stops and cleans up a Copilot agent session. + rpc StopSession(StopCopilotSessionRequest) returns (EmptyResponse); +} + +// --- Session lifecycle messages --- + +// CreateCopilotSessionRequest configures a new Copilot agent session. +message CreateCopilotSessionRequest { + string model = 1; // Model to use (empty = default). + string reasoning_effort = 2; // Reasoning effort level (low, medium, high). + string system_message = 3; // Custom system message appended to the default prompt. + string mode = 4; // Agent mode (autopilot, interactive, plan). Defaults to autopilot. + bool debug = 5; // Enable debug logging. + string working_directory = 6; // Working directory for the session (empty = cwd). + bool headless = 7; // Suppress all console output. Defaults to true for gRPC. +} + +// CreateCopilotSessionResponse contains the ID of the created session. +message CreateCopilotSessionResponse { + string session_id = 1; // Unique session handle for subsequent calls. +} + +// ResumeCopilotSessionRequest resumes an existing session by ID. +message ResumeCopilotSessionRequest { + string session_id = 1; // Session ID to resume (from ListSessions or previous CreateSession). + string model = 2; // Optional model override for the resumed session. + string reasoning_effort = 3; // Optional reasoning effort override. + string system_message = 4; // Optional system message override. + string mode = 5; // Optional mode override. + bool debug = 6; // Enable debug logging. + bool headless = 7; // Suppress all console output. Defaults to true for gRPC. +} + +// ResumeCopilotSessionResponse contains the ID of the resumed session. +message ResumeCopilotSessionResponse { + string session_id = 1; // Session handle for subsequent calls. +} + +// 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. +} + +// --- Agent operation messages --- + +// InitializeCopilotRequest configures model and reasoning for a session. +message InitializeCopilotRequest { + string session_id = 1; // Session to initialize. + string model = 2; // Model to use (overrides session config). + string reasoning_effort = 3; // Reasoning effort to use (overrides session config). +} + +// 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. +} + +// SendCopilotMessageRequest sends a prompt to the agent. +message SendCopilotMessageRequest { + string session_id = 1; // Session to send the message to. + string prompt = 2; // The prompt/message to send. +} + +// SendCopilotMessageResponse contains the result of the message. +message SendCopilotMessageResponse { + string session_id = 1; // Session that processed the message. + CopilotUsageMetrics usage = 2; // Usage metrics for this message turn. +} + +// --- Metrics messages --- + +// GetCopilotUsageMetricsRequest requests 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 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. +} diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index d2095e68f15..2e68a9665b0 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -43,14 +43,16 @@ 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 + sessionCtx context.Context + display *AgentDisplay // last display for usage metrics (interactive mode) + cumulativeUsage UsageMetrics // cumulative metrics across multiple SendMessage calls // Cleanup — ordered slice for deterministic teardown cleanupTasks []cleanupTask @@ -277,7 +279,29 @@ func (a *CopilotAgent) SendMessage(ctx context.Context, prompt string, opts ...S 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) @@ -300,20 +324,40 @@ func (a *CopilotAgent) SendMessage(ctx context.Context, prompt string, opts ...S } }() - // 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.accumulateUsage(turnUsage) + + return &AgentResult{ + SessionID: a.sessionID, + Usage: turnUsage, + }, nil +} + +// sendMessageHeadless sends a message silently using the HeadlessCollector. +func (a *CopilotAgent) sendMessageHeadless( + ctx context.Context, prompt string, mode AgentMode, +) (*AgentResult, error) { + collector := NewHeadlessCollector() + + unsubscribe := a.session.On(collector.HandleEvent) + defer unsubscribe() + + _, err := a.session.Send(ctx, copilot.MessageOptions{ Prompt: prompt, Mode: string(mode), }) @@ -321,17 +365,39 @@ 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.accumulateUsage(turnUsage) + return &AgentResult{ SessionID: a.sessionID, - Usage: display.GetUsageMetrics(), + Usage: turnUsage, }, 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 + } +} + +// GetCumulativeUsage returns the cumulative usage metrics across all SendMessage calls. +func (a *CopilotAgent) GetCumulativeUsage() UsageMetrics { + return a.cumulativeUsage +} + // SendMessageWithRetry wraps SendMessage with interactive retry-on-error UX. func (a *CopilotAgent) SendMessageWithRetry(ctx context.Context, prompt string, opts ...SendOption) (*AgentResult, error) { for { @@ -490,11 +556,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) 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..1436665486c 100644 --- a/cli/azd/internal/agent/types.go +++ b/cli/azd/internal/agent/types.go @@ -136,6 +136,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 } diff --git a/cli/azd/internal/grpcserver/copilot_service.go b/cli/azd/internal/grpcserver/copilot_service.go new file mode 100644 index 00000000000..6731038ae8b --- /dev/null +++ b/cli/azd/internal/grpcserver/copilot_service.go @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "sync" + "sync/atomic" + + "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" +) + +// sessionCounter generates unique session handles without external dependencies. +var sessionCounter atomic.Int64 + +// managedCopilotSession tracks an active Copilot agent session and its associated resources. +type managedCopilotSession struct { + agent *agent.CopilotAgent + watcher watch.Watcher + watchCancel context.CancelFunc + resumeSessionID string // SDK session ID for resumption in SendMessage + workingDir string // working directory for this session +} + +// copilotService implements the CopilotServiceServer gRPC interface. +// It manages agent sessions, delegating to CopilotAgent for all operations. +type copilotService struct { + azdext.UnimplementedCopilotServiceServer + + agentFactory *agent.CopilotAgentFactory + + mu sync.RWMutex + sessions map[string]*managedCopilotSession +} + +// NewCopilotService creates a new CopilotService gRPC server. +func NewCopilotService(agentFactory *agent.CopilotAgentFactory) azdext.CopilotServiceServer { + return &copilotService{ + agentFactory: agentFactory, + sessions: make(map[string]*managedCopilotSession), + } +} + +// CreateSession creates a new Copilot agent session with the given configuration. +func (s *copilotService) CreateSession( + ctx context.Context, req *azdext.CreateCopilotSessionRequest, +) (*azdext.CreateCopilotSessionResponse, error) { + 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, status.Errorf(codes.Internal, "failed to create copilot agent: %v", err) + } + + cwd := req.WorkingDirectory + if cwd == "" { + cwd, _ = os.Getwd() + } + + watchCtx, watchCancel := context.WithCancel(context.Background()) //nolint:gosec // cancel stored in session + watcher, watchErr := watch.NewWatcher(watchCtx) + if watchErr != nil { + log.Printf("[copilot-service] file watcher unavailable: %v", watchErr) + } + + sessionHandle := fmt.Sprintf("copilot-%d", sessionCounter.Add(1)) + + s.mu.Lock() + s.sessions[sessionHandle] = &managedCopilotSession{ + agent: copilotAgent, + watcher: watcher, + watchCancel: watchCancel, + workingDir: cwd, + } + s.mu.Unlock() + + return &azdext.CreateCopilotSessionResponse{ + SessionId: sessionHandle, + }, nil +} + +// ResumeSession resumes an existing Copilot session by ID. +func (s *copilotService) ResumeSession( + ctx context.Context, req *azdext.ResumeCopilotSessionRequest, +) (*azdext.ResumeCopilotSessionResponse, error) { + if req.SessionId == "" { + return nil, status.Error(codes.InvalidArgument, "session_id is required") + } + + 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, status.Errorf(codes.Internal, "failed to create copilot agent: %v", err) + } + + watchCtx, watchCancel := context.WithCancel(context.Background()) //nolint:gosec // cancel stored in session + watcher, watchErr := watch.NewWatcher(watchCtx) + if watchErr != nil { + log.Printf("[copilot-service] file watcher unavailable: %v", watchErr) + } + + sessionHandle := fmt.Sprintf("copilot-%d", sessionCounter.Add(1)) + + s.mu.Lock() + s.sessions[sessionHandle] = &managedCopilotSession{ + agent: copilotAgent, + watcher: watcher, + watchCancel: watchCancel, + resumeSessionID: req.SessionId, + } + s.mu.Unlock() + + return &azdext.ResumeCopilotSessionResponse{ + SessionId: sessionHandle, + }, 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) + } + } + + // Create a temporary agent to list sessions + 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 +} + +// Initialize performs first-run configuration for a session. +func (s *copilotService) Initialize( + ctx context.Context, req *azdext.InitializeCopilotRequest, +) (*azdext.InitializeCopilotResponse, error) { + managed, err := s.getSession(req.SessionId) + if err != nil { + return nil, err + } + + // Apply model/reasoning overrides if provided + if req.Model != "" { + agent.WithModel(req.Model)(managed.agent) + } + if req.ReasoningEffort != "" { + agent.WithReasoningEffort(req.ReasoningEffort)(managed.agent) + } + + result, err := managed.agent.Initialize(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to initialize agent: %v", err) + } + + return &azdext.InitializeCopilotResponse{ + Model: result.Model, + ReasoningEffort: result.ReasoningEffort, + IsFirstRun: result.IsFirstRun, + }, nil +} + +// SendMessage sends a prompt to the agent and blocks until processing completes. +func (s *copilotService) SendMessage( + ctx context.Context, req *azdext.SendCopilotMessageRequest, +) (*azdext.SendCopilotMessageResponse, error) { + managed, err := s.getSession(req.SessionId) + if err != nil { + return nil, err + } + + if req.Prompt == "" { + return nil, status.Error(codes.InvalidArgument, "prompt cannot be empty") + } + + // Pass the resume session ID if this session was created via ResumeSession + var sendOpts []agent.SendOption + if managed.resumeSessionID != "" { + sendOpts = append(sendOpts, agent.WithSessionID(managed.resumeSessionID)) + // Clear after first use — subsequent calls reuse the same session + managed.resumeSessionID = "" + } + + result, err := managed.agent.SendMessage(ctx, req.Prompt, sendOpts...) + if err != nil { + return nil, status.Errorf(codes.Internal, "copilot agent error: %v", err) + } + + return &azdext.SendCopilotMessageResponse{ + SessionId: req.SessionId, + Usage: convertUsageMetrics(result.Usage), + }, nil +} + +// GetUsageMetrics returns cumulative usage metrics for a session. +func (s *copilotService) GetUsageMetrics( + ctx context.Context, req *azdext.GetCopilotUsageMetricsRequest, +) (*azdext.GetCopilotUsageMetricsResponse, error) { + managed, err := s.getSession(req.SessionId) + if err != nil { + return nil, err + } + + return &azdext.GetCopilotUsageMetricsResponse{ + Usage: convertUsageMetrics(managed.agent.GetCumulativeUsage()), + }, nil +} + +// GetFileChanges returns files created, modified, or deleted during the session. +func (s *copilotService) GetFileChanges( + ctx context.Context, req *azdext.GetCopilotFileChangesRequest, +) (*azdext.GetCopilotFileChangesResponse, error) { + managed, err := s.getSession(req.SessionId) + if err != nil { + return nil, err + } + + if managed.watcher == nil { + return &azdext.GetCopilotFileChangesResponse{}, nil + } + + changes := managed.watcher.GetFileChanges() + 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 &azdext.GetCopilotFileChangesResponse{ + FileChanges: protoChanges, + }, 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() + managed, 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 managed.watchCancel != nil { + managed.watchCancel() + } + managed.agent.Stop() + + return &azdext.EmptyResponse{}, nil +} + +// getSession retrieves a managed session by its handle. +func (s *copilotService) getSession(sessionID string) (*managedCopilotSession, error) { + if sessionID == "" { + return nil, status.Error(codes.InvalidArgument, "session_id is required") + } + + s.mu.RLock() + defer s.mu.RUnlock() + + managed, ok := s.sessions[sessionID] + if !ok { + return nil, status.Errorf(codes.NotFound, "session %q not found", sessionID) + } + + return managed, 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, + } +} + +// 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 + } +} 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..6df3fc0fa6e 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() 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..06ee70950fd --- /dev/null +++ b/cli/azd/pkg/azdext/copilot.pb.go @@ -0,0 +1,1292 @@ +// 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" + 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} +} + +// CreateCopilotSessionRequest configures a new Copilot agent session. +type CreateCopilotSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` // Model to use (empty = default). + ReasoningEffort string `protobuf:"bytes,2,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Reasoning effort level (low, medium, high). + SystemMessage string `protobuf:"bytes,3,opt,name=system_message,json=systemMessage,proto3" json:"system_message,omitempty"` // Custom system message appended to the default prompt. + Mode string `protobuf:"bytes,4,opt,name=mode,proto3" json:"mode,omitempty"` // Agent mode (autopilot, interactive, plan). Defaults to autopilot. + Debug bool `protobuf:"varint,5,opt,name=debug,proto3" json:"debug,omitempty"` // Enable debug logging. + WorkingDirectory string `protobuf:"bytes,6,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` // Working directory for the session (empty = cwd). + Headless bool `protobuf:"varint,7,opt,name=headless,proto3" json:"headless,omitempty"` // Suppress all console output. Defaults to true for gRPC. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCopilotSessionRequest) Reset() { + *x = CreateCopilotSessionRequest{} + mi := &file_copilot_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCopilotSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCopilotSessionRequest) ProtoMessage() {} + +func (x *CreateCopilotSessionRequest) 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 CreateCopilotSessionRequest.ProtoReflect.Descriptor instead. +func (*CreateCopilotSessionRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{0} +} + +func (x *CreateCopilotSessionRequest) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *CreateCopilotSessionRequest) GetReasoningEffort() string { + if x != nil { + return x.ReasoningEffort + } + return "" +} + +func (x *CreateCopilotSessionRequest) GetSystemMessage() string { + if x != nil { + return x.SystemMessage + } + return "" +} + +func (x *CreateCopilotSessionRequest) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *CreateCopilotSessionRequest) GetDebug() bool { + if x != nil { + return x.Debug + } + return false +} + +func (x *CreateCopilotSessionRequest) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +func (x *CreateCopilotSessionRequest) GetHeadless() bool { + if x != nil { + return x.Headless + } + return false +} + +// CreateCopilotSessionResponse contains the ID of the created session. +type CreateCopilotSessionResponse 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 handle for subsequent calls. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCopilotSessionResponse) Reset() { + *x = CreateCopilotSessionResponse{} + mi := &file_copilot_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCopilotSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCopilotSessionResponse) ProtoMessage() {} + +func (x *CreateCopilotSessionResponse) 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 CreateCopilotSessionResponse.ProtoReflect.Descriptor instead. +func (*CreateCopilotSessionResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{1} +} + +func (x *CreateCopilotSessionResponse) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// ResumeCopilotSessionRequest resumes an existing session by ID. +type ResumeCopilotSessionRequest 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 to resume (from ListSessions or previous CreateSession). + Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` // Optional model override for the resumed session. + ReasoningEffort string `protobuf:"bytes,3,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Optional reasoning effort override. + SystemMessage string `protobuf:"bytes,4,opt,name=system_message,json=systemMessage,proto3" json:"system_message,omitempty"` // Optional system message override. + Mode string `protobuf:"bytes,5,opt,name=mode,proto3" json:"mode,omitempty"` // Optional mode override. + Debug bool `protobuf:"varint,6,opt,name=debug,proto3" json:"debug,omitempty"` // Enable debug logging. + Headless bool `protobuf:"varint,7,opt,name=headless,proto3" json:"headless,omitempty"` // Suppress all console output. Defaults to true for gRPC. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeCopilotSessionRequest) Reset() { + *x = ResumeCopilotSessionRequest{} + mi := &file_copilot_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeCopilotSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeCopilotSessionRequest) ProtoMessage() {} + +func (x *ResumeCopilotSessionRequest) 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 ResumeCopilotSessionRequest.ProtoReflect.Descriptor instead. +func (*ResumeCopilotSessionRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{2} +} + +func (x *ResumeCopilotSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ResumeCopilotSessionRequest) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *ResumeCopilotSessionRequest) GetReasoningEffort() string { + if x != nil { + return x.ReasoningEffort + } + return "" +} + +func (x *ResumeCopilotSessionRequest) GetSystemMessage() string { + if x != nil { + return x.SystemMessage + } + return "" +} + +func (x *ResumeCopilotSessionRequest) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *ResumeCopilotSessionRequest) GetDebug() bool { + if x != nil { + return x.Debug + } + return false +} + +func (x *ResumeCopilotSessionRequest) GetHeadless() bool { + if x != nil { + return x.Headless + } + return false +} + +// ResumeCopilotSessionResponse contains the ID of the resumed session. +type ResumeCopilotSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session handle for subsequent calls. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeCopilotSessionResponse) Reset() { + *x = ResumeCopilotSessionResponse{} + mi := &file_copilot_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeCopilotSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeCopilotSessionResponse) ProtoMessage() {} + +func (x *ResumeCopilotSessionResponse) 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 ResumeCopilotSessionResponse.ProtoReflect.Descriptor instead. +func (*ResumeCopilotSessionResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{3} +} + +func (x *ResumeCopilotSessionResponse) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// 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[4] + 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[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 ListCopilotSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListCopilotSessionsRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{4} +} + +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[5] + 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[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 ListCopilotSessionsResponse.ProtoReflect.Descriptor instead. +func (*ListCopilotSessionsResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{5} +} + +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[6] + 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[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 CopilotSessionMetadata.ProtoReflect.Descriptor instead. +func (*CopilotSessionMetadata) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{6} +} + +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 "" +} + +// InitializeCopilotRequest configures model and reasoning for a session. +type InitializeCopilotRequest 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 initialize. + Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` // Model to use (overrides session config). + ReasoningEffort string `protobuf:"bytes,3,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Reasoning effort to use (overrides session config). + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InitializeCopilotRequest) Reset() { + *x = InitializeCopilotRequest{} + mi := &file_copilot_proto_msgTypes[7] + 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[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 InitializeCopilotRequest.ProtoReflect.Descriptor instead. +func (*InitializeCopilotRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{7} +} + +func (x *InitializeCopilotRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +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[8] + 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[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 InitializeCopilotResponse.ProtoReflect.Descriptor instead. +func (*InitializeCopilotResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{8} +} + +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 +} + +// SendCopilotMessageRequest sends a prompt to the agent. +type SendCopilotMessageRequest 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 send the message to. + Prompt string `protobuf:"bytes,2,opt,name=prompt,proto3" json:"prompt,omitempty"` // The prompt/message to send. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendCopilotMessageRequest) Reset() { + *x = SendCopilotMessageRequest{} + mi := &file_copilot_proto_msgTypes[9] + 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[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 SendCopilotMessageRequest.ProtoReflect.Descriptor instead. +func (*SendCopilotMessageRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{9} +} + +func (x *SendCopilotMessageRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SendCopilotMessageRequest) GetPrompt() string { + if x != nil { + return x.Prompt + } + return "" +} + +// 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 that processed the message. + Usage *CopilotUsageMetrics `protobuf:"bytes,2,opt,name=usage,proto3" json:"usage,omitempty"` // Usage metrics for this message turn. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendCopilotMessageResponse) Reset() { + *x = SendCopilotMessageResponse{} + mi := &file_copilot_proto_msgTypes[10] + 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[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 SendCopilotMessageResponse.ProtoReflect.Descriptor instead. +func (*SendCopilotMessageResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{10} +} + +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 +} + +// GetCopilotUsageMetricsRequest requests 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[11] + 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[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 GetCopilotUsageMetricsRequest.ProtoReflect.Descriptor instead. +func (*GetCopilotUsageMetricsRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{11} +} + +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[12] + 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[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 GetCopilotUsageMetricsResponse.ProtoReflect.Descriptor instead. +func (*GetCopilotUsageMetricsResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{12} +} + +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[13] + 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[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 CopilotUsageMetrics.ProtoReflect.Descriptor instead. +func (*CopilotUsageMetrics) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{13} +} + +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 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[14] + 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[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 GetCopilotFileChangesRequest.ProtoReflect.Descriptor instead. +func (*GetCopilotFileChangesRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{14} +} + +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[15] + 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[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 GetCopilotFileChangesResponse.ProtoReflect.Descriptor instead. +func (*GetCopilotFileChangesResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{15} +} + +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[16] + 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[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 CopilotFileChange.ProtoReflect.Descriptor instead. +func (*CopilotFileChange) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{16} +} + +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[17] + 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[17] + 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{17} +} + +func (x *StopCopilotSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +var File_copilot_proto protoreflect.FileDescriptor + +const file_copilot_proto_rawDesc = "" + + "\n" + + "\rcopilot.proto\x12\x06azdext\x1a\fmodels.proto\"\xf8\x01\n" + + "\x1bCreateCopilotSessionRequest\x12\x14\n" + + "\x05model\x18\x01 \x01(\tR\x05model\x12)\n" + + "\x10reasoning_effort\x18\x02 \x01(\tR\x0freasoningEffort\x12%\n" + + "\x0esystem_message\x18\x03 \x01(\tR\rsystemMessage\x12\x12\n" + + "\x04mode\x18\x04 \x01(\tR\x04mode\x12\x14\n" + + "\x05debug\x18\x05 \x01(\bR\x05debug\x12+\n" + + "\x11working_directory\x18\x06 \x01(\tR\x10workingDirectory\x12\x1a\n" + + "\bheadless\x18\a \x01(\bR\bheadless\"=\n" + + "\x1cCreateCopilotSessionResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\xea\x01\n" + + "\x1bResumeCopilotSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x14\n" + + "\x05model\x18\x02 \x01(\tR\x05model\x12)\n" + + "\x10reasoning_effort\x18\x03 \x01(\tR\x0freasoningEffort\x12%\n" + + "\x0esystem_message\x18\x04 \x01(\tR\rsystemMessage\x12\x12\n" + + "\x04mode\x18\x05 \x01(\tR\x04mode\x12\x14\n" + + "\x05debug\x18\x06 \x01(\bR\x05debug\x12\x1a\n" + + "\bheadless\x18\a \x01(\bR\bheadless\"=\n" + + "\x1cResumeCopilotSessionResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"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\"z\n" + + "\x18InitializeCopilotRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x14\n" + + "\x05model\x18\x02 \x01(\tR\x05model\x12)\n" + + "\x10reasoning_effort\x18\x03 \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\"R\n" + + "\x19SendCopilotMessageRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x16\n" + + "\x06prompt\x18\x02 \x01(\tR\x06prompt\"n\n" + + "\x1aSendCopilotMessageResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x121\n" + + "\x05usage\x18\x02 \x01(\v2\x1b.azdext.CopilotUsageMetricsR\x05usage\">\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*\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\xd4\x05\n" + + "\x0eCopilotService\x12Z\n" + + "\rCreateSession\x12#.azdext.CreateCopilotSessionRequest\x1a$.azdext.CreateCopilotSessionResponse\x12Z\n" + + "\rResumeSession\x12#.azdext.ResumeCopilotSessionRequest\x1a$.azdext.ResumeCopilotSessionResponse\x12W\n" + + "\fListSessions\x12\".azdext.ListCopilotSessionsRequest\x1a#.azdext.ListCopilotSessionsResponse\x12Q\n" + + "\n" + + "Initialize\x12 .azdext.InitializeCopilotRequest\x1a!.azdext.InitializeCopilotResponse\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.EmptyResponseB/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, 18) +var file_copilot_proto_goTypes = []any{ + (CopilotFileChangeType)(0), // 0: azdext.CopilotFileChangeType + (*CreateCopilotSessionRequest)(nil), // 1: azdext.CreateCopilotSessionRequest + (*CreateCopilotSessionResponse)(nil), // 2: azdext.CreateCopilotSessionResponse + (*ResumeCopilotSessionRequest)(nil), // 3: azdext.ResumeCopilotSessionRequest + (*ResumeCopilotSessionResponse)(nil), // 4: azdext.ResumeCopilotSessionResponse + (*ListCopilotSessionsRequest)(nil), // 5: azdext.ListCopilotSessionsRequest + (*ListCopilotSessionsResponse)(nil), // 6: azdext.ListCopilotSessionsResponse + (*CopilotSessionMetadata)(nil), // 7: azdext.CopilotSessionMetadata + (*InitializeCopilotRequest)(nil), // 8: azdext.InitializeCopilotRequest + (*InitializeCopilotResponse)(nil), // 9: azdext.InitializeCopilotResponse + (*SendCopilotMessageRequest)(nil), // 10: azdext.SendCopilotMessageRequest + (*SendCopilotMessageResponse)(nil), // 11: azdext.SendCopilotMessageResponse + (*GetCopilotUsageMetricsRequest)(nil), // 12: azdext.GetCopilotUsageMetricsRequest + (*GetCopilotUsageMetricsResponse)(nil), // 13: azdext.GetCopilotUsageMetricsResponse + (*CopilotUsageMetrics)(nil), // 14: azdext.CopilotUsageMetrics + (*GetCopilotFileChangesRequest)(nil), // 15: azdext.GetCopilotFileChangesRequest + (*GetCopilotFileChangesResponse)(nil), // 16: azdext.GetCopilotFileChangesResponse + (*CopilotFileChange)(nil), // 17: azdext.CopilotFileChange + (*StopCopilotSessionRequest)(nil), // 18: azdext.StopCopilotSessionRequest + (*EmptyResponse)(nil), // 19: azdext.EmptyResponse +} +var file_copilot_proto_depIdxs = []int32{ + 7, // 0: azdext.ListCopilotSessionsResponse.sessions:type_name -> azdext.CopilotSessionMetadata + 14, // 1: azdext.SendCopilotMessageResponse.usage:type_name -> azdext.CopilotUsageMetrics + 14, // 2: azdext.GetCopilotUsageMetricsResponse.usage:type_name -> azdext.CopilotUsageMetrics + 17, // 3: azdext.GetCopilotFileChangesResponse.file_changes:type_name -> azdext.CopilotFileChange + 0, // 4: azdext.CopilotFileChange.change_type:type_name -> azdext.CopilotFileChangeType + 1, // 5: azdext.CopilotService.CreateSession:input_type -> azdext.CreateCopilotSessionRequest + 3, // 6: azdext.CopilotService.ResumeSession:input_type -> azdext.ResumeCopilotSessionRequest + 5, // 7: azdext.CopilotService.ListSessions:input_type -> azdext.ListCopilotSessionsRequest + 8, // 8: azdext.CopilotService.Initialize:input_type -> azdext.InitializeCopilotRequest + 10, // 9: azdext.CopilotService.SendMessage:input_type -> azdext.SendCopilotMessageRequest + 12, // 10: azdext.CopilotService.GetUsageMetrics:input_type -> azdext.GetCopilotUsageMetricsRequest + 15, // 11: azdext.CopilotService.GetFileChanges:input_type -> azdext.GetCopilotFileChangesRequest + 18, // 12: azdext.CopilotService.StopSession:input_type -> azdext.StopCopilotSessionRequest + 2, // 13: azdext.CopilotService.CreateSession:output_type -> azdext.CreateCopilotSessionResponse + 4, // 14: azdext.CopilotService.ResumeSession:output_type -> azdext.ResumeCopilotSessionResponse + 6, // 15: azdext.CopilotService.ListSessions:output_type -> azdext.ListCopilotSessionsResponse + 9, // 16: azdext.CopilotService.Initialize:output_type -> azdext.InitializeCopilotResponse + 11, // 17: azdext.CopilotService.SendMessage:output_type -> azdext.SendCopilotMessageResponse + 13, // 18: azdext.CopilotService.GetUsageMetrics:output_type -> azdext.GetCopilotUsageMetricsResponse + 16, // 19: azdext.CopilotService.GetFileChanges:output_type -> azdext.GetCopilotFileChangesResponse + 19, // 20: azdext.CopilotService.StopSession:output_type -> azdext.EmptyResponse + 13, // [13:21] is the sub-list for method output_type + 5, // [5:13] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] 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: 18, + 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..c9f5639c65d --- /dev/null +++ b/cli/azd/pkg/azdext/copilot_grpc.pb.go @@ -0,0 +1,420 @@ +// 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_CreateSession_FullMethodName = "/azdext.CopilotService/CreateSession" + CopilotService_ResumeSession_FullMethodName = "/azdext.CopilotService/ResumeSession" + CopilotService_ListSessions_FullMethodName = "/azdext.CopilotService/ListSessions" + CopilotService_Initialize_FullMethodName = "/azdext.CopilotService/Initialize" + CopilotService_SendMessage_FullMethodName = "/azdext.CopilotService/SendMessage" + CopilotService_GetUsageMetrics_FullMethodName = "/azdext.CopilotService/GetUsageMetrics" + CopilotService_GetFileChanges_FullMethodName = "/azdext.CopilotService/GetFileChanges" + CopilotService_StopSession_FullMethodName = "/azdext.CopilotService/StopSession" +) + +// 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. +// Extensions can create sessions, send messages, track file changes, and collect +// usage metrics. Sessions run in headless/autopilot mode by default when invoked +// via gRPC, suppressing all console output. +type CopilotServiceClient interface { + // CreateSession creates a new Copilot agent session with the given configuration. + // The session can be reused for multiple SendMessage calls without re-bootstrapping. + CreateSession(ctx context.Context, in *CreateCopilotSessionRequest, opts ...grpc.CallOption) (*CreateCopilotSessionResponse, error) + // ResumeSession resumes an existing Copilot session by its session ID. + ResumeSession(ctx context.Context, in *ResumeCopilotSessionRequest, opts ...grpc.CallOption) (*ResumeCopilotSessionResponse, error) + // ListSessions returns available Copilot sessions for a working directory. + ListSessions(ctx context.Context, in *ListCopilotSessionsRequest, opts ...grpc.CallOption) (*ListCopilotSessionsResponse, error) + // Initialize performs first-run configuration (model/reasoning setup). + // When called through gRPC, uses provided config values rather than interactive prompts. + Initialize(ctx context.Context, in *InitializeCopilotRequest, opts ...grpc.CallOption) (*InitializeCopilotResponse, error) + // SendMessage sends a prompt to the agent and blocks until processing completes. + SendMessage(ctx context.Context, in *SendCopilotMessageRequest, opts ...grpc.CallOption) (*SendCopilotMessageResponse, error) + // GetUsageMetrics returns cumulative usage metrics for a session. + GetUsageMetrics(ctx context.Context, in *GetCopilotUsageMetricsRequest, opts ...grpc.CallOption) (*GetCopilotUsageMetricsResponse, error) + // GetFileChanges returns files created, modified, or deleted during the 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) +} + +type copilotServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewCopilotServiceClient(cc grpc.ClientConnInterface) CopilotServiceClient { + return &copilotServiceClient{cc} +} + +func (c *copilotServiceClient) CreateSession(ctx context.Context, in *CreateCopilotSessionRequest, opts ...grpc.CallOption) (*CreateCopilotSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateCopilotSessionResponse) + err := c.cc.Invoke(ctx, CopilotService_CreateSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *copilotServiceClient) ResumeSession(ctx context.Context, in *ResumeCopilotSessionRequest, opts ...grpc.CallOption) (*ResumeCopilotSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResumeCopilotSessionResponse) + err := c.cc.Invoke(ctx, CopilotService_ResumeSession_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) 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) 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 +} + +// CopilotServiceServer is the server API for CopilotService service. +// All implementations must embed UnimplementedCopilotServiceServer +// for forward compatibility. +// +// CopilotService provides Copilot agent capabilities to extensions. +// Extensions can create sessions, send messages, track file changes, and collect +// usage metrics. Sessions run in headless/autopilot mode by default when invoked +// via gRPC, suppressing all console output. +type CopilotServiceServer interface { + // CreateSession creates a new Copilot agent session with the given configuration. + // The session can be reused for multiple SendMessage calls without re-bootstrapping. + CreateSession(context.Context, *CreateCopilotSessionRequest) (*CreateCopilotSessionResponse, error) + // ResumeSession resumes an existing Copilot session by its session ID. + ResumeSession(context.Context, *ResumeCopilotSessionRequest) (*ResumeCopilotSessionResponse, error) + // ListSessions returns available Copilot sessions for a working directory. + ListSessions(context.Context, *ListCopilotSessionsRequest) (*ListCopilotSessionsResponse, error) + // Initialize performs first-run configuration (model/reasoning setup). + // When called through gRPC, uses provided config values rather than interactive prompts. + Initialize(context.Context, *InitializeCopilotRequest) (*InitializeCopilotResponse, error) + // SendMessage sends a prompt to the agent and blocks until processing completes. + SendMessage(context.Context, *SendCopilotMessageRequest) (*SendCopilotMessageResponse, error) + // GetUsageMetrics returns cumulative usage metrics for a session. + GetUsageMetrics(context.Context, *GetCopilotUsageMetricsRequest) (*GetCopilotUsageMetricsResponse, error) + // GetFileChanges returns files created, modified, or deleted during the session. + GetFileChanges(context.Context, *GetCopilotFileChangesRequest) (*GetCopilotFileChangesResponse, error) + // StopSession stops and cleans up a Copilot agent session. + StopSession(context.Context, *StopCopilotSessionRequest) (*EmptyResponse, 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) CreateSession(context.Context, *CreateCopilotSessionRequest) (*CreateCopilotSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateSession not implemented") +} +func (UnimplementedCopilotServiceServer) ResumeSession(context.Context, *ResumeCopilotSessionRequest) (*ResumeCopilotSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ResumeSession not implemented") +} +func (UnimplementedCopilotServiceServer) ListSessions(context.Context, *ListCopilotSessionsRequest) (*ListCopilotSessionsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListSessions not implemented") +} +func (UnimplementedCopilotServiceServer) Initialize(context.Context, *InitializeCopilotRequest) (*InitializeCopilotResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Initialize 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) 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_CreateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateCopilotSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).CreateSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_CreateSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).CreateSession(ctx, req.(*CreateCopilotSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CopilotService_ResumeSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResumeCopilotSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CopilotServiceServer).ResumeSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CopilotService_ResumeSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CopilotServiceServer).ResumeSession(ctx, req.(*ResumeCopilotSessionRequest)) + } + 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_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_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) +} + +// 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: "CreateSession", + Handler: _CopilotService_CreateSession_Handler, + }, + { + MethodName: "ResumeSession", + Handler: _CopilotService_ResumeSession_Handler, + }, + { + MethodName: "ListSessions", + Handler: _CopilotService_ListSessions_Handler, + }, + { + MethodName: "Initialize", + Handler: _CopilotService_Initialize_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, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "copilot.proto", +} diff --git a/cli/azd/pkg/watch/watch.go b/cli/azd/pkg/watch/watch.go index 0e18043598e..5c1b676e35a 100644 --- a/cli/azd/pkg/watch/watch.go +++ b/cli/azd/pkg/watch/watch.go @@ -19,6 +19,7 @@ import ( type Watcher interface { PrintChangedFiles(ctx context.Context) + GetFileChanges() []FileChange } type fileWatcher struct { @@ -204,3 +205,42 @@ 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 +} + +// GetFileChanges returns all file changes tracked by the watcher as structured data. +func (fw *fileWatcher) GetFileChanges() []FileChange { + fw.mu.Lock() + defer fw.mu.Unlock() + + changes := make([]FileChange, 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}) + } + + 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..734f08cec75 --- /dev/null +++ b/cli/azd/pkg/watch/watch_test.go @@ -0,0 +1,135 @@ +// 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) + + // Create a file + testFile := filepath.Join(dir, "test.txt") + err = os.WriteFile(testFile, []byte("hello"), 0600) + require.NoError(t, err) + + // Wait for the watcher to pick up the change + time.Sleep(200 * time.Millisecond) + + changes := watcher.GetFileChanges() + require.NotEmpty(t, changes) + + found := false + for _, change := range changes { + if filepath.Base(change.Path) == "test.txt" && change.ChangeType == FileCreated { + found = true + break + } + } + require.True(t, found, "expected to find created file test.txt in changes") +} + +func TestGetFileChanges_ModifiedFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Create a file before starting the watcher + 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) + + // Modify the file + err = os.WriteFile(testFile, []byte("modified"), 0600) + require.NoError(t, err) + + // Wait for the watcher to pick up the change + time.Sleep(200 * time.Millisecond) + + changes := watcher.GetFileChanges() + require.NotEmpty(t, changes) + + found := false + for _, change := range changes { + if filepath.Base(change.Path) == "existing.txt" && change.ChangeType == FileModified { + found = true + break + } + } + require.True(t, found, "expected to find modified file existing.txt in changes") +} + +func TestGetFileChanges_DeletedFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Create a file before starting the watcher + 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) + + // Delete the file + err = os.Remove(testFile) + require.NoError(t, err) + + // Wait for the watcher to pick up the change + time.Sleep(200 * time.Millisecond) + + changes := watcher.GetFileChanges() + require.NotEmpty(t, changes) + + found := false + for _, change := range changes { + if filepath.Base(change.Path) == "deleteme.txt" && change.ChangeType == FileDeleted { + found = true + break + } + } + require.True(t, found, "expected to find deleted file deleteme.txt in changes") +} + +func TestFileChangeType_Values(t *testing.T) { + require.Equal(t, FileChangeType(0), FileCreated) + require.Equal(t, FileChangeType(1), FileModified) + require.Equal(t, FileChangeType(2), FileDeleted) +} From 5dde87900f2926b88a84b8eff16eb34571c85f29 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 10:18:58 -0700 Subject: [PATCH 02/11] feat(demo): add copilot chat command demonstrating CopilotService gRPC API Add azd demo copilot command that implements an interactive chat loop showcasing the full CopilotService gRPC integration: - CreateSession with configurable model, reasoning, system message, mode - Initialize to display resolved model/reasoning configuration - SendMessage in an interactive chat loop with per-turn usage metrics - GetUsageMetrics for cumulative session stats on exit - GetFileChanges for file change summary (created/modified/deleted) - StopSession for cleanup - ListSessions + ResumeSession via --resume flag Flags: --model, --reasoning-effort, --system-message, --mode, --resume Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../microsoft.azd.demo/extension.yaml | 3 + .../internal/cmd/copilot.go | 431 ++++++++++++++++++ .../microsoft.azd.demo/internal/cmd/root.go | 1 + 3 files changed, 435 insertions(+) create mode 100644 cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go 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..fc46bf9fe43 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go @@ -0,0 +1,431 @@ +// 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 full CopilotService gRPC integration by running an interactive +chat loop. Creates a session, sends messages, displays usage metrics per turn, +and shows cumulative stats and file changes 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() + prompt := 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() + + var sessionID string + + if flags.resume { + // List and resume an existing session + resumed, err := resumeExistingSession(ctx, copilot, prompt) + if err != nil { + return err + } + if resumed != "" { + sessionID = resumed + } + } + + if sessionID == "" { + // Create a new session + created, err := createNewSession(ctx, copilot, flags) + if err != nil { + return err + } + sessionID = created + } + + // Run the chat loop + if err := chatLoop(ctx, copilot, prompt, sessionID); err != nil { + return err + } + + // Show cumulative usage metrics + showCumulativeMetrics(ctx, copilot, sessionID) + + // Show file changes + showFileChanges(ctx, copilot, sessionID) + + // Stop the session + _, err := copilot.StopSession(ctx, &azdext.StopCopilotSessionRequest{ + SessionId: sessionID, + }) + if err != nil { + fmt.Printf(" %s Failed to stop session: %v\n", color.RedString("✗"), err) + } else { + fmt.Printf(" %s Session stopped\n", color.GreenString("✓")) + } + + fmt.Println() + return nil +} + +// resumeExistingSession lists available sessions and prompts the user to select one. +// Returns the session handle if resumed, or empty string if user chose to start new. +func resumeExistingSession( + ctx context.Context, + copilot azdext.CopilotServiceClient, + prompt azdext.PromptServiceClient, +) (string, error) { + 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 "", nil + } + + if len(listResp.Sessions) == 0 { + fmt.Println(color.HiBlackString(" No existing sessions found. Starting new session.")) + fmt.Println() + return "", nil + } + + // Build choices for the session picker + 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 := prompt.Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a session", + Choices: choices, + }, + }) + if err != nil { + return "", nil + } + + idx := int(selectResp.GetValue()) + if idx < 0 || idx >= len(choices) || choices[idx].Value == "__new__" { + fmt.Println() + return "", nil + } + + selectedSDKSessionID := choices[idx].Value + + resumeResp, err := copilot.ResumeSession(ctx, &azdext.ResumeCopilotSessionRequest{ + SessionId: selectedSDKSessionID, + Headless: true, + }) + if err != nil { + return "", fmt.Errorf("failed to resume session: %w", err) + } + + fmt.Printf(" %s Resumed session: %s\n", + color.GreenString("✓"), color.CyanString(resumeResp.SessionId)) + fmt.Println() + + return resumeResp.SessionId, nil +} + +// createNewSession creates a fresh Copilot session with the given flags. +func createNewSession( + ctx context.Context, + copilot azdext.CopilotServiceClient, + flags copilotFlags, +) (string, error) { + fmt.Println(color.HiYellowString(" Creating Copilot session...")) + + createResp, err := copilot.CreateSession(ctx, &azdext.CreateCopilotSessionRequest{ + Model: flags.model, + ReasoningEffort: flags.reasoningEffort, + SystemMessage: flags.systemMessage, + Mode: flags.mode, + Headless: true, + }) + if err != nil { + return "", fmt.Errorf("failed to create session: %w", err) + } + + sessionID := createResp.SessionId + fmt.Printf(" %s Session created: %s\n", + color.GreenString("✓"), color.CyanString(sessionID)) + + // Initialize the session + initResp, err := copilot.Initialize(ctx, &azdext.InitializeCopilotRequest{ + SessionId: sessionID, + 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("•")) + } + } + + fmt.Println() + fmt.Println(color.HiBlackString(" Type your message and press Enter. Type 'exit' or 'quit' to stop.")) + fmt.Println() + + return sessionID, nil +} + +// chatLoop runs the interactive prompt → send → display cycle. +func chatLoop( + ctx context.Context, + copilot azdext.CopilotServiceClient, + promptSvc azdext.PromptServiceClient, + sessionID string, +) error { + turn := 0 + for { + turn++ + + // Prompt user for input + resp, err := promptSvc.Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: fmt.Sprintf("[%d] You", turn), + Placeholder: "Type a message...", + }, + }) + if err != nil { + return 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 + } + + // Send message to agent + fmt.Printf(" %s Sending to agent...\n", color.HiBlackString("⏳")) + + sendResp, err := copilot.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ + SessionId: sessionID, + Prompt: input, + }) + if err != nil { + fmt.Printf(" %s Error: %v\n", color.RedString("✗"), err) + fmt.Println() + continue + } + + // Display turn usage metrics + if sendResp.Usage != nil { + displayTurnUsage(turn, sendResp.Usage) + } + + fmt.Println() + } + + return nil +} + +// displayTurnUsage shows usage metrics for a single turn. +func displayTurnUsage(turn int, usage *azdext.CopilotUsageMetrics) { + fmt.Printf(" %s Turn %d complete", color.GreenString("✓"), turn) + if usage.Model != "" { + fmt.Printf(" (%s)", color.CyanString(usage.Model)) + } + fmt.Println() + + fmt.Printf(" %s Tokens: %s in / %s out / %s total\n", + color.HiBlackString("•"), + formatTokens(usage.InputTokens), + formatTokens(usage.OutputTokens), + formatTokens(usage.TotalTokens)) + + if usage.DurationMs > 0 { + fmt.Printf(" %s Duration: %s\n", + color.HiBlackString("•"), formatDuration(usage.DurationMs)) + } + if usage.PremiumRequests > 0 { + fmt.Printf(" %s Premium requests: %.0f\n", + color.HiBlackString("•"), usage.PremiumRequests) + } +} + +// 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 files changed during the session. +func showFileChanges( + ctx context.Context, + copilot azdext.CopilotServiceClient, + sessionID string, +) { + changesResp, err := copilot.GetFileChanges(ctx, &azdext.GetCopilotFileChangesRequest{ + SessionId: sessionID, + }) + if err != nil { + return + } + + if 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 } From a422008f097bdff7cefea35d684f5e32881a2ad9 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 11:37:07 -0700 Subject: [PATCH 03/11] fix: eagerly start copilot client in CreateSession to surface errors early The CopilotService.CreateSession gRPC handler was only creating the CopilotAgent struct without starting the underlying Copilot SDK client. This caused startup errors (auth, binary download, runtime crashes) to surface only on the first SendMessage call, making debugging confusing. Changes: - Add CopilotAgent.EnsureStarted() public method for eager client startup - Call EnsureStarted() in both CreateSession and ResumeSession handlers - Fix double 'failed to create session' error wrapping in ensureSession Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/agent/copilot_agent.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index 2e68a9665b0..00256be3616 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -457,6 +457,21 @@ 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. func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string) error { if a.session != nil { @@ -541,7 +556,7 @@ func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string log.Println("[copilot] Creating session...") session, err := a.clientManager.Client().CreateSession(ctx, 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 From 8501f0eb8c3cd3e8ec07c34c19750e56e9bd3886 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 12:11:23 -0700 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20simplify=20CopilotService=20A?= =?UTF-8?q?PI=20=E2=80=94=20remove=20CreateSession/ResumeSession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the CopilotService gRPC API based on design review: API changes (8 RPCs → 6): - Remove CreateSession, ResumeSession — session lifecycle is now implicit - SendMessage handles session creation/resumption inline via optional session_id field. First call creates a session, subsequent calls reuse it. Passing an SDK session_id resumes that session. - SendMessage response now includes per-turn file changes - Initialize is session-independent — just warms up client and resolves config Architecture changes: - CopilotAgent now owns file change accumulation (watcher per SendMessage, changes appended to agent cache) - New PrintSessionMetrics() prints usage + file changes (replaces auto-print) - Remove auto-printing of usage/file changes from SendMessage — callers decide when to display (init calls PrintSessionMetrics at the end) - gRPC service is thin routing layer — maps session IDs to agents Demo extension updated to use new API — SendMessage with inline config, Initialize for warmup, GetUsageMetrics/GetFileChanges at end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/init.go | 9 +- .../internal/cmd/copilot.go | 224 +++---- cli/azd/grpc/proto/copilot.proto | 106 ++- cli/azd/internal/agent/copilot_agent.go | 96 ++- cli/azd/internal/agent/types.go | 5 +- .../internal/grpcserver/copilot_service.go | 299 ++++----- cli/azd/pkg/azdext/copilot.pb.go | 622 ++++++------------ cli/azd/pkg/azdext/copilot_grpc.pb.go | 152 ++--- 8 files changed, 565 insertions(+), 948 deletions(-) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index de21e5df07d..91e4eba9bb3 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -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,11 +532,8 @@ When complete, provide a brief summary of what was accomplished.` _ = azdCtx.ClearCopilotSession() } - // Show usage - if usage := result.Usage.Format(); usage != "" { - i.console.Message(ctx, "") - i.console.Message(ctx, usage) - } + // Show session metrics (usage + file changes) + copilotAgent.PrintSessionMetrics(ctx) i.console.Message(ctx, "") return nil diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go index fc46bf9fe43..48c926bc4a7 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go @@ -24,9 +24,9 @@ func newCopilotCommand() *cobra.Command { cmd := &cobra.Command{ Use: "copilot", Short: "Interactive Copilot chat loop demonstrating the CopilotService gRPC API.", - Long: `Demonstrates the full CopilotService gRPC integration by running an interactive -chat loop. Creates a session, sends messages, displays usage metrics per turn, -and shows cumulative stats and file changes on exit. + 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 { @@ -76,84 +76,91 @@ type copilotFlags struct { func runCopilotChat(ctx context.Context, client *azdext.AzdClient, flags copilotFlags) error { copilot := client.Copilot() - prompt := client.Prompt() + promptSvc := client.Prompt() fmt.Println() fmt.Println(color.HiCyanString("╔══════════════════════════════════════╗")) - fmt.Println(color.HiCyanString("║") + color.HiWhiteString(" Copilot Chat — Demo Extension ") + + fmt.Println(color.HiCyanString("║") + + color.HiWhiteString(" Copilot Chat — Demo Extension ") + color.HiCyanString(" ║")) fmt.Println(color.HiCyanString("╚══════════════════════════════════════╝")) fmt.Println() - var sessionID string - - if flags.resume { - // List and resume an existing session - resumed, err := resumeExistingSession(ctx, copilot, prompt) - if err != nil { - return err + // 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 resumed != "" { - sessionID = resumed + 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("•")) } } - if sessionID == "" { - // Create a new session - created, err := createNewSession(ctx, copilot, flags) - if err != nil { - return err - } - sessionID = created + // Determine starting session ID (empty = new, or from --resume) + var sessionID string + if flags.resume { + sessionID = pickExistingSession(ctx, copilot, promptSvc) } - // Run the chat loop - if err := chatLoop(ctx, copilot, prompt, sessionID); err != nil { + 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 usage metrics - showCumulativeMetrics(ctx, copilot, sessionID) + // Show cumulative metrics and file changes + if sessionID != "" { + showCumulativeMetrics(ctx, copilot, sessionID) + showFileChanges(ctx, copilot, sessionID) - // Show file changes - showFileChanges(ctx, copilot, sessionID) - - // Stop the session - _, err := copilot.StopSession(ctx, &azdext.StopCopilotSessionRequest{ - SessionId: sessionID, - }) - if err != nil { - fmt.Printf(" %s Failed to stop session: %v\n", color.RedString("✗"), err) - } else { - fmt.Printf(" %s Session stopped\n", color.GreenString("✓")) + // 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.Printf(" %s Session stopped\n", color.GreenString("✓")) + } } fmt.Println() return nil } -// resumeExistingSession lists available sessions and prompts the user to select one. -// Returns the session handle if resumed, or empty string if user chose to start new. -func resumeExistingSession( +// 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, - prompt azdext.PromptServiceClient, -) (string, error) { + 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 "", nil + return "" } if len(listResp.Sessions) == 0 { - fmt.Println(color.HiBlackString(" No existing sessions found. Starting new session.")) - fmt.Println() - return "", nil + fmt.Println(color.HiBlackString(" No existing sessions found.")) + return "" } - // Build choices for the session picker choices := []*azdext.SelectChoice{ {Value: "__new__", Label: "Start a new session"}, } @@ -175,102 +182,41 @@ func resumeExistingSession( }) } - selectResp, err := prompt.Select(ctx, &azdext.SelectRequest{ + selectResp, err := promptSvc.Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ Message: "Select a session", Choices: choices, }, }) if err != nil { - return "", nil + return "" } idx := int(selectResp.GetValue()) if idx < 0 || idx >= len(choices) || choices[idx].Value == "__new__" { - fmt.Println() - return "", nil - } - - selectedSDKSessionID := choices[idx].Value - - resumeResp, err := copilot.ResumeSession(ctx, &azdext.ResumeCopilotSessionRequest{ - SessionId: selectedSDKSessionID, - Headless: true, - }) - if err != nil { - return "", fmt.Errorf("failed to resume session: %w", err) - } - - fmt.Printf(" %s Resumed session: %s\n", - color.GreenString("✓"), color.CyanString(resumeResp.SessionId)) - fmt.Println() - - return resumeResp.SessionId, nil -} - -// createNewSession creates a fresh Copilot session with the given flags. -func createNewSession( - ctx context.Context, - copilot azdext.CopilotServiceClient, - flags copilotFlags, -) (string, error) { - fmt.Println(color.HiYellowString(" Creating Copilot session...")) - - createResp, err := copilot.CreateSession(ctx, &azdext.CreateCopilotSessionRequest{ - Model: flags.model, - ReasoningEffort: flags.reasoningEffort, - SystemMessage: flags.systemMessage, - Mode: flags.mode, - Headless: true, - }) - if err != nil { - return "", fmt.Errorf("failed to create session: %w", err) + return "" } - sessionID := createResp.SessionId - fmt.Printf(" %s Session created: %s\n", - color.GreenString("✓"), color.CyanString(sessionID)) + sdkSessionID := choices[idx].Value + fmt.Printf(" %s Will resume session: %s\n", + color.GreenString("✓"), color.CyanString(sdkSessionID)) - // Initialize the session - initResp, err := copilot.Initialize(ctx, &azdext.InitializeCopilotRequest{ - SessionId: sessionID, - 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("•")) - } - } - - fmt.Println() - fmt.Println(color.HiBlackString(" Type your message and press Enter. Type 'exit' or 'quit' to stop.")) - fmt.Println() - - return sessionID, nil + 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, -) error { + flags copilotFlags, +) (string, error) { turn := 0 for { turn++ - // Prompt user for input resp, err := promptSvc.Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ Message: fmt.Sprintf("[%d] You", turn), @@ -278,7 +224,7 @@ func chatLoop( }, }) if err != nil { - return nil // user cancelled + return sessionID, nil // user cancelled } input := strings.TrimSpace(resp.Value) @@ -288,20 +234,34 @@ func chatLoop( break } - // Send message to agent fmt.Printf(" %s Sending to agent...\n", color.HiBlackString("⏳")) - sendResp, err := copilot.SendMessage(ctx, &azdext.SendCopilotMessageRequest{ - SessionId: sessionID, - Prompt: input, - }) + // SendMessage creates the session on the first call, reuses it after + sendReq := &azdext.SendCopilotMessageRequest{ + Prompt: input, + Headless: true, + } + 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 } - // Display turn usage metrics + // Capture the session ID from the response for reuse + sessionID = sendResp.SessionId + + // Display turn usage if sendResp.Usage != nil { displayTurnUsage(turn, sendResp.Usage) } @@ -309,10 +269,10 @@ func chatLoop( fmt.Println() } - return nil + return sessionID, nil } -// displayTurnUsage shows usage metrics for a single turn. +// displayTurnUsage shows brief usage metrics for a single turn. func displayTurnUsage(turn int, usage *azdext.CopilotUsageMetrics) { fmt.Printf(" %s Turn %d complete", color.GreenString("✓"), turn) if usage.Model != "" { @@ -330,10 +290,6 @@ func displayTurnUsage(turn int, usage *azdext.CopilotUsageMetrics) { fmt.Printf(" %s Duration: %s\n", color.HiBlackString("•"), formatDuration(usage.DurationMs)) } - if usage.PremiumRequests > 0 { - fmt.Printf(" %s Premium requests: %.0f\n", - color.HiBlackString("•"), usage.PremiumRequests) - } } // showCumulativeMetrics displays cumulative session usage. @@ -378,7 +334,7 @@ func showCumulativeMetrics( } } -// showFileChanges displays files changed during the session. +// showFileChanges displays accumulated file changes from the session. func showFileChanges( ctx context.Context, copilot azdext.CopilotServiceClient, @@ -387,11 +343,7 @@ func showFileChanges( changesResp, err := copilot.GetFileChanges(ctx, &azdext.GetCopilotFileChangesRequest{ SessionId: sessionID, }) - if err != nil { - return - } - - if len(changesResp.FileChanges) == 0 { + if err != nil || len(changesResp.FileChanges) == 0 { return } diff --git a/cli/azd/grpc/proto/copilot.proto b/cli/azd/grpc/proto/copilot.proto index 13c32c67e95..e7f850b6151 100644 --- a/cli/azd/grpc/proto/copilot.proto +++ b/cli/azd/grpc/proto/copilot.proto @@ -9,70 +9,50 @@ option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; import "models.proto"; // CopilotService provides Copilot agent capabilities to extensions. -// Extensions can create sessions, send messages, track file changes, and collect -// usage metrics. Sessions run in headless/autopilot mode by default when invoked -// via gRPC, suppressing all console output. +// 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 { - // CreateSession creates a new Copilot agent session with the given configuration. - // The session can be reused for multiple SendMessage calls without re-bootstrapping. - rpc CreateSession(CreateCopilotSessionRequest) returns (CreateCopilotSessionResponse); - - // ResumeSession resumes an existing Copilot session by its session ID. - rpc ResumeSession(ResumeCopilotSessionRequest) returns (ResumeCopilotSessionResponse); + // 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); - // Initialize performs first-run configuration (model/reasoning setup). - // When called through gRPC, uses provided config values rather than interactive prompts. - rpc Initialize(InitializeCopilotRequest) returns (InitializeCopilotResponse); - - // SendMessage sends a prompt to the agent and blocks until processing completes. + // 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 for a session. + // GetUsageMetrics returns cumulative usage metrics cached for a session. rpc GetUsageMetrics(GetCopilotUsageMetricsRequest) returns (GetCopilotUsageMetricsResponse); - // GetFileChanges returns files created, modified, or deleted during the session. + // 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); } -// --- Session lifecycle messages --- +// --- Initialize messages --- -// CreateCopilotSessionRequest configures a new Copilot agent session. -message CreateCopilotSessionRequest { - string model = 1; // Model to use (empty = default). - string reasoning_effort = 2; // Reasoning effort level (low, medium, high). - string system_message = 3; // Custom system message appended to the default prompt. - string mode = 4; // Agent mode (autopilot, interactive, plan). Defaults to autopilot. - bool debug = 5; // Enable debug logging. - string working_directory = 6; // Working directory for the session (empty = cwd). - bool headless = 7; // Suppress all console output. Defaults to true for gRPC. +// 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). } -// CreateCopilotSessionResponse contains the ID of the created session. -message CreateCopilotSessionResponse { - string session_id = 1; // Unique session handle for subsequent calls. +// 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. } -// ResumeCopilotSessionRequest resumes an existing session by ID. -message ResumeCopilotSessionRequest { - string session_id = 1; // Session ID to resume (from ListSessions or previous CreateSession). - string model = 2; // Optional model override for the resumed session. - string reasoning_effort = 3; // Optional reasoning effort override. - string system_message = 4; // Optional system message override. - string mode = 5; // Optional mode override. - bool debug = 6; // Enable debug logging. - bool headless = 7; // Suppress all console output. Defaults to true for gRPC. -} - -// ResumeCopilotSessionResponse contains the ID of the resumed session. -message ResumeCopilotSessionResponse { - string session_id = 1; // Session handle for subsequent calls. -} +// --- ListSessions messages --- // ListCopilotSessionsRequest requests available sessions for a directory. message ListCopilotSessionsRequest { @@ -91,37 +71,33 @@ message CopilotSessionMetadata { string summary = 3; // Optional session summary. } -// --- Agent operation messages --- - -// InitializeCopilotRequest configures model and reasoning for a session. -message InitializeCopilotRequest { - string session_id = 1; // Session to initialize. - string model = 2; // Model to use (overrides session config). - string reasoning_effort = 3; // Reasoning effort to use (overrides session config). -} - -// 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. -} +// --- 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 session_id = 1; // Session to send the message to. - string prompt = 2; // The prompt/message to send. + 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; // Optional: suppress console output (default true for gRPC). } // SendCopilotMessageResponse contains the result of the message. message SendCopilotMessageResponse { - string session_id = 1; // Session that processed the message. - CopilotUsageMetrics usage = 2; // Usage metrics for this message turn. + 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 usage metrics for a session. +// GetCopilotUsageMetricsRequest requests cached usage metrics for a session. message GetCopilotUsageMetricsRequest { string session_id = 1; // Session to get metrics for. } @@ -144,7 +120,7 @@ message CopilotUsageMetrics { // --- File change messages --- -// GetCopilotFileChangesRequest requests file changes for a session. +// GetCopilotFileChangesRequest requests cached file changes for a session. message GetCopilotFileChangesRequest { string session_id = 1; // Session to get file changes for. } diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index 00256be3616..a1a105e3260 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -47,12 +47,13 @@ type CopilotAgent struct { onSessionStarted func(sessionID string) // Runtime state - clientStarted bool - session *copilot.Session - sessionID string - sessionCtx context.Context - display *AgentDisplay // last display for usage metrics (interactive mode) - cumulativeUsage UsageMetrics // cumulative metrics across multiple SendMessage calls + clientStarted bool + session *copilot.Session + sessionID string + sessionCtx context.Context + display *AgentDisplay // last display for usage metrics (interactive mode) + cumulativeUsage UsageMetrics // cumulative metrics across multiple SendMessage calls + accumulatedFileChanges []watch.FileChange // accumulated file changes across all SendMessage calls // Cleanup — ordered slice for deterministic teardown cleanupTasks []cleanupTask @@ -319,9 +320,6 @@ func (a *CopilotAgent) sendMessageInteractive( displayCancel() time.Sleep(100 * time.Millisecond) cleanup() - if watcher != nil { - watcher.PrintChangedFiles(ctx) - } }() unsubscribe := a.session.On(display.HandleEvent) @@ -342,9 +340,12 @@ func (a *CopilotAgent) sendMessageInteractive( turnUsage := display.GetUsageMetrics() a.accumulateUsage(turnUsage) + turnFileChanges := a.collectFileChanges(watcher) + return &AgentResult{ - SessionID: a.sessionID, - Usage: turnUsage, + SessionID: a.sessionID, + Usage: turnUsage, + FileChanges: turnFileChanges, }, nil } @@ -354,6 +355,9 @@ func (a *CopilotAgent) sendMessageHeadless( ) (*AgentResult, error) { collector := NewHeadlessCollector() + var watcher watch.Watcher + watcher, _ = watch.NewWatcher(ctx) + unsubscribe := a.session.On(collector.HandleEvent) defer unsubscribe() @@ -372,9 +376,12 @@ func (a *CopilotAgent) sendMessageHeadless( turnUsage := collector.GetUsageMetrics() a.accumulateUsage(turnUsage) + turnFileChanges := a.collectFileChanges(watcher) + return &AgentResult{ - SessionID: a.sessionID, - Usage: turnUsage, + SessionID: a.sessionID, + Usage: turnUsage, + FileChanges: turnFileChanges, }, nil } @@ -398,6 +405,69 @@ func (a *CopilotAgent) GetCumulativeUsage() UsageMetrics { return a.cumulativeUsage } +// 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.FileChange { + if watcher == nil { + return nil + } + + turnChanges := watcher.GetFileChanges() + if len(turnChanges) > 0 { + a.accumulatedFileChanges = append(a.accumulatedFileChanges, turnChanges...) + } + return turnChanges +} + +// GetAccumulatedFileChanges returns all file changes across all SendMessage calls. +func (a *CopilotAgent) GetAccumulatedFileChanges() []watch.FileChange { + return a.accumulatedFileChanges +} + +// PrintSessionMetrics prints cumulative usage metrics and accumulated file changes. +// Call this after the last SendMessage to display session summary. +func (a *CopilotAgent) PrintSessionMetrics(ctx context.Context) { + // Print usage metrics + usageStr := a.cumulativeUsage.Format() + if usageStr != "" { + a.console.Message(ctx, "") + a.console.Message(ctx, usageStr) + } + + // Print file changes + if len(a.accumulatedFileChanges) > 0 { + printFileChanges(a.accumulatedFileChanges) + } +} + +// printFileChanges prints file changes in the standard format. +func printFileChanges(changes []watch.FileChange) { + fmt.Println(output.WithGrayFormat("\n| Files changed:")) + + cwd, cwdErr := os.Getwd() + getDisplayPath := func(file string) string { + if cwdErr != nil { + return file + } + if relPath, err := filepath.Rel(cwd, file); err == nil { + return relPath + } + return file + } + + for _, change := range changes { + path := getDisplayPath(change.Path) + switch change.ChangeType { + case watch.FileCreated: + fmt.Println(output.WithGrayFormat("| "), output.WithSuccessFormat("+ Created "), path) + case watch.FileModified: + fmt.Println(output.WithGrayFormat("| "), output.WithWarningFormat("± Modified "), path) + case watch.FileDeleted: + fmt.Println(output.WithGrayFormat("| "), output.WithErrorFormat("- Deleted "), path) + } + } +} + // SendMessageWithRetry wraps SendMessage with interactive retry-on-error UX. func (a *CopilotAgent) SendMessageWithRetry(ctx context.Context, prompt string, opts ...SendOption) (*AgentResult, error) { for { diff --git a/cli/azd/internal/agent/types.go b/cli/azd/internal/agent/types.go index 1436665486c..7561f71401a 100644 --- a/cli/azd/internal/agent/types.go +++ b/cli/azd/internal/agent/types.go @@ -10,14 +10,17 @@ import ( 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.FileChange } // UsageMetrics tracks resource consumption for an agent session. diff --git a/cli/azd/internal/grpcserver/copilot_service.go b/cli/azd/internal/grpcserver/copilot_service.go index 6731038ae8b..c5b2cc42039 100644 --- a/cli/azd/internal/grpcserver/copilot_service.go +++ b/cli/azd/internal/grpcserver/copilot_service.go @@ -5,12 +5,9 @@ package grpcserver import ( "context" - "fmt" - "log" "os" "path/filepath" "sync" - "sync/atomic" "github.com/azure/azure-dev/cli/azd/internal/agent" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -19,113 +16,54 @@ import ( "google.golang.org/grpc/status" ) -// sessionCounter generates unique session handles without external dependencies. -var sessionCounter atomic.Int64 - -// managedCopilotSession tracks an active Copilot agent session and its associated resources. -type managedCopilotSession struct { - agent *agent.CopilotAgent - watcher watch.Watcher - watchCancel context.CancelFunc - resumeSessionID string // SDK session ID for resumption in SendMessage - workingDir string // working directory for this session -} - // copilotService implements the CopilotServiceServer gRPC interface. -// It manages agent sessions, delegating to CopilotAgent for all operations. +// 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.CopilotAgentFactory mu sync.RWMutex - sessions map[string]*managedCopilotSession + sessions map[string]*agent.CopilotAgent } // NewCopilotService creates a new CopilotService gRPC server. func NewCopilotService(agentFactory *agent.CopilotAgentFactory) azdext.CopilotServiceServer { return &copilotService{ agentFactory: agentFactory, - sessions: make(map[string]*managedCopilotSession), + sessions: make(map[string]*agent.CopilotAgent), } } -// CreateSession creates a new Copilot agent session with the given configuration. -func (s *copilotService) CreateSession( - ctx context.Context, req *azdext.CreateCopilotSessionRequest, -) (*azdext.CreateCopilotSessionResponse, error) { - 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, status.Errorf(codes.Internal, "failed to create copilot agent: %v", err) - } - - cwd := req.WorkingDirectory - if cwd == "" { - cwd, _ = os.Getwd() - } - - watchCtx, watchCancel := context.WithCancel(context.Background()) //nolint:gosec // cancel stored in session - watcher, watchErr := watch.NewWatcher(watchCtx) - if watchErr != nil { - log.Printf("[copilot-service] file watcher unavailable: %v", watchErr) - } - - sessionHandle := fmt.Sprintf("copilot-%d", sessionCounter.Add(1)) - - s.mu.Lock() - s.sessions[sessionHandle] = &managedCopilotSession{ - agent: copilotAgent, - watcher: watcher, - watchCancel: watchCancel, - workingDir: cwd, +// 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)) } - s.mu.Unlock() - - return &azdext.CreateCopilotSessionResponse{ - SessionId: sessionHandle, - }, nil -} - -// ResumeSession resumes an existing Copilot session by ID. -func (s *copilotService) ResumeSession( - ctx context.Context, req *azdext.ResumeCopilotSessionRequest, -) (*azdext.ResumeCopilotSessionResponse, error) { - if req.SessionId == "" { - return nil, status.Error(codes.InvalidArgument, "session_id is required") + if req.ReasoningEffort != "" { + opts = append(opts, agent.WithReasoningEffort(req.ReasoningEffort)) } - opts := buildAgentOptions( - req.Model, req.ReasoningEffort, req.SystemMessage, req.Mode, req.Debug, req.Headless, - ) - - copilotAgent, err := s.agentFactory.Create(ctx, opts...) + tempAgent, err := s.agentFactory.Create(ctx, opts...) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to create copilot agent: %v", err) + return nil, status.Errorf(codes.Internal, "failed to create agent: %v", err) } + defer tempAgent.Stop() - watchCtx, watchCancel := context.WithCancel(context.Background()) //nolint:gosec // cancel stored in session - watcher, watchErr := watch.NewWatcher(watchCtx) - if watchErr != nil { - log.Printf("[copilot-service] file watcher unavailable: %v", watchErr) - } - - sessionHandle := fmt.Sprintf("copilot-%d", sessionCounter.Add(1)) - - s.mu.Lock() - s.sessions[sessionHandle] = &managedCopilotSession{ - agent: copilotAgent, - watcher: watcher, - watchCancel: watchCancel, - resumeSessionID: req.SessionId, + result, err := tempAgent.Initialize(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to initialize copilot: %v", err) } - s.mu.Unlock() - return &azdext.ResumeCopilotSessionResponse{ - SessionId: sessionHandle, + return &azdext.InitializeCopilotResponse{ + Model: result.Model, + ReasoningEffort: result.ReasoningEffort, + IsFirstRun: result.IsFirstRun, }, nil } @@ -138,14 +76,15 @@ func (s *copilotService) ListSessions( var err error cwd, err = os.Getwd() if err != nil { - return nil, status.Errorf(codes.Internal, "failed to get working directory: %v", err) + return nil, status.Errorf(codes.Internal, + "failed to get working directory: %v", err) } } - // Create a temporary agent to list sessions 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) + return nil, status.Errorf(codes.Internal, + "failed to create agent for listing sessions: %v", err) } defer tempAgent.Stop() @@ -155,132 +94,119 @@ func (s *copilotService) ListSessions( } protoSessions := make([]*azdext.CopilotSessionMetadata, len(sessions)) - for i, session := range sessions { + for i, sess := range sessions { summary := "" - if session.Summary != nil { - summary = *session.Summary + if sess.Summary != nil { + summary = *sess.Summary } - protoSessions[i] = &azdext.CopilotSessionMetadata{ - SessionId: session.SessionID, - ModifiedTime: session.ModifiedTime, + SessionId: sess.SessionID, + ModifiedTime: sess.ModifiedTime, Summary: summary, } } - return &azdext.ListCopilotSessionsResponse{ - Sessions: protoSessions, - }, nil + return &azdext.ListCopilotSessionsResponse{Sessions: protoSessions}, nil } -// Initialize performs first-run configuration for a session. -func (s *copilotService) Initialize( - ctx context.Context, req *azdext.InitializeCopilotRequest, -) (*azdext.InitializeCopilotResponse, error) { - managed, err := s.getSession(req.SessionId) +// 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, isResume, err := s.resolveOrCreateAgent(ctx, req) if err != nil { return nil, err } - // Apply model/reasoning overrides if provided - if req.Model != "" { - agent.WithModel(req.Model)(managed.agent) - } - if req.ReasoningEffort != "" { - agent.WithReasoningEffort(req.ReasoningEffort)(managed.agent) + var sendOpts []agent.SendOption + if isResume { + sendOpts = append(sendOpts, agent.WithSessionID(req.SessionId)) } - result, err := managed.agent.Initialize(ctx) + result, err := copilotAgent.SendMessage(ctx, req.Prompt, sendOpts...) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to initialize agent: %v", err) + return nil, status.Errorf(codes.Internal, "copilot agent error: %v", err) } - return &azdext.InitializeCopilotResponse{ - Model: result.Model, - ReasoningEffort: result.ReasoningEffort, - IsFirstRun: result.IsFirstRun, + // 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 } -// SendMessage sends a prompt to the agent and blocks until processing completes. -func (s *copilotService) SendMessage( +// 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, -) (*azdext.SendCopilotMessageResponse, error) { - managed, err := s.getSession(req.SessionId) - if err != nil { - return nil, err - } +) (copilotAgent *agent.CopilotAgent, 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, nil + } - if req.Prompt == "" { - return nil, status.Error(codes.InvalidArgument, "prompt cannot be empty") + // Not in our map — treat as an SDK session ID to resume + isResume = true } - // Pass the resume session ID if this session was created via ResumeSession - var sendOpts []agent.SendOption - if managed.resumeSessionID != "" { - sendOpts = append(sendOpts, agent.WithSessionID(managed.resumeSessionID)) - // Clear after first use — subsequent calls reuse the same session - managed.resumeSessionID = "" - } + // Create a new agent + opts := buildAgentOptions( + req.Model, req.ReasoningEffort, req.SystemMessage, + req.Mode, req.Debug, req.Headless, + ) - result, err := managed.agent.SendMessage(ctx, req.Prompt, sendOpts...) + copilotAgent, err = s.agentFactory.Create(ctx, opts...) if err != nil { - return nil, status.Errorf(codes.Internal, "copilot agent error: %v", err) + return nil, false, status.Errorf(codes.Internal, + "failed to create copilot agent: %v", err) } - return &azdext.SendCopilotMessageResponse{ - SessionId: req.SessionId, - Usage: convertUsageMetrics(result.Usage), - }, nil + return copilotAgent, isResume, nil } -// GetUsageMetrics returns cumulative usage metrics for a session. +// GetUsageMetrics returns cumulative usage metrics cached on the agent. func (s *copilotService) GetUsageMetrics( ctx context.Context, req *azdext.GetCopilotUsageMetricsRequest, ) (*azdext.GetCopilotUsageMetricsResponse, error) { - managed, err := s.getSession(req.SessionId) + copilotAgent, err := s.getAgent(req.SessionId) if err != nil { return nil, err } return &azdext.GetCopilotUsageMetricsResponse{ - Usage: convertUsageMetrics(managed.agent.GetCumulativeUsage()), + Usage: convertUsageMetrics(copilotAgent.GetCumulativeUsage()), }, nil } -// GetFileChanges returns files created, modified, or deleted during the session. +// GetFileChanges returns accumulated file changes cached on the agent. func (s *copilotService) GetFileChanges( ctx context.Context, req *azdext.GetCopilotFileChangesRequest, ) (*azdext.GetCopilotFileChangesResponse, error) { - managed, err := s.getSession(req.SessionId) + copilotAgent, err := s.getAgent(req.SessionId) if err != nil { return nil, err } - if managed.watcher == nil { - return &azdext.GetCopilotFileChangesResponse{}, nil - } - - changes := managed.watcher.GetFileChanges() - 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 &azdext.GetCopilotFileChangesResponse{ - FileChanges: protoChanges, + FileChanges: convertFileChanges(copilotAgent.GetAccumulatedFileChanges()), }, nil } @@ -289,7 +215,7 @@ func (s *copilotService) StopSession( ctx context.Context, req *azdext.StopCopilotSessionRequest, ) (*azdext.EmptyResponse, error) { s.mu.Lock() - managed, ok := s.sessions[req.SessionId] + copilotAgent, ok := s.sessions[req.SessionId] if ok { delete(s.sessions, req.SessionId) } @@ -299,16 +225,12 @@ func (s *copilotService) StopSession( return nil, status.Errorf(codes.NotFound, "session %q not found", req.SessionId) } - if managed.watchCancel != nil { - managed.watchCancel() - } - managed.agent.Stop() - + copilotAgent.Stop() return &azdext.EmptyResponse{}, nil } -// getSession retrieves a managed session by its handle. -func (s *copilotService) getSession(sessionID string) (*managedCopilotSession, error) { +// getAgent retrieves a managed agent by session ID. +func (s *copilotService) getAgent(sessionID string) (*agent.CopilotAgent, error) { if sessionID == "" { return nil, status.Error(codes.InvalidArgument, "session_id is required") } @@ -316,12 +238,12 @@ func (s *copilotService) getSession(sessionID string) (*managedCopilotSession, e s.mu.RLock() defer s.mu.RUnlock() - managed, ok := s.sessions[sessionID] + copilotAgent, ok := s.sessions[sessionID] if !ok { return nil, status.Errorf(codes.NotFound, "session %q not found", sessionID) } - return managed, nil + return copilotAgent, nil } // buildAgentOptions constructs AgentOption slice from request fields. @@ -331,7 +253,6 @@ func buildAgentOptions( opts := []agent.AgentOption{ agent.WithHeadless(headless), } - if model != "" { opts = append(opts, agent.WithModel(model)) } @@ -347,7 +268,6 @@ func buildAgentOptions( if debug { opts = append(opts, agent.WithDebug(true)) } - return opts } @@ -364,6 +284,31 @@ func convertUsageMetrics(usage agent.UsageMetrics) *azdext.CopilotUsageMetrics { } } +// 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 { diff --git a/cli/azd/pkg/azdext/copilot.pb.go b/cli/azd/pkg/azdext/copilot.pb.go index 06ee70950fd..3b67d642534 100644 --- a/cli/azd/pkg/azdext/copilot.pb.go +++ b/cli/azd/pkg/azdext/copilot.pb.go @@ -77,34 +77,29 @@ func (CopilotFileChangeType) EnumDescriptor() ([]byte, []int) { return file_copilot_proto_rawDescGZIP(), []int{0} } -// CreateCopilotSessionRequest configures a new Copilot agent session. -type CreateCopilotSessionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` // Model to use (empty = default). - ReasoningEffort string `protobuf:"bytes,2,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Reasoning effort level (low, medium, high). - SystemMessage string `protobuf:"bytes,3,opt,name=system_message,json=systemMessage,proto3" json:"system_message,omitempty"` // Custom system message appended to the default prompt. - Mode string `protobuf:"bytes,4,opt,name=mode,proto3" json:"mode,omitempty"` // Agent mode (autopilot, interactive, plan). Defaults to autopilot. - Debug bool `protobuf:"varint,5,opt,name=debug,proto3" json:"debug,omitempty"` // Enable debug logging. - WorkingDirectory string `protobuf:"bytes,6,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` // Working directory for the session (empty = cwd). - Headless bool `protobuf:"varint,7,opt,name=headless,proto3" json:"headless,omitempty"` // Suppress all console output. Defaults to true for gRPC. - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// 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 *CreateCopilotSessionRequest) Reset() { - *x = CreateCopilotSessionRequest{} +func (x *InitializeCopilotRequest) Reset() { + *x = InitializeCopilotRequest{} mi := &file_copilot_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *CreateCopilotSessionRequest) String() string { +func (x *InitializeCopilotRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*CreateCopilotSessionRequest) ProtoMessage() {} +func (*InitializeCopilotRequest) ProtoMessage() {} -func (x *CreateCopilotSessionRequest) ProtoReflect() protoreflect.Message { +func (x *InitializeCopilotRequest) ProtoReflect() protoreflect.Message { mi := &file_copilot_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -116,134 +111,50 @@ func (x *CreateCopilotSessionRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use CreateCopilotSessionRequest.ProtoReflect.Descriptor instead. -func (*CreateCopilotSessionRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use InitializeCopilotRequest.ProtoReflect.Descriptor instead. +func (*InitializeCopilotRequest) Descriptor() ([]byte, []int) { return file_copilot_proto_rawDescGZIP(), []int{0} } -func (x *CreateCopilotSessionRequest) GetModel() string { +func (x *InitializeCopilotRequest) GetModel() string { if x != nil { return x.Model } return "" } -func (x *CreateCopilotSessionRequest) GetReasoningEffort() string { +func (x *InitializeCopilotRequest) GetReasoningEffort() string { if x != nil { return x.ReasoningEffort } return "" } -func (x *CreateCopilotSessionRequest) GetSystemMessage() string { - if x != nil { - return x.SystemMessage - } - return "" -} - -func (x *CreateCopilotSessionRequest) GetMode() string { - if x != nil { - return x.Mode - } - return "" -} - -func (x *CreateCopilotSessionRequest) GetDebug() bool { - if x != nil { - return x.Debug - } - return false -} - -func (x *CreateCopilotSessionRequest) GetWorkingDirectory() string { - if x != nil { - return x.WorkingDirectory - } - return "" -} - -func (x *CreateCopilotSessionRequest) GetHeadless() bool { - if x != nil { - return x.Headless - } - return false -} - -// CreateCopilotSessionResponse contains the ID of the created session. -type CreateCopilotSessionResponse 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 handle for subsequent calls. - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateCopilotSessionResponse) Reset() { - *x = CreateCopilotSessionResponse{} - mi := &file_copilot_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateCopilotSessionResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateCopilotSessionResponse) ProtoMessage() {} - -func (x *CreateCopilotSessionResponse) 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 CreateCopilotSessionResponse.ProtoReflect.Descriptor instead. -func (*CreateCopilotSessionResponse) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{1} -} - -func (x *CreateCopilotSessionResponse) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -// ResumeCopilotSessionRequest resumes an existing session by ID. -type ResumeCopilotSessionRequest struct { +// InitializeCopilotResponse returns the resolved configuration. +type InitializeCopilotResponse 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 to resume (from ListSessions or previous CreateSession). - Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` // Optional model override for the resumed session. - ReasoningEffort string `protobuf:"bytes,3,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Optional reasoning effort override. - SystemMessage string `protobuf:"bytes,4,opt,name=system_message,json=systemMessage,proto3" json:"system_message,omitempty"` // Optional system message override. - Mode string `protobuf:"bytes,5,opt,name=mode,proto3" json:"mode,omitempty"` // Optional mode override. - Debug bool `protobuf:"varint,6,opt,name=debug,proto3" json:"debug,omitempty"` // Enable debug logging. - Headless bool `protobuf:"varint,7,opt,name=headless,proto3" json:"headless,omitempty"` // Suppress all console output. Defaults to true for gRPC. + 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 *ResumeCopilotSessionRequest) Reset() { - *x = ResumeCopilotSessionRequest{} - mi := &file_copilot_proto_msgTypes[2] +func (x *InitializeCopilotResponse) Reset() { + *x = InitializeCopilotResponse{} + mi := &file_copilot_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ResumeCopilotSessionRequest) String() string { +func (x *InitializeCopilotResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ResumeCopilotSessionRequest) ProtoMessage() {} +func (*InitializeCopilotResponse) ProtoMessage() {} -func (x *ResumeCopilotSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[2] +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 { @@ -254,105 +165,32 @@ func (x *ResumeCopilotSessionRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ResumeCopilotSessionRequest.ProtoReflect.Descriptor instead. -func (*ResumeCopilotSessionRequest) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{2} -} - -func (x *ResumeCopilotSessionRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" +// Deprecated: Use InitializeCopilotResponse.ProtoReflect.Descriptor instead. +func (*InitializeCopilotResponse) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{1} } -func (x *ResumeCopilotSessionRequest) GetModel() string { +func (x *InitializeCopilotResponse) GetModel() string { if x != nil { return x.Model } return "" } -func (x *ResumeCopilotSessionRequest) GetReasoningEffort() string { +func (x *InitializeCopilotResponse) GetReasoningEffort() string { if x != nil { return x.ReasoningEffort } return "" } -func (x *ResumeCopilotSessionRequest) GetSystemMessage() string { - if x != nil { - return x.SystemMessage - } - return "" -} - -func (x *ResumeCopilotSessionRequest) GetMode() string { - if x != nil { - return x.Mode - } - return "" -} - -func (x *ResumeCopilotSessionRequest) GetDebug() bool { - if x != nil { - return x.Debug - } - return false -} - -func (x *ResumeCopilotSessionRequest) GetHeadless() bool { +func (x *InitializeCopilotResponse) GetIsFirstRun() bool { if x != nil { - return x.Headless + return x.IsFirstRun } return false } -// ResumeCopilotSessionResponse contains the ID of the resumed session. -type ResumeCopilotSessionResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session handle for subsequent calls. - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ResumeCopilotSessionResponse) Reset() { - *x = ResumeCopilotSessionResponse{} - mi := &file_copilot_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ResumeCopilotSessionResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ResumeCopilotSessionResponse) ProtoMessage() {} - -func (x *ResumeCopilotSessionResponse) 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 ResumeCopilotSessionResponse.ProtoReflect.Descriptor instead. -func (*ResumeCopilotSessionResponse) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{3} -} - -func (x *ResumeCopilotSessionResponse) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - // ListCopilotSessionsRequest requests available sessions for a directory. type ListCopilotSessionsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -363,7 +201,7 @@ type ListCopilotSessionsRequest struct { func (x *ListCopilotSessionsRequest) Reset() { *x = ListCopilotSessionsRequest{} - mi := &file_copilot_proto_msgTypes[4] + mi := &file_copilot_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -375,7 +213,7 @@ func (x *ListCopilotSessionsRequest) String() string { func (*ListCopilotSessionsRequest) ProtoMessage() {} func (x *ListCopilotSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[4] + mi := &file_copilot_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -388,7 +226,7 @@ func (x *ListCopilotSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListCopilotSessionsRequest.ProtoReflect.Descriptor instead. func (*ListCopilotSessionsRequest) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{4} + return file_copilot_proto_rawDescGZIP(), []int{2} } func (x *ListCopilotSessionsRequest) GetWorkingDirectory() string { @@ -408,7 +246,7 @@ type ListCopilotSessionsResponse struct { func (x *ListCopilotSessionsResponse) Reset() { *x = ListCopilotSessionsResponse{} - mi := &file_copilot_proto_msgTypes[5] + mi := &file_copilot_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -420,7 +258,7 @@ func (x *ListCopilotSessionsResponse) String() string { func (*ListCopilotSessionsResponse) ProtoMessage() {} func (x *ListCopilotSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[5] + mi := &file_copilot_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -433,7 +271,7 @@ func (x *ListCopilotSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListCopilotSessionsResponse.ProtoReflect.Descriptor instead. func (*ListCopilotSessionsResponse) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{5} + return file_copilot_proto_rawDescGZIP(), []int{3} } func (x *ListCopilotSessionsResponse) GetSessions() []*CopilotSessionMetadata { @@ -455,7 +293,7 @@ type CopilotSessionMetadata struct { func (x *CopilotSessionMetadata) Reset() { *x = CopilotSessionMetadata{} - mi := &file_copilot_proto_msgTypes[6] + mi := &file_copilot_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -467,7 +305,7 @@ func (x *CopilotSessionMetadata) String() string { func (*CopilotSessionMetadata) ProtoMessage() {} func (x *CopilotSessionMetadata) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[6] + mi := &file_copilot_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -480,7 +318,7 @@ func (x *CopilotSessionMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use CopilotSessionMetadata.ProtoReflect.Descriptor instead. func (*CopilotSessionMetadata) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{6} + return file_copilot_proto_rawDescGZIP(), []int{4} } func (x *CopilotSessionMetadata) GetSessionId() string { @@ -504,31 +342,39 @@ func (x *CopilotSessionMetadata) GetSummary() string { return "" } -// InitializeCopilotRequest configures model and reasoning for a session. -type InitializeCopilotRequest struct { +// 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"` - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session to initialize. - Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` // Model to use (overrides session config). - ReasoningEffort string `protobuf:"bytes,3,opt,name=reasoning_effort,json=reasoningEffort,proto3" json:"reasoning_effort,omitempty"` // Reasoning effort to use (overrides session config). + 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"` // Optional: suppress console output (default true for gRPC). unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *InitializeCopilotRequest) Reset() { - *x = InitializeCopilotRequest{} - mi := &file_copilot_proto_msgTypes[7] +func (x *SendCopilotMessageRequest) Reset() { + *x = SendCopilotMessageRequest{} + mi := &file_copilot_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *InitializeCopilotRequest) String() string { +func (x *SendCopilotMessageRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*InitializeCopilotRequest) ProtoMessage() {} +func (*SendCopilotMessageRequest) ProtoMessage() {} -func (x *InitializeCopilotRequest) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[7] +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 { @@ -539,158 +385,80 @@ func (x *InitializeCopilotRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use InitializeCopilotRequest.ProtoReflect.Descriptor instead. -func (*InitializeCopilotRequest) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{7} +// Deprecated: Use SendCopilotMessageRequest.ProtoReflect.Descriptor instead. +func (*SendCopilotMessageRequest) Descriptor() ([]byte, []int) { + return file_copilot_proto_rawDescGZIP(), []int{5} } -func (x *InitializeCopilotRequest) GetSessionId() string { +func (x *SendCopilotMessageRequest) GetPrompt() string { if x != nil { - return x.SessionId + return x.Prompt } return "" } -func (x *InitializeCopilotRequest) GetModel() string { +func (x *SendCopilotMessageRequest) GetSessionId() string { if x != nil { - return x.Model + return x.SessionId } 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[8] - 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[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 InitializeCopilotResponse.ProtoReflect.Descriptor instead. -func (*InitializeCopilotResponse) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{8} -} - -func (x *InitializeCopilotResponse) GetModel() string { +func (x *SendCopilotMessageRequest) GetModel() string { if x != nil { return x.Model } return "" } -func (x *InitializeCopilotResponse) GetReasoningEffort() string { +func (x *SendCopilotMessageRequest) GetReasoningEffort() string { if x != nil { return x.ReasoningEffort } return "" } -func (x *InitializeCopilotResponse) GetIsFirstRun() bool { +func (x *SendCopilotMessageRequest) GetSystemMessage() string { if x != nil { - return x.IsFirstRun + return x.SystemMessage } - return false -} - -// SendCopilotMessageRequest sends a prompt to the agent. -type SendCopilotMessageRequest 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 send the message to. - Prompt string `protobuf:"bytes,2,opt,name=prompt,proto3" json:"prompt,omitempty"` // The prompt/message to send. - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SendCopilotMessageRequest) Reset() { - *x = SendCopilotMessageRequest{} - mi := &file_copilot_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SendCopilotMessageRequest) String() string { - return protoimpl.X.MessageStringOf(x) + return "" } -func (*SendCopilotMessageRequest) ProtoMessage() {} - -func (x *SendCopilotMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[9] +func (x *SendCopilotMessageRequest) GetMode() string { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Mode } - return mi.MessageOf(x) -} - -// Deprecated: Use SendCopilotMessageRequest.ProtoReflect.Descriptor instead. -func (*SendCopilotMessageRequest) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{9} + return "" } -func (x *SendCopilotMessageRequest) GetSessionId() string { +func (x *SendCopilotMessageRequest) GetDebug() bool { if x != nil { - return x.SessionId + return x.Debug } - return "" + return false } -func (x *SendCopilotMessageRequest) GetPrompt() string { +func (x *SendCopilotMessageRequest) GetHeadless() bool { if x != nil { - return x.Prompt + return x.Headless } - return "" + 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 that processed the message. - Usage *CopilotUsageMetrics `protobuf:"bytes,2,opt,name=usage,proto3" json:"usage,omitempty"` // Usage metrics for this message turn. + 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[10] + mi := &file_copilot_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -702,7 +470,7 @@ func (x *SendCopilotMessageResponse) String() string { func (*SendCopilotMessageResponse) ProtoMessage() {} func (x *SendCopilotMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[10] + mi := &file_copilot_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -715,7 +483,7 @@ func (x *SendCopilotMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendCopilotMessageResponse.ProtoReflect.Descriptor instead. func (*SendCopilotMessageResponse) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{10} + return file_copilot_proto_rawDescGZIP(), []int{6} } func (x *SendCopilotMessageResponse) GetSessionId() string { @@ -732,7 +500,14 @@ func (x *SendCopilotMessageResponse) GetUsage() *CopilotUsageMetrics { return nil } -// GetCopilotUsageMetricsRequest requests usage metrics for a session. +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. @@ -742,7 +517,7 @@ type GetCopilotUsageMetricsRequest struct { func (x *GetCopilotUsageMetricsRequest) Reset() { *x = GetCopilotUsageMetricsRequest{} - mi := &file_copilot_proto_msgTypes[11] + mi := &file_copilot_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -754,7 +529,7 @@ func (x *GetCopilotUsageMetricsRequest) String() string { func (*GetCopilotUsageMetricsRequest) ProtoMessage() {} func (x *GetCopilotUsageMetricsRequest) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[11] + mi := &file_copilot_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -767,7 +542,7 @@ func (x *GetCopilotUsageMetricsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCopilotUsageMetricsRequest.ProtoReflect.Descriptor instead. func (*GetCopilotUsageMetricsRequest) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{11} + return file_copilot_proto_rawDescGZIP(), []int{7} } func (x *GetCopilotUsageMetricsRequest) GetSessionId() string { @@ -787,7 +562,7 @@ type GetCopilotUsageMetricsResponse struct { func (x *GetCopilotUsageMetricsResponse) Reset() { *x = GetCopilotUsageMetricsResponse{} - mi := &file_copilot_proto_msgTypes[12] + mi := &file_copilot_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -799,7 +574,7 @@ func (x *GetCopilotUsageMetricsResponse) String() string { func (*GetCopilotUsageMetricsResponse) ProtoMessage() {} func (x *GetCopilotUsageMetricsResponse) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[12] + mi := &file_copilot_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -812,7 +587,7 @@ func (x *GetCopilotUsageMetricsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCopilotUsageMetricsResponse.ProtoReflect.Descriptor instead. func (*GetCopilotUsageMetricsResponse) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{12} + return file_copilot_proto_rawDescGZIP(), []int{8} } func (x *GetCopilotUsageMetricsResponse) GetUsage() *CopilotUsageMetrics { @@ -838,7 +613,7 @@ type CopilotUsageMetrics struct { func (x *CopilotUsageMetrics) Reset() { *x = CopilotUsageMetrics{} - mi := &file_copilot_proto_msgTypes[13] + mi := &file_copilot_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -850,7 +625,7 @@ func (x *CopilotUsageMetrics) String() string { func (*CopilotUsageMetrics) ProtoMessage() {} func (x *CopilotUsageMetrics) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[13] + mi := &file_copilot_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -863,7 +638,7 @@ func (x *CopilotUsageMetrics) ProtoReflect() protoreflect.Message { // Deprecated: Use CopilotUsageMetrics.ProtoReflect.Descriptor instead. func (*CopilotUsageMetrics) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{13} + return file_copilot_proto_rawDescGZIP(), []int{9} } func (x *CopilotUsageMetrics) GetModel() string { @@ -915,7 +690,7 @@ func (x *CopilotUsageMetrics) GetDurationMs() float64 { return 0 } -// GetCopilotFileChangesRequest requests file changes for a session. +// 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. @@ -925,7 +700,7 @@ type GetCopilotFileChangesRequest struct { func (x *GetCopilotFileChangesRequest) Reset() { *x = GetCopilotFileChangesRequest{} - mi := &file_copilot_proto_msgTypes[14] + mi := &file_copilot_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -937,7 +712,7 @@ func (x *GetCopilotFileChangesRequest) String() string { func (*GetCopilotFileChangesRequest) ProtoMessage() {} func (x *GetCopilotFileChangesRequest) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[14] + mi := &file_copilot_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -950,7 +725,7 @@ func (x *GetCopilotFileChangesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCopilotFileChangesRequest.ProtoReflect.Descriptor instead. func (*GetCopilotFileChangesRequest) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{14} + return file_copilot_proto_rawDescGZIP(), []int{10} } func (x *GetCopilotFileChangesRequest) GetSessionId() string { @@ -970,7 +745,7 @@ type GetCopilotFileChangesResponse struct { func (x *GetCopilotFileChangesResponse) Reset() { *x = GetCopilotFileChangesResponse{} - mi := &file_copilot_proto_msgTypes[15] + mi := &file_copilot_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -982,7 +757,7 @@ func (x *GetCopilotFileChangesResponse) String() string { func (*GetCopilotFileChangesResponse) ProtoMessage() {} func (x *GetCopilotFileChangesResponse) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[15] + mi := &file_copilot_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -995,7 +770,7 @@ func (x *GetCopilotFileChangesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCopilotFileChangesResponse.ProtoReflect.Descriptor instead. func (*GetCopilotFileChangesResponse) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{15} + return file_copilot_proto_rawDescGZIP(), []int{11} } func (x *GetCopilotFileChangesResponse) GetFileChanges() []*CopilotFileChange { @@ -1016,7 +791,7 @@ type CopilotFileChange struct { func (x *CopilotFileChange) Reset() { *x = CopilotFileChange{} - mi := &file_copilot_proto_msgTypes[16] + mi := &file_copilot_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1028,7 +803,7 @@ func (x *CopilotFileChange) String() string { func (*CopilotFileChange) ProtoMessage() {} func (x *CopilotFileChange) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[16] + mi := &file_copilot_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1041,7 +816,7 @@ func (x *CopilotFileChange) ProtoReflect() protoreflect.Message { // Deprecated: Use CopilotFileChange.ProtoReflect.Descriptor instead. func (*CopilotFileChange) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{16} + return file_copilot_proto_rawDescGZIP(), []int{12} } func (x *CopilotFileChange) GetPath() string { @@ -1068,7 +843,7 @@ type StopCopilotSessionRequest struct { func (x *StopCopilotSessionRequest) Reset() { *x = StopCopilotSessionRequest{} - mi := &file_copilot_proto_msgTypes[17] + mi := &file_copilot_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1080,7 +855,7 @@ func (x *StopCopilotSessionRequest) String() string { func (*StopCopilotSessionRequest) ProtoMessage() {} func (x *StopCopilotSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_copilot_proto_msgTypes[17] + mi := &file_copilot_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1093,7 +868,7 @@ func (x *StopCopilotSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCopilotSessionRequest.ProtoReflect.Descriptor instead. func (*StopCopilotSessionRequest) Descriptor() ([]byte, []int) { - return file_copilot_proto_rawDescGZIP(), []int{17} + return file_copilot_proto_rawDescGZIP(), []int{13} } func (x *StopCopilotSessionRequest) GetSessionId() string { @@ -1107,30 +882,15 @@ var File_copilot_proto protoreflect.FileDescriptor const file_copilot_proto_rawDesc = "" + "\n" + - "\rcopilot.proto\x12\x06azdext\x1a\fmodels.proto\"\xf8\x01\n" + - "\x1bCreateCopilotSessionRequest\x12\x14\n" + + "\rcopilot.proto\x12\x06azdext\x1a\fmodels.proto\"[\n" + + "\x18InitializeCopilotRequest\x12\x14\n" + "\x05model\x18\x01 \x01(\tR\x05model\x12)\n" + - "\x10reasoning_effort\x18\x02 \x01(\tR\x0freasoningEffort\x12%\n" + - "\x0esystem_message\x18\x03 \x01(\tR\rsystemMessage\x12\x12\n" + - "\x04mode\x18\x04 \x01(\tR\x04mode\x12\x14\n" + - "\x05debug\x18\x05 \x01(\bR\x05debug\x12+\n" + - "\x11working_directory\x18\x06 \x01(\tR\x10workingDirectory\x12\x1a\n" + - "\bheadless\x18\a \x01(\bR\bheadless\"=\n" + - "\x1cCreateCopilotSessionResponse\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"\xea\x01\n" + - "\x1bResumeCopilotSessionRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12\x14\n" + - "\x05model\x18\x02 \x01(\tR\x05model\x12)\n" + - "\x10reasoning_effort\x18\x03 \x01(\tR\x0freasoningEffort\x12%\n" + - "\x0esystem_message\x18\x04 \x01(\tR\rsystemMessage\x12\x12\n" + - "\x04mode\x18\x05 \x01(\tR\x04mode\x12\x14\n" + - "\x05debug\x18\x06 \x01(\bR\x05debug\x12\x1a\n" + - "\bheadless\x18\a \x01(\bR\bheadless\"=\n" + - "\x1cResumeCopilotSessionResponse\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"I\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" + @@ -1139,25 +899,22 @@ const file_copilot_proto_rawDesc = "" + "\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\"z\n" + - "\x18InitializeCopilotRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12\x14\n" + - "\x05model\x18\x02 \x01(\tR\x05model\x12)\n" + - "\x10reasoning_effort\x18\x03 \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\"R\n" + - "\x19SendCopilotMessageRequest\x12\x1d\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\x01 \x01(\tR\tsessionId\x12\x16\n" + - "\x06prompt\x18\x02 \x01(\tR\x06prompt\"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\">\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" + @@ -1188,13 +945,11 @@ const file_copilot_proto_rawDesc = "" + "$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\xd4\x05\n" + - "\x0eCopilotService\x12Z\n" + - "\rCreateSession\x12#.azdext.CreateCopilotSessionRequest\x1a$.azdext.CreateCopilotSessionResponse\x12Z\n" + - "\rResumeSession\x12#.azdext.ResumeCopilotSessionRequest\x1a$.azdext.ResumeCopilotSessionResponse\x12W\n" + - "\fListSessions\x12\".azdext.ListCopilotSessionsRequest\x1a#.azdext.ListCopilotSessionsResponse\x12Q\n" + + " COPILOT_FILE_CHANGE_TYPE_DELETED\x10\x032\x9c\x04\n" + + "\x0eCopilotService\x12Q\n" + "\n" + - "Initialize\x12 .azdext.InitializeCopilotRequest\x1a!.azdext.InitializeCopilotResponse\x12T\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" + @@ -1213,56 +968,49 @@ func file_copilot_proto_rawDescGZIP() []byte { } var file_copilot_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_copilot_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_copilot_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_copilot_proto_goTypes = []any{ (CopilotFileChangeType)(0), // 0: azdext.CopilotFileChangeType - (*CreateCopilotSessionRequest)(nil), // 1: azdext.CreateCopilotSessionRequest - (*CreateCopilotSessionResponse)(nil), // 2: azdext.CreateCopilotSessionResponse - (*ResumeCopilotSessionRequest)(nil), // 3: azdext.ResumeCopilotSessionRequest - (*ResumeCopilotSessionResponse)(nil), // 4: azdext.ResumeCopilotSessionResponse - (*ListCopilotSessionsRequest)(nil), // 5: azdext.ListCopilotSessionsRequest - (*ListCopilotSessionsResponse)(nil), // 6: azdext.ListCopilotSessionsResponse - (*CopilotSessionMetadata)(nil), // 7: azdext.CopilotSessionMetadata - (*InitializeCopilotRequest)(nil), // 8: azdext.InitializeCopilotRequest - (*InitializeCopilotResponse)(nil), // 9: azdext.InitializeCopilotResponse - (*SendCopilotMessageRequest)(nil), // 10: azdext.SendCopilotMessageRequest - (*SendCopilotMessageResponse)(nil), // 11: azdext.SendCopilotMessageResponse - (*GetCopilotUsageMetricsRequest)(nil), // 12: azdext.GetCopilotUsageMetricsRequest - (*GetCopilotUsageMetricsResponse)(nil), // 13: azdext.GetCopilotUsageMetricsResponse - (*CopilotUsageMetrics)(nil), // 14: azdext.CopilotUsageMetrics - (*GetCopilotFileChangesRequest)(nil), // 15: azdext.GetCopilotFileChangesRequest - (*GetCopilotFileChangesResponse)(nil), // 16: azdext.GetCopilotFileChangesResponse - (*CopilotFileChange)(nil), // 17: azdext.CopilotFileChange - (*StopCopilotSessionRequest)(nil), // 18: azdext.StopCopilotSessionRequest - (*EmptyResponse)(nil), // 19: azdext.EmptyResponse + (*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 + (*EmptyResponse)(nil), // 15: azdext.EmptyResponse } var file_copilot_proto_depIdxs = []int32{ - 7, // 0: azdext.ListCopilotSessionsResponse.sessions:type_name -> azdext.CopilotSessionMetadata - 14, // 1: azdext.SendCopilotMessageResponse.usage:type_name -> azdext.CopilotUsageMetrics - 14, // 2: azdext.GetCopilotUsageMetricsResponse.usage:type_name -> azdext.CopilotUsageMetrics - 17, // 3: azdext.GetCopilotFileChangesResponse.file_changes:type_name -> azdext.CopilotFileChange - 0, // 4: azdext.CopilotFileChange.change_type:type_name -> azdext.CopilotFileChangeType - 1, // 5: azdext.CopilotService.CreateSession:input_type -> azdext.CreateCopilotSessionRequest - 3, // 6: azdext.CopilotService.ResumeSession:input_type -> azdext.ResumeCopilotSessionRequest - 5, // 7: azdext.CopilotService.ListSessions:input_type -> azdext.ListCopilotSessionsRequest - 8, // 8: azdext.CopilotService.Initialize:input_type -> azdext.InitializeCopilotRequest - 10, // 9: azdext.CopilotService.SendMessage:input_type -> azdext.SendCopilotMessageRequest - 12, // 10: azdext.CopilotService.GetUsageMetrics:input_type -> azdext.GetCopilotUsageMetricsRequest - 15, // 11: azdext.CopilotService.GetFileChanges:input_type -> azdext.GetCopilotFileChangesRequest - 18, // 12: azdext.CopilotService.StopSession:input_type -> azdext.StopCopilotSessionRequest - 2, // 13: azdext.CopilotService.CreateSession:output_type -> azdext.CreateCopilotSessionResponse - 4, // 14: azdext.CopilotService.ResumeSession:output_type -> azdext.ResumeCopilotSessionResponse - 6, // 15: azdext.CopilotService.ListSessions:output_type -> azdext.ListCopilotSessionsResponse - 9, // 16: azdext.CopilotService.Initialize:output_type -> azdext.InitializeCopilotResponse - 11, // 17: azdext.CopilotService.SendMessage:output_type -> azdext.SendCopilotMessageResponse - 13, // 18: azdext.CopilotService.GetUsageMetrics:output_type -> azdext.GetCopilotUsageMetricsResponse - 16, // 19: azdext.CopilotService.GetFileChanges:output_type -> azdext.GetCopilotFileChangesResponse - 19, // 20: azdext.CopilotService.StopSession:output_type -> azdext.EmptyResponse - 13, // [13:21] is the sub-list for method output_type - 5, // [5:13] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 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 + 1, // 6: azdext.CopilotService.Initialize:input_type -> azdext.InitializeCopilotRequest + 3, // 7: azdext.CopilotService.ListSessions:input_type -> azdext.ListCopilotSessionsRequest + 6, // 8: azdext.CopilotService.SendMessage:input_type -> azdext.SendCopilotMessageRequest + 8, // 9: azdext.CopilotService.GetUsageMetrics:input_type -> azdext.GetCopilotUsageMetricsRequest + 11, // 10: azdext.CopilotService.GetFileChanges:input_type -> azdext.GetCopilotFileChangesRequest + 14, // 11: azdext.CopilotService.StopSession:input_type -> azdext.StopCopilotSessionRequest + 2, // 12: azdext.CopilotService.Initialize:output_type -> azdext.InitializeCopilotResponse + 4, // 13: azdext.CopilotService.ListSessions:output_type -> azdext.ListCopilotSessionsResponse + 7, // 14: azdext.CopilotService.SendMessage:output_type -> azdext.SendCopilotMessageResponse + 9, // 15: azdext.CopilotService.GetUsageMetrics:output_type -> azdext.GetCopilotUsageMetricsResponse + 12, // 16: azdext.CopilotService.GetFileChanges:output_type -> azdext.GetCopilotFileChangesResponse + 15, // 17: azdext.CopilotService.StopSession:output_type -> azdext.EmptyResponse + 12, // [12:18] is the sub-list for method output_type + 6, // [6:12] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_copilot_proto_init() } @@ -1277,7 +1025,7 @@ func file_copilot_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_copilot_proto_rawDesc), len(file_copilot_proto_rawDesc)), NumEnums: 1, - NumMessages: 18, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/copilot_grpc.pb.go b/cli/azd/pkg/azdext/copilot_grpc.pb.go index c9f5639c65d..b280eb213ff 100644 --- a/cli/azd/pkg/azdext/copilot_grpc.pb.go +++ b/cli/azd/pkg/azdext/copilot_grpc.pb.go @@ -22,10 +22,8 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - CopilotService_CreateSession_FullMethodName = "/azdext.CopilotService/CreateSession" - CopilotService_ResumeSession_FullMethodName = "/azdext.CopilotService/ResumeSession" - CopilotService_ListSessions_FullMethodName = "/azdext.CopilotService/ListSessions" 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" @@ -37,25 +35,24 @@ const ( // 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. -// Extensions can create sessions, send messages, track file changes, and collect -// usage metrics. Sessions run in headless/autopilot mode by default when invoked -// via gRPC, suppressing all console output. +// 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 { - // CreateSession creates a new Copilot agent session with the given configuration. - // The session can be reused for multiple SendMessage calls without re-bootstrapping. - CreateSession(ctx context.Context, in *CreateCopilotSessionRequest, opts ...grpc.CallOption) (*CreateCopilotSessionResponse, error) - // ResumeSession resumes an existing Copilot session by its session ID. - ResumeSession(ctx context.Context, in *ResumeCopilotSessionRequest, opts ...grpc.CallOption) (*ResumeCopilotSessionResponse, error) + // 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) - // Initialize performs first-run configuration (model/reasoning setup). - // When called through gRPC, uses provided config values rather than interactive prompts. - Initialize(ctx context.Context, in *InitializeCopilotRequest, opts ...grpc.CallOption) (*InitializeCopilotResponse, error) - // SendMessage sends a prompt to the agent and blocks until processing completes. + // 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 for a session. + // 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 the session. + // 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) @@ -69,20 +66,10 @@ func NewCopilotServiceClient(cc grpc.ClientConnInterface) CopilotServiceClient { return &copilotServiceClient{cc} } -func (c *copilotServiceClient) CreateSession(ctx context.Context, in *CreateCopilotSessionRequest, opts ...grpc.CallOption) (*CreateCopilotSessionResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateCopilotSessionResponse) - err := c.cc.Invoke(ctx, CopilotService_CreateSession_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *copilotServiceClient) ResumeSession(ctx context.Context, in *ResumeCopilotSessionRequest, opts ...grpc.CallOption) (*ResumeCopilotSessionResponse, error) { +func (c *copilotServiceClient) Initialize(ctx context.Context, in *InitializeCopilotRequest, opts ...grpc.CallOption) (*InitializeCopilotResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ResumeCopilotSessionResponse) - err := c.cc.Invoke(ctx, CopilotService_ResumeSession_FullMethodName, in, out, cOpts...) + out := new(InitializeCopilotResponse) + err := c.cc.Invoke(ctx, CopilotService_Initialize_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -99,16 +86,6 @@ func (c *copilotServiceClient) ListSessions(ctx context.Context, in *ListCopilot return out, nil } -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) SendMessage(ctx context.Context, in *SendCopilotMessageRequest, opts ...grpc.CallOption) (*SendCopilotMessageResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SendCopilotMessageResponse) @@ -154,25 +131,24 @@ func (c *copilotServiceClient) StopSession(ctx context.Context, in *StopCopilotS // for forward compatibility. // // CopilotService provides Copilot agent capabilities to extensions. -// Extensions can create sessions, send messages, track file changes, and collect -// usage metrics. Sessions run in headless/autopilot mode by default when invoked -// via gRPC, suppressing all console output. +// 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 { - // CreateSession creates a new Copilot agent session with the given configuration. - // The session can be reused for multiple SendMessage calls without re-bootstrapping. - CreateSession(context.Context, *CreateCopilotSessionRequest) (*CreateCopilotSessionResponse, error) - // ResumeSession resumes an existing Copilot session by its session ID. - ResumeSession(context.Context, *ResumeCopilotSessionRequest) (*ResumeCopilotSessionResponse, error) + // 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) - // Initialize performs first-run configuration (model/reasoning setup). - // When called through gRPC, uses provided config values rather than interactive prompts. - Initialize(context.Context, *InitializeCopilotRequest) (*InitializeCopilotResponse, error) - // SendMessage sends a prompt to the agent and blocks until processing completes. + // 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 for a session. + // GetUsageMetrics returns cumulative usage metrics cached for a session. GetUsageMetrics(context.Context, *GetCopilotUsageMetricsRequest) (*GetCopilotUsageMetricsResponse, error) - // GetFileChanges returns files created, modified, or deleted during the session. + // 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) @@ -186,18 +162,12 @@ type CopilotServiceServer interface { // pointer dereference when methods are called. type UnimplementedCopilotServiceServer struct{} -func (UnimplementedCopilotServiceServer) CreateSession(context.Context, *CreateCopilotSessionRequest) (*CreateCopilotSessionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CreateSession not implemented") -} -func (UnimplementedCopilotServiceServer) ResumeSession(context.Context, *ResumeCopilotSessionRequest) (*ResumeCopilotSessionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ResumeSession not implemented") +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) Initialize(context.Context, *InitializeCopilotRequest) (*InitializeCopilotResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Initialize not implemented") -} func (UnimplementedCopilotServiceServer) SendMessage(context.Context, *SendCopilotMessageRequest) (*SendCopilotMessageResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SendMessage not implemented") } @@ -231,38 +201,20 @@ func RegisterCopilotServiceServer(s grpc.ServiceRegistrar, srv CopilotServiceSer s.RegisterService(&CopilotService_ServiceDesc, srv) } -func _CopilotService_CreateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreateCopilotSessionRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(CopilotServiceServer).CreateSession(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: CopilotService_CreateSession_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(CopilotServiceServer).CreateSession(ctx, req.(*CreateCopilotSessionRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _CopilotService_ResumeSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ResumeCopilotSessionRequest) +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).ResumeSession(ctx, in) + return srv.(CopilotServiceServer).Initialize(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: CopilotService_ResumeSession_FullMethodName, + FullMethod: CopilotService_Initialize_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(CopilotServiceServer).ResumeSession(ctx, req.(*ResumeCopilotSessionRequest)) + return srv.(CopilotServiceServer).Initialize(ctx, req.(*InitializeCopilotRequest)) } return interceptor(ctx, in, info, handler) } @@ -285,24 +237,6 @@ func _CopilotService_ListSessions_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } -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_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 { @@ -383,21 +317,13 @@ var CopilotService_ServiceDesc = grpc.ServiceDesc{ HandlerType: (*CopilotServiceServer)(nil), Methods: []grpc.MethodDesc{ { - MethodName: "CreateSession", - Handler: _CopilotService_CreateSession_Handler, - }, - { - MethodName: "ResumeSession", - Handler: _CopilotService_ResumeSession_Handler, + MethodName: "Initialize", + Handler: _CopilotService_Initialize_Handler, }, { MethodName: "ListSessions", Handler: _CopilotService_ListSessions_Handler, }, - { - MethodName: "Initialize", - Handler: _CopilotService_Initialize_Handler, - }, { MethodName: "SendMessage", Handler: _CopilotService_SendMessage_Handler, From 870122d0ff9eba97c13412d51f441fcb4ea3cb34 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 12:20:31 -0700 Subject: [PATCH 05/11] fix(demo): remove per-turn usage display and headless mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove per-turn usage metrics display — shown only at end via GetUsageMetrics - Remove headless=true — agent output (thinking, tools, responses) should be visible in the demo chat experience Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/copilot.go | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go index 48c926bc4a7..a2674af651a 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go @@ -238,8 +238,7 @@ func chatLoop( // SendMessage creates the session on the first call, reuses it after sendReq := &azdext.SendCopilotMessageRequest{ - Prompt: input, - Headless: true, + Prompt: input, } if sessionID != "" { sendReq.SessionId = sessionID @@ -261,37 +260,12 @@ func chatLoop( // Capture the session ID from the response for reuse sessionID = sendResp.SessionId - // Display turn usage - if sendResp.Usage != nil { - displayTurnUsage(turn, sendResp.Usage) - } - fmt.Println() } return sessionID, nil } -// displayTurnUsage shows brief usage metrics for a single turn. -func displayTurnUsage(turn int, usage *azdext.CopilotUsageMetrics) { - fmt.Printf(" %s Turn %d complete", color.GreenString("✓"), turn) - if usage.Model != "" { - fmt.Printf(" (%s)", color.CyanString(usage.Model)) - } - fmt.Println() - - fmt.Printf(" %s Tokens: %s in / %s out / %s total\n", - color.HiBlackString("•"), - formatTokens(usage.InputTokens), - formatTokens(usage.OutputTokens), - formatTokens(usage.TotalTokens)) - - if usage.DurationMs > 0 { - fmt.Printf(" %s Duration: %s\n", - color.HiBlackString("•"), formatDuration(usage.DurationMs)) - } -} - // showCumulativeMetrics displays cumulative session usage. func showCumulativeMetrics( ctx context.Context, From dd89c45bef686ec23be7ddd80af0174d09c79d1d Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 13:27:55 -0700 Subject: [PATCH 06/11] fix: multiple UX and reliability improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent changes: - Remove stored sessionCtx from CopilotAgent — use atomic.Pointer for active context, updated per SendMessage call (SDK callbacks read it) - Use context.WithoutCancel in ensureSession so SDK client/session outlive individual gRPC request contexts (fixes CLI process crash on 2nd message) - Improve Stop() — clear cleanup tasks (idempotent), return first error - Skip plugin management in headless mode - Plugin detection fallback: when 'copilot plugin list' reports no plugins but they exist on disk, scan directory directly (CLI version mismatch workaround) - PrintSessionMetrics now prints file changes before usage metrics Demo extension: - Remove per-turn usage display and headless mode - Remove turn number prefixes from prompt - Remove 'Sending to agent' message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/copilot.go | 13 ++- cli/azd/internal/agent/copilot/cli.go | 73 ++++++++++++++++ cli/azd/internal/agent/copilot_agent.go | 87 ++++++++++++------- 3 files changed, 132 insertions(+), 41 deletions(-) diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go index a2674af651a..086b9122349 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go @@ -82,7 +82,7 @@ func runCopilotChat(ctx context.Context, client *azdext.AzdClient, flags copilot fmt.Println(color.HiCyanString("╔══════════════════════════════════════╗")) fmt.Println(color.HiCyanString("║") + color.HiWhiteString(" Copilot Chat — Demo Extension ") + - color.HiCyanString(" ║")) + color.HiCyanString(" ║")) fmt.Println(color.HiCyanString("╚══════════════════════════════════════╝")) fmt.Println() @@ -133,6 +133,7 @@ func runCopilotChat(ctx context.Context, client *azdext.AzdClient, flags copilot }); 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("✓")) } } @@ -213,14 +214,12 @@ func chatLoop( sessionID string, flags copilotFlags, ) (string, error) { - turn := 0 for { - turn++ - resp, err := promptSvc.Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ - Message: fmt.Sprintf("[%d] You", turn), - Placeholder: "Type a message...", + Message: "You", + Placeholder: "Type a message...", + IgnoreHintKeys: true, }, }) if err != nil { @@ -234,8 +233,6 @@ func chatLoop( break } - fmt.Printf(" %s Sending to agent...\n", color.HiBlackString("⏳")) - // SendMessage creates the session on the first call, reuses it after sendReq := &azdext.SendCopilotMessageRequest{ Prompt: input, 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 a1a105e3260..70573b74565 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync/atomic" "time" copilot "github.com/github/copilot-sdk/go" @@ -50,10 +51,10 @@ type CopilotAgent struct { clientStarted bool session *copilot.Session sessionID string - sessionCtx context.Context - display *AgentDisplay // last display for usage metrics (interactive mode) - cumulativeUsage UsageMetrics // cumulative metrics across multiple SendMessage calls - accumulatedFileChanges []watch.FileChange // accumulated file changes across all SendMessage calls + activeCtx atomic.Pointer[context.Context] // current SendMessage context for SDK callbacks + display *AgentDisplay // last display for usage metrics (interactive mode) + cumulativeUsage UsageMetrics // cumulative metrics across multiple SendMessage calls + accumulatedFileChanges []watch.FileChange // accumulated file changes across all SendMessage calls // Cleanup — ordered slice for deterministic teardown cleanupTasks []cleanupTask @@ -275,6 +276,9 @@ 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 @@ -424,20 +428,20 @@ func (a *CopilotAgent) GetAccumulatedFileChanges() []watch.FileChange { return a.accumulatedFileChanges } -// PrintSessionMetrics prints cumulative usage metrics and accumulated file changes. +// PrintSessionMetrics prints accumulated file changes followed by cumulative usage metrics. // Call this after the last SendMessage to display session summary. func (a *CopilotAgent) PrintSessionMetrics(ctx context.Context) { + // Print file changes first + if len(a.accumulatedFileChanges) > 0 { + printFileChanges(a.accumulatedFileChanges) + } + // Print usage metrics usageStr := a.cumulativeUsage.Format() if usageStr != "" { a.console.Message(ctx, "") a.console.Message(ctx, usageStr) } - - // Print file changes - if len(a.accumulatedFileChanges) > 0 { - printFileChanges(a.accumulatedFileChanges) - } } // printFileChanges prints file changes in the standard format. @@ -484,15 +488,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. @@ -500,10 +513,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() } @@ -543,25 +557,29 @@ func (a *CopilotAgent) EnsureStarted(ctx context.Context) error { } // 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() @@ -570,7 +588,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) } @@ -605,12 +623,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) } @@ -620,11 +638,11 @@ 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("creating copilot session: %w", err) } @@ -667,7 +685,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 @@ -690,7 +708,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 { @@ -1042,8 +1060,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( @@ -1064,7 +1086,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) From 605b8ef699cc9a4c74274708e97d5cbfcbf4e721 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 13:46:03 -0700 Subject: [PATCH 07/11] refactor: extract Agent/AgentFactory interfaces and add copilotService tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract Agent and AgentFactory interfaces from the concrete CopilotAgent and CopilotAgentFactory types, enabling unit testing of the gRPC service layer without a real Copilot SDK runtime. Interface changes: - Agent interface: Initialize, SendMessage, SendMessageWithRetry, ListSessions, GetUsage, GetFileChanges, SessionID, Stop - AgentFactory interface: Create(ctx, opts...) (Agent, error) - Rename GetCumulativeUsage → GetUsage, GetAccumulatedFileChanges → GetFileChanges Tests (12 test cases): - SendMessage: new session, reuse session, resume SDK session, empty prompt - GetUsageMetrics: valid session, unknown session, empty session_id - GetFileChanges: valid session with changes - StopSession: valid cleanup, unknown session - Initialize: delegates to agent - SendMessage with file changes in response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 4 + cli/azd/cmd/init.go | 4 +- cli/azd/internal/agent/copilot_agent.go | 8 +- .../internal/agent/copilot_agent_factory.go | 2 +- cli/azd/internal/agent/types.go | 28 ++ .../internal/grpcserver/copilot_service.go | 16 +- .../grpcserver/copilot_service_test.go | 392 ++++++++++++++++++ 7 files changed, 440 insertions(+), 14 deletions(-) create mode 100644 cli/azd/internal/grpcserver/copilot_service_test.go diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index dd03c21ef42..c20c4a0cbd5 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -586,6 +586,10 @@ func registerCommonDependencies(container *ioc.NestedContainer) { return agentcopilot.NewCopilotClientManager(nil, cli) }) container.MustRegisterScoped(agent.NewCopilotAgentFactory) + // Register the factory as the AgentFactory interface for the gRPC service + container.MustRegisterScoped(func(f *agent.CopilotAgentFactory) agent.AgentFactory { + return f + }) container.MustRegisterScoped(consent.NewConsentManager) // Agent security manager diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index 91e4eba9bb3..341f64c0e3f 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -533,7 +533,9 @@ When complete, provide a brief summary of what was accomplished.` } // Show session metrics (usage + file changes) - copilotAgent.PrintSessionMetrics(ctx) + if ca, ok := copilotAgent.(*agent.CopilotAgent); ok { + ca.PrintSessionMetrics(ctx) + } i.console.Message(ctx, "") return nil diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index 70573b74565..91f5c2418db 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -404,8 +404,8 @@ func (a *CopilotAgent) accumulateUsage(turn UsageMetrics) { } } -// GetCumulativeUsage returns the cumulative usage metrics across all SendMessage calls. -func (a *CopilotAgent) GetCumulativeUsage() UsageMetrics { +// GetUsage returns cumulative usage metrics across all SendMessage calls. +func (a *CopilotAgent) GetUsage() UsageMetrics { return a.cumulativeUsage } @@ -423,8 +423,8 @@ func (a *CopilotAgent) collectFileChanges(watcher watch.Watcher) []watch.FileCha return turnChanges } -// GetAccumulatedFileChanges returns all file changes across all SendMessage calls. -func (a *CopilotAgent) GetAccumulatedFileChanges() []watch.FileChange { +// GetFileChanges returns accumulated file changes across all SendMessage calls. +func (a *CopilotAgent) GetFileChanges() []watch.FileChange { return a.accumulatedFileChanges } diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go index ad7d25420cb..5324262e265 100644 --- a/cli/azd/internal/agent/copilot_agent_factory.go +++ b/cli/azd/internal/agent/copilot_agent_factory.go @@ -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/types.go b/cli/azd/internal/agent/types.go index 7561f71401a..1a1e85f804c 100644 --- a/cli/azd/internal/agent/types.go +++ b/cli/azd/internal/agent/types.go @@ -4,6 +4,7 @@ package agent import ( + "context" "fmt" "strings" @@ -177,3 +178,30 @@ func WithSessionID(id string) SendOption { // SessionMetadata contains metadata about a previous session. type SessionMetadata = copilot.SessionMetadata + +// 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) + // GetUsage returns cumulative usage metrics across all SendMessage calls. + GetUsage() UsageMetrics + // GetFileChanges returns accumulated file changes across all SendMessage calls. + GetFileChanges() []watch.FileChange + // 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/grpcserver/copilot_service.go b/cli/azd/internal/grpcserver/copilot_service.go index c5b2cc42039..f2c61b8ae6d 100644 --- a/cli/azd/internal/grpcserver/copilot_service.go +++ b/cli/azd/internal/grpcserver/copilot_service.go @@ -22,17 +22,17 @@ import ( type copilotService struct { azdext.UnimplementedCopilotServiceServer - agentFactory *agent.CopilotAgentFactory + agentFactory agent.AgentFactory mu sync.RWMutex - sessions map[string]*agent.CopilotAgent + sessions map[string]agent.Agent } // NewCopilotService creates a new CopilotService gRPC server. -func NewCopilotService(agentFactory *agent.CopilotAgentFactory) azdext.CopilotServiceServer { +func NewCopilotService(agentFactory agent.AgentFactory) azdext.CopilotServiceServer { return &copilotService{ agentFactory: agentFactory, - sessions: make(map[string]*agent.CopilotAgent), + sessions: make(map[string]agent.Agent), } } @@ -152,7 +152,7 @@ func (s *copilotService) SendMessage( // or prepares one for SDK session resumption. func (s *copilotService) resolveOrCreateAgent( ctx context.Context, req *azdext.SendCopilotMessageRequest, -) (copilotAgent *agent.CopilotAgent, isResume bool, err error) { +) (copilotAgent agent.Agent, isResume bool, err error) { if req.SessionId != "" { // Try to reuse an existing managed session s.mu.RLock() @@ -192,7 +192,7 @@ func (s *copilotService) GetUsageMetrics( } return &azdext.GetCopilotUsageMetricsResponse{ - Usage: convertUsageMetrics(copilotAgent.GetCumulativeUsage()), + Usage: convertUsageMetrics(copilotAgent.GetUsage()), }, nil } @@ -206,7 +206,7 @@ func (s *copilotService) GetFileChanges( } return &azdext.GetCopilotFileChangesResponse{ - FileChanges: convertFileChanges(copilotAgent.GetAccumulatedFileChanges()), + FileChanges: convertFileChanges(copilotAgent.GetFileChanges()), }, nil } @@ -230,7 +230,7 @@ func (s *copilotService) StopSession( } // getAgent retrieves a managed agent by session ID. -func (s *copilotService) getAgent(sessionID string) (*agent.CopilotAgent, error) { +func (s *copilotService) getAgent(sessionID string) (agent.Agent, error) { if sessionID == "" { return nil, status.Error(codes.InvalidArgument, "session_id is required") } 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..b374fc1077f --- /dev/null +++ b/cli/azd/internal/grpcserver/copilot_service_test.go @@ -0,0 +1,392 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "testing" + + "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" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- 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) GetUsage() agent.UsageMetrics { + args := m.Called() + return args.Get(0).(agent.UsageMetrics) +} + +func (m *MockAgent) GetFileChanges() []watch.FileChange { + args := m.Called() + if result := args.Get(0); result != nil { + return result.([]watch.FileChange) + } + return nil +} + +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("GetUsage").Return(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("GetFileChanges").Return([]watch.FileChange{ + {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) +} From 6b9695f4e85253480ea423be9170e9686ee5c9bd Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 14:08:59 -0700 Subject: [PATCH 08/11] fix: add missing CopilotService param to new server_test from main merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/grpcserver/server_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/internal/grpcserver/server_test.go b/cli/azd/internal/grpcserver/server_test.go index 6df3fc0fa6e..8416d3a603b 100644 --- a/cli/azd/internal/grpcserver/server_test.go +++ b/cli/azd/internal/grpcserver/server_test.go @@ -127,6 +127,7 @@ func Test_Server_StreamInterceptor(t *testing.T) { azdext.UnimplementedContainerServiceServer{}, azdext.UnimplementedAccountServiceServer{}, azdext.UnimplementedAiModelServiceServer{}, + azdext.UnimplementedCopilotServiceServer{}, ) serverInfo, err := server.Start() From 4631416a21c74a8dce4be9fbe44c9ab36ac8196c Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 14:26:13 -0700 Subject: [PATCH 09/11] refactor: introduce AgentMetrics type with String() for display Replace separate GetUsage/GetFileChanges/PrintSessionMetrics with a unified AgentMetrics type: - AgentMetrics.String() prints file changes then usage metrics - UsageMetrics.Format() renamed to String() (implements fmt.Stringer) - watch.FileChanges named slice type with String() for formatted display - watch.FileChange.String() for individual file change display - Agent interface: GetMetrics() replaces GetUsage/GetFileChanges/PrintSessionMetrics - AgentFactory returns AgentFactory interface, not concrete type - All callers updated: init.go, error middleware, gRPC service, tests - watch.PrintChangedFiles deprecated in favor of GetFileChanges().String() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 4 -- cli/azd/cmd/init.go | 9 +-- cli/azd/cmd/middleware/error.go | 6 +- cli/azd/internal/agent/copilot_agent.go | 62 +++---------------- .../internal/agent/copilot_agent_factory.go | 2 +- cli/azd/internal/agent/types.go | 32 +++++++--- cli/azd/internal/agent/types_test.go | 12 ++-- .../internal/grpcserver/copilot_service.go | 16 ++--- .../grpcserver/copilot_service_test.go | 26 ++++---- cli/azd/pkg/watch/watch.go | 59 ++++++++++++++++-- 10 files changed, 123 insertions(+), 105 deletions(-) diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index c20c4a0cbd5..dd03c21ef42 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -586,10 +586,6 @@ func registerCommonDependencies(container *ioc.NestedContainer) { return agentcopilot.NewCopilotClientManager(nil, cli) }) container.MustRegisterScoped(agent.NewCopilotAgentFactory) - // Register the factory as the AgentFactory interface for the gRPC service - container.MustRegisterScoped(func(f *agent.CopilotAgentFactory) agent.AgentFactory { - return f - }) container.MustRegisterScoped(consent.NewConsentManager) // Agent security manager diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index 341f64c0e3f..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 { @@ -533,8 +533,9 @@ When complete, provide a brief summary of what was accomplished.` } // Show session metrics (usage + file changes) - if ca, ok := copilotAgent.(*agent.CopilotAgent); ok { - ca.PrintSessionMetrics(ctx) + if metrics := copilotAgent.GetMetrics().String(); metrics != "" { + i.console.Message(ctx, "") + 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/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index 91f5c2418db..09851c3e894 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -54,7 +54,7 @@ type CopilotAgent struct { activeCtx atomic.Pointer[context.Context] // current SendMessage context for SDK callbacks display *AgentDisplay // last display for usage metrics (interactive mode) cumulativeUsage UsageMetrics // cumulative metrics across multiple SendMessage calls - accumulatedFileChanges []watch.FileChange // accumulated file changes across all SendMessage calls + accumulatedFileChanges watch.FileChanges // accumulated file changes across all SendMessage calls // Cleanup — ordered slice for deterministic teardown cleanupTasks []cleanupTask @@ -404,14 +404,17 @@ func (a *CopilotAgent) accumulateUsage(turn UsageMetrics) { } } -// GetUsage returns cumulative usage metrics across all SendMessage calls. -func (a *CopilotAgent) GetUsage() UsageMetrics { - return a.cumulativeUsage +// GetMetrics returns cumulative session metrics (usage + file changes). +func (a *CopilotAgent) GetMetrics() AgentMetrics { + return AgentMetrics{ + Usage: a.cumulativeUsage, + FileChanges: a.accumulatedFileChanges, + } } // 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.FileChange { +func (a *CopilotAgent) collectFileChanges(watcher watch.Watcher) watch.FileChanges { if watcher == nil { return nil } @@ -423,55 +426,6 @@ func (a *CopilotAgent) collectFileChanges(watcher watch.Watcher) []watch.FileCha return turnChanges } -// GetFileChanges returns accumulated file changes across all SendMessage calls. -func (a *CopilotAgent) GetFileChanges() []watch.FileChange { - return a.accumulatedFileChanges -} - -// PrintSessionMetrics prints accumulated file changes followed by cumulative usage metrics. -// Call this after the last SendMessage to display session summary. -func (a *CopilotAgent) PrintSessionMetrics(ctx context.Context) { - // Print file changes first - if len(a.accumulatedFileChanges) > 0 { - printFileChanges(a.accumulatedFileChanges) - } - - // Print usage metrics - usageStr := a.cumulativeUsage.Format() - if usageStr != "" { - a.console.Message(ctx, "") - a.console.Message(ctx, usageStr) - } -} - -// printFileChanges prints file changes in the standard format. -func printFileChanges(changes []watch.FileChange) { - fmt.Println(output.WithGrayFormat("\n| Files changed:")) - - cwd, cwdErr := os.Getwd() - getDisplayPath := func(file string) string { - if cwdErr != nil { - return file - } - if relPath, err := filepath.Rel(cwd, file); err == nil { - return relPath - } - return file - } - - for _, change := range changes { - path := getDisplayPath(change.Path) - switch change.ChangeType { - case watch.FileCreated: - fmt.Println(output.WithGrayFormat("| "), output.WithSuccessFormat("+ Created "), path) - case watch.FileModified: - fmt.Println(output.WithGrayFormat("| "), output.WithWarningFormat("± Modified "), path) - case watch.FileDeleted: - fmt.Println(output.WithGrayFormat("| "), output.WithErrorFormat("- Deleted "), path) - } - } -} - // SendMessageWithRetry wraps SendMessage with interactive retry-on-error UX. func (a *CopilotAgent) SendMessageWithRetry(ctx context.Context, prompt string, opts ...SendOption) (*AgentResult, error) { for { diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go index 5324262e265..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, diff --git a/cli/azd/internal/agent/types.go b/cli/azd/internal/agent/types.go index 1a1e85f804c..4f22ec508bb 100644 --- a/cli/azd/internal/agent/types.go +++ b/cli/azd/internal/agent/types.go @@ -21,7 +21,7 @@ type AgentResult struct { // Usage contains token and cost metrics for this turn. Usage UsageMetrics // FileChanges contains files created/modified/deleted during this turn. - FileChanges []watch.FileChange + FileChanges watch.FileChanges } // UsageMetrics tracks resource consumption for an agent session. @@ -39,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 "" } @@ -100,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 @@ -190,10 +210,8 @@ type Agent interface { 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) - // GetUsage returns cumulative usage metrics across all SendMessage calls. - GetUsage() UsageMetrics - // GetFileChanges returns accumulated file changes across all SendMessage calls. - GetFileChanges() []watch.FileChange + // GetMetrics returns cumulative session metrics (usage + file changes). + GetMetrics() AgentMetrics // SessionID returns the current session ID, or empty if no session exists. SessionID() string // Stop terminates the agent and releases resources. 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 index f2c61b8ae6d..7c53bb69e9c 100644 --- a/cli/azd/internal/grpcserver/copilot_service.go +++ b/cli/azd/internal/grpcserver/copilot_service.go @@ -94,14 +94,14 @@ func (s *copilotService) ListSessions( } protoSessions := make([]*azdext.CopilotSessionMetadata, len(sessions)) - for i, sess := range sessions { + for i, session := range sessions { summary := "" - if sess.Summary != nil { - summary = *sess.Summary + if session.Summary != nil { + summary = *session.Summary } protoSessions[i] = &azdext.CopilotSessionMetadata{ - SessionId: sess.SessionID, - ModifiedTime: sess.ModifiedTime, + SessionId: session.SessionID, + ModifiedTime: session.ModifiedTime, Summary: summary, } } @@ -191,8 +191,9 @@ func (s *copilotService) GetUsageMetrics( return nil, err } + metrics := copilotAgent.GetMetrics() return &azdext.GetCopilotUsageMetricsResponse{ - Usage: convertUsageMetrics(copilotAgent.GetUsage()), + Usage: convertUsageMetrics(metrics.Usage), }, nil } @@ -205,8 +206,9 @@ func (s *copilotService) GetFileChanges( return nil, err } + metrics := copilotAgent.GetMetrics() return &azdext.GetCopilotFileChangesResponse{ - FileChanges: convertFileChanges(copilotAgent.GetFileChanges()), + FileChanges: convertFileChanges(metrics.FileChanges), }, nil } diff --git a/cli/azd/internal/grpcserver/copilot_service_test.go b/cli/azd/internal/grpcserver/copilot_service_test.go index b374fc1077f..9f2a90f4257 100644 --- a/cli/azd/internal/grpcserver/copilot_service_test.go +++ b/cli/azd/internal/grpcserver/copilot_service_test.go @@ -56,17 +56,9 @@ func (m *MockAgent) ListSessions(ctx context.Context, cwd string) ([]agent.Sessi return nil, args.Error(1) } -func (m *MockAgent) GetUsage() agent.UsageMetrics { +func (m *MockAgent) GetMetrics() agent.AgentMetrics { args := m.Called() - return args.Get(0).(agent.UsageMetrics) -} - -func (m *MockAgent) GetFileChanges() []watch.FileChange { - args := m.Called() - if result := args.Get(0); result != nil { - return result.([]watch.FileChange) - } - return nil + return args.Get(0).(agent.AgentMetrics) } func (m *MockAgent) SessionID() string { @@ -208,8 +200,10 @@ func TestCopilotService_GetUsageMetrics_ValidSession(t *testing.T) { SessionID: "metrics-session", Usage: agent.UsageMetrics{InputTokens: 100}, }, nil) - mockAgent.On("GetUsage").Return(agent.UsageMetrics{ - Model: "gpt-4o", InputTokens: 500, OutputTokens: 250, DurationMS: 3000, + mockAgent.On("GetMetrics").Return(agent.AgentMetrics{ + Usage: agent.UsageMetrics{ + Model: "gpt-4o", InputTokens: 500, OutputTokens: 250, DurationMS: 3000, + }, }) svc := NewCopilotService(factory) @@ -253,9 +247,11 @@ func TestCopilotService_GetFileChanges_ValidSession(t *testing.T) { mockAgent.On("SendMessage", mock.Anything, "test", mock.Anything).Return(&agent.AgentResult{ SessionID: "files-session", }, nil) - mockAgent.On("GetFileChanges").Return([]watch.FileChange{ - {Path: "main.go", ChangeType: watch.FileModified}, - {Path: "new.txt", ChangeType: watch.FileCreated}, + mockAgent.On("GetMetrics").Return(agent.AgentMetrics{ + FileChanges: watch.FileChanges{ + {Path: "main.go", ChangeType: watch.FileModified}, + {Path: "new.txt", ChangeType: watch.FileCreated}, + }, }) svc := NewCopilotService(factory) diff --git a/cli/azd/pkg/watch/watch.go b/cli/azd/pkg/watch/watch.go index 5c1b676e35a..28803a96e26 100644 --- a/cli/azd/pkg/watch/watch.go +++ b/cli/azd/pkg/watch/watch.go @@ -9,6 +9,7 @@ import ( "log" "os" "path/filepath" + "strings" "sync" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -18,8 +19,9 @@ import ( ) type Watcher interface { + // Deprecated: Use GetFileChanges().String() instead. PrintChangedFiles(ctx context.Context) - GetFileChanges() []FileChange + GetFileChanges() FileChanges } type fileWatcher struct { @@ -224,12 +226,61 @@ type FileChange struct { ChangeType FileChangeType } -// GetFileChanges returns all file changes tracked by the watcher as structured data. -func (fw *fileWatcher) GetFileChanges() []FileChange { +// 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. +func (fw *fileWatcher) GetFileChanges() FileChanges { fw.mu.Lock() defer fw.mu.Unlock() - changes := make([]FileChange, 0, + changes := make(FileChanges, 0, len(fw.fileChanges.Created)+len(fw.fileChanges.Modified)+len(fw.fileChanges.Deleted)) for file := range fw.fileChanges.Created { From 5664aa57a33ca5045b5cd29ebce52a88655f4a7c Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 15:12:52 -0700 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20address=20PR=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20flaky=20tests,=20resource=20leaks,=20thread=20sa?= =?UTF-8?q?fety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch tests: - Replace time.Sleep with require.Eventually for deterministic polling - Sort FileChanges by path for stable output Agent (copilot_agent.go): - Fix watcher leak: use per-turn context.WithCancel, cancel in defer - Log NewWatcher errors instead of ignoring - Add sync.Mutex to guard cumulative metrics and file changes - GetMetrics() acquires lock for thread-safe reads gRPC service (copilot_service.go): - Stop newly-created agent if SendMessage fails (prevents process leak) - Log Stop() errors in StopSession instead of silently dropping Proto: - Update headless field comment to not imply a proto3 default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/grpc/proto/copilot.proto | 2 +- cli/azd/internal/agent/copilot_agent.go | 27 ++++++-- .../internal/grpcserver/copilot_service.go | 19 ++++-- cli/azd/pkg/azdext/copilot.pb.go | 2 +- cli/azd/pkg/watch/watch.go | 8 ++- cli/azd/pkg/watch/watch_test.go | 65 ++++++------------- 6 files changed, 64 insertions(+), 59 deletions(-) diff --git a/cli/azd/grpc/proto/copilot.proto b/cli/azd/grpc/proto/copilot.proto index e7f850b6151..b08375a60a5 100644 --- a/cli/azd/grpc/proto/copilot.proto +++ b/cli/azd/grpc/proto/copilot.proto @@ -85,7 +85,7 @@ message SendCopilotMessageRequest { 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; // Optional: suppress console output (default true for gRPC). + bool headless = 8; // Set to true for headless mode (suppresses console output). } // SendCopilotMessageResponse contains the result of the message. diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index 09851c3e894..947cfb9d351 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "sync/atomic" "time" @@ -53,6 +54,7 @@ type CopilotAgent struct { 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 @@ -317,10 +319,14 @@ func (a *CopilotAgent) sendMessageInteractive( 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() @@ -342,9 +348,10 @@ func (a *CopilotAgent) sendMessageInteractive( } turnUsage := display.GetUsageMetrics() + a.mu.Lock() a.accumulateUsage(turnUsage) - turnFileChanges := a.collectFileChanges(watcher) + a.mu.Unlock() return &AgentResult{ SessionID: a.sessionID, @@ -359,8 +366,12 @@ func (a *CopilotAgent) sendMessageHeadless( ) (*AgentResult, error) { collector := NewHeadlessCollector() - 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 watchCancel() unsubscribe := a.session.On(collector.HandleEvent) defer unsubscribe() @@ -378,9 +389,10 @@ func (a *CopilotAgent) sendMessageHeadless( } turnUsage := collector.GetUsageMetrics() + a.mu.Lock() a.accumulateUsage(turnUsage) - turnFileChanges := a.collectFileChanges(watcher) + a.mu.Unlock() return &AgentResult{ SessionID: a.sessionID, @@ -406,6 +418,9 @@ func (a *CopilotAgent) accumulateUsage(turn UsageMetrics) { // 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, diff --git a/cli/azd/internal/grpcserver/copilot_service.go b/cli/azd/internal/grpcserver/copilot_service.go index 7c53bb69e9c..3bb3a11291c 100644 --- a/cli/azd/internal/grpcserver/copilot_service.go +++ b/cli/azd/internal/grpcserver/copilot_service.go @@ -5,6 +5,7 @@ package grpcserver import ( "context" + "log" "os" "path/filepath" "sync" @@ -120,7 +121,7 @@ func (s *copilotService) SendMessage( return nil, status.Error(codes.InvalidArgument, "prompt cannot be empty") } - copilotAgent, isResume, err := s.resolveOrCreateAgent(ctx, req) + copilotAgent, isNew, isResume, err := s.resolveOrCreateAgent(ctx, req) if err != nil { return nil, err } @@ -132,6 +133,10 @@ func (s *copilotService) SendMessage( 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) } @@ -152,7 +157,7 @@ func (s *copilotService) SendMessage( // or prepares one for SDK session resumption. func (s *copilotService) resolveOrCreateAgent( ctx context.Context, req *azdext.SendCopilotMessageRequest, -) (copilotAgent agent.Agent, isResume bool, err error) { +) (copilotAgent agent.Agent, isNew bool, isResume bool, err error) { if req.SessionId != "" { // Try to reuse an existing managed session s.mu.RLock() @@ -160,7 +165,7 @@ func (s *copilotService) resolveOrCreateAgent( s.mu.RUnlock() if ok { - return existing, false, nil + return existing, false, false, nil } // Not in our map — treat as an SDK session ID to resume @@ -175,11 +180,11 @@ func (s *copilotService) resolveOrCreateAgent( copilotAgent, err = s.agentFactory.Create(ctx, opts...) if err != nil { - return nil, false, status.Errorf(codes.Internal, + return nil, false, false, status.Errorf(codes.Internal, "failed to create copilot agent: %v", err) } - return copilotAgent, isResume, nil + return copilotAgent, true, isResume, nil } // GetUsageMetrics returns cumulative usage metrics cached on the agent. @@ -227,7 +232,9 @@ func (s *copilotService) StopSession( return nil, status.Errorf(codes.NotFound, "session %q not found", req.SessionId) } - copilotAgent.Stop() + if err := copilotAgent.Stop(); err != nil { + log.Printf("[copilot-service] session %q stop error: %v", req.SessionId, err) + } return &azdext.EmptyResponse{}, nil } diff --git a/cli/azd/pkg/azdext/copilot.pb.go b/cli/azd/pkg/azdext/copilot.pb.go index 3b67d642534..8e2c17f678c 100644 --- a/cli/azd/pkg/azdext/copilot.pb.go +++ b/cli/azd/pkg/azdext/copilot.pb.go @@ -355,7 +355,7 @@ type SendCopilotMessageRequest struct { 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"` // Optional: suppress console output (default true for gRPC). + 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 } diff --git a/cli/azd/pkg/watch/watch.go b/cli/azd/pkg/watch/watch.go index 28803a96e26..fe46cc0333f 100644 --- a/cli/azd/pkg/watch/watch.go +++ b/cli/azd/pkg/watch/watch.go @@ -4,11 +4,13 @@ package watch import ( + "cmp" "context" "fmt" "log" "os" "path/filepath" + "slices" "strings" "sync" @@ -275,7 +277,7 @@ func (fc FileChanges) String() string { return b.String() } -// GetFileChanges returns all file changes tracked by the watcher. +// GetFileChanges returns all file changes tracked by the watcher, sorted by path. func (fw *fileWatcher) GetFileChanges() FileChanges { fw.mu.Lock() defer fw.mu.Unlock() @@ -293,5 +295,9 @@ func (fw *fileWatcher) GetFileChanges() FileChanges { 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 index 734f08cec75..7d9c372686e 100644 --- a/cli/azd/pkg/watch/watch_test.go +++ b/cli/azd/pkg/watch/watch_test.go @@ -37,32 +37,24 @@ func TestGetFileChanges_CreatedFile(t *testing.T) { watcher, err := NewWatcher(ctx) require.NoError(t, err) - // Create a file testFile := filepath.Join(dir, "test.txt") err = os.WriteFile(testFile, []byte("hello"), 0600) require.NoError(t, err) - // Wait for the watcher to pick up the change - time.Sleep(200 * time.Millisecond) - - changes := watcher.GetFileChanges() - require.NotEmpty(t, changes) - - found := false - for _, change := range changes { - if filepath.Base(change.Path) == "test.txt" && change.ChangeType == FileCreated { - found = true - break + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "test.txt" && c.ChangeType == FileCreated { + return true + } } - } - require.True(t, found, "expected to find created file test.txt in changes") + 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) - // Create a file before starting the watcher testFile := filepath.Join(dir, "existing.txt") err := os.WriteFile(testFile, []byte("original"), 0600) require.NoError(t, err) @@ -73,31 +65,23 @@ func TestGetFileChanges_ModifiedFile(t *testing.T) { watcher, err := NewWatcher(ctx) require.NoError(t, err) - // Modify the file err = os.WriteFile(testFile, []byte("modified"), 0600) require.NoError(t, err) - // Wait for the watcher to pick up the change - time.Sleep(200 * time.Millisecond) - - changes := watcher.GetFileChanges() - require.NotEmpty(t, changes) - - found := false - for _, change := range changes { - if filepath.Base(change.Path) == "existing.txt" && change.ChangeType == FileModified { - found = true - break + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "existing.txt" && c.ChangeType == FileModified { + return true + } } - } - require.True(t, found, "expected to find modified file existing.txt in changes") + 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) - // Create a file before starting the watcher testFile := filepath.Join(dir, "deleteme.txt") err := os.WriteFile(testFile, []byte("delete me"), 0600) require.NoError(t, err) @@ -108,24 +92,17 @@ func TestGetFileChanges_DeletedFile(t *testing.T) { watcher, err := NewWatcher(ctx) require.NoError(t, err) - // Delete the file err = os.Remove(testFile) require.NoError(t, err) - // Wait for the watcher to pick up the change - time.Sleep(200 * time.Millisecond) - - changes := watcher.GetFileChanges() - require.NotEmpty(t, changes) - - found := false - for _, change := range changes { - if filepath.Base(change.Path) == "deleteme.txt" && change.ChangeType == FileDeleted { - found = true - break + require.Eventually(t, func() bool { + for _, c := range watcher.GetFileChanges() { + if filepath.Base(c.Path) == "deleteme.txt" && c.ChangeType == FileDeleted { + return true + } } - } - require.True(t, found, "expected to find deleted file deleteme.txt in changes") + return false + }, 2*time.Second, 50*time.Millisecond, "expected deleted file deleteme.txt") } func TestFileChangeType_Values(t *testing.T) { From 0eafddc4ae1d8a894269663fcf1b5fd3a84ea6ac Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 18 Mar 2026 18:37:44 -0700 Subject: [PATCH 11/11] feat: add GetMessages RPC to expose Copilot session event log Add GetMessages to the Agent interface and CopilotService gRPC API, exposing the Copilot SDK's session event log to extensions. - Agent.GetMessages(ctx) delegates to session.GetMessages(ctx) - CopilotSessionEvent proto uses google.protobuf.Struct for event data, avoiding schema coupling with the SDK's Data type - Extensions can unmarshal to SDK types or work with the generic map - 2 new unit tests: valid session with events, unknown session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/grpc/proto/copilot.proto | 26 +++ cli/azd/internal/agent/copilot_agent.go | 10 + cli/azd/internal/agent/types.go | 5 + .../internal/grpcserver/copilot_service.go | 56 +++++ .../grpcserver/copilot_service_test.go | 160 +++++++++++++ cli/azd/pkg/azdext/copilot.pb.go | 220 ++++++++++++++++-- cli/azd/pkg/azdext/copilot_grpc.pb.go | 42 ++++ 7 files changed, 495 insertions(+), 24 deletions(-) diff --git a/cli/azd/grpc/proto/copilot.proto b/cli/azd/grpc/proto/copilot.proto index b08375a60a5..196536e66cf 100644 --- a/cli/azd/grpc/proto/copilot.proto +++ b/cli/azd/grpc/proto/copilot.proto @@ -7,6 +7,7 @@ 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 @@ -35,6 +36,10 @@ service CopilotService { // 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 --- @@ -150,3 +155,24 @@ enum CopilotFileChangeType { 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_agent.go b/cli/azd/internal/agent/copilot_agent.go index 947cfb9d351..ebe30b0e694 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -427,6 +427,16 @@ func (a *CopilotAgent) GetMetrics() AgentMetrics { } } +// 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 { diff --git a/cli/azd/internal/agent/types.go b/cli/azd/internal/agent/types.go index 4f22ec508bb..18a2c9544b7 100644 --- a/cli/azd/internal/agent/types.go +++ b/cli/azd/internal/agent/types.go @@ -199,6 +199,9 @@ 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 { @@ -212,6 +215,8 @@ type Agent interface { 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. diff --git a/cli/azd/internal/grpcserver/copilot_service.go b/cli/azd/internal/grpcserver/copilot_service.go index 3bb3a11291c..3180d38e48f 100644 --- a/cli/azd/internal/grpcserver/copilot_service.go +++ b/cli/azd/internal/grpcserver/copilot_service.go @@ -5,6 +5,7 @@ package grpcserver import ( "context" + "encoding/json" "log" "os" "path/filepath" @@ -15,6 +16,7 @@ import ( "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. @@ -217,6 +219,28 @@ func (s *copilotService) GetFileChanges( }, 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, @@ -331,3 +355,35 @@ func convertFileChangeType(ct watch.FileChangeType) azdext.CopilotFileChangeType 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 index 9f2a90f4257..4e2f63bc05d 100644 --- a/cli/azd/internal/grpcserver/copilot_service_test.go +++ b/cli/azd/internal/grpcserver/copilot_service_test.go @@ -5,13 +5,17 @@ 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 --- @@ -61,6 +65,14 @@ func (m *MockAgent) GetMetrics() agent.AgentMetrics { 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) @@ -386,3 +398,151 @@ func TestCopilotService_SendMessage_WithFileChanges(t *testing.T) { 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/pkg/azdext/copilot.pb.go b/cli/azd/pkg/azdext/copilot.pb.go index 8e2c17f678c..94063e6d908 100644 --- a/cli/azd/pkg/azdext/copilot.pb.go +++ b/cli/azd/pkg/azdext/copilot.pb.go @@ -12,6 +12,7 @@ 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" @@ -878,11 +879,164 @@ func (x *StopCopilotSessionRequest) GetSessionId() string { 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\"[\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" + @@ -940,12 +1094,21 @@ const file_copilot_proto_rawDesc = "" + "changeType\":\n" + "\x19StopCopilotSessionRequest\x12\x1d\n" + "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId*\xb4\x01\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\x9c\x04\n" + + " COPILOT_FILE_CHANGE_TYPE_DELETED\x10\x032\xf2\x04\n" + "\x0eCopilotService\x12Q\n" + "\n" + "Initialize\x12 .azdext.InitializeCopilotRequest\x1a!.azdext.InitializeCopilotResponse\x12W\n" + @@ -953,7 +1116,8 @@ const file_copilot_proto_rawDesc = "" + "\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.EmptyResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + "\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 @@ -968,7 +1132,7 @@ func file_copilot_proto_rawDescGZIP() []byte { } var file_copilot_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_copilot_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +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 @@ -985,7 +1149,11 @@ var file_copilot_proto_goTypes = []any{ (*GetCopilotFileChangesResponse)(nil), // 12: azdext.GetCopilotFileChangesResponse (*CopilotFileChange)(nil), // 13: azdext.CopilotFileChange (*StopCopilotSessionRequest)(nil), // 14: azdext.StopCopilotSessionRequest - (*EmptyResponse)(nil), // 15: azdext.EmptyResponse + (*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 @@ -994,23 +1162,27 @@ var file_copilot_proto_depIdxs = []int32{ 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 - 1, // 6: azdext.CopilotService.Initialize:input_type -> azdext.InitializeCopilotRequest - 3, // 7: azdext.CopilotService.ListSessions:input_type -> azdext.ListCopilotSessionsRequest - 6, // 8: azdext.CopilotService.SendMessage:input_type -> azdext.SendCopilotMessageRequest - 8, // 9: azdext.CopilotService.GetUsageMetrics:input_type -> azdext.GetCopilotUsageMetricsRequest - 11, // 10: azdext.CopilotService.GetFileChanges:input_type -> azdext.GetCopilotFileChangesRequest - 14, // 11: azdext.CopilotService.StopSession:input_type -> azdext.StopCopilotSessionRequest - 2, // 12: azdext.CopilotService.Initialize:output_type -> azdext.InitializeCopilotResponse - 4, // 13: azdext.CopilotService.ListSessions:output_type -> azdext.ListCopilotSessionsResponse - 7, // 14: azdext.CopilotService.SendMessage:output_type -> azdext.SendCopilotMessageResponse - 9, // 15: azdext.CopilotService.GetUsageMetrics:output_type -> azdext.GetCopilotUsageMetricsResponse - 12, // 16: azdext.CopilotService.GetFileChanges:output_type -> azdext.GetCopilotFileChangesResponse - 15, // 17: azdext.CopilotService.StopSession:output_type -> azdext.EmptyResponse - 12, // [12:18] is the sub-list for method output_type - 6, // [6:12] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 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() } @@ -1025,7 +1197,7 @@ func file_copilot_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_copilot_proto_rawDesc), len(file_copilot_proto_rawDesc)), NumEnums: 1, - NumMessages: 14, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/copilot_grpc.pb.go b/cli/azd/pkg/azdext/copilot_grpc.pb.go index b280eb213ff..b837b05ca8f 100644 --- a/cli/azd/pkg/azdext/copilot_grpc.pb.go +++ b/cli/azd/pkg/azdext/copilot_grpc.pb.go @@ -28,6 +28,7 @@ const ( 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. @@ -56,6 +57,9 @@ type CopilotServiceClient interface { 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 { @@ -126,6 +130,16 @@ func (c *copilotServiceClient) StopSession(ctx context.Context, in *StopCopilotS 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. @@ -152,6 +166,9 @@ type CopilotServiceServer interface { 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() } @@ -180,6 +197,9 @@ func (UnimplementedCopilotServiceServer) GetFileChanges(context.Context, *GetCop 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() {} @@ -309,6 +329,24 @@ func _CopilotService_StopSession_Handler(srv interface{}, ctx context.Context, d 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) @@ -340,6 +378,10 @@ var CopilotService_ServiceDesc = grpc.ServiceDesc{ MethodName: "StopSession", Handler: _CopilotService_StopSession_Handler, }, + { + MethodName: "GetMessages", + Handler: _CopilotService_GetMessages_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "copilot.proto",