Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ a365 auth logout
| `A365_CLIENT_ID` | `--client-id` | Entra app client ID (default: `aebc6443-996d-45c2-90f0-388ff96faa56`) |
| `A365_TENANT_ID` | `--tenant-id` | Entra tenant ID (optional, defaults to `organizations`) |
| `A365_ENDPOINT` | — | Override the agent365 base URL |
| `A365_MCP_RESPONSE_HEADER_TIMEOUT` | — | Override the MCP HTTP response-header timeout (for example `180s`, `5m`) |
| `A365_COPILOT_RESPONSE_HEADER_TIMEOUT` | — | Override the Copilot MCP response-header timeout (default: `5m`) |

## Configuration

Expand Down
9 changes: 9 additions & 0 deletions docs/copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,12 @@ a365 copilot chat "Can you give more detail on the second point?" \
# Output as JSON
a365 copilot chat "Who shared files with me this week?" --output json
```


## Timeout tuning

Copilot requests can take longer than typical MCP tool calls. By default, `a365` waits up to `5m` for Copilot response headers before failing. Override this with `A365_COPILOT_RESPONSE_HEADER_TIMEOUT`, or use `A365_MCP_RESPONSE_HEADER_TIMEOUT` to change the timeout for every MCP service.

```sh
A365_COPILOT_RESPONSE_HEADER_TIMEOUT=10m a365 copilot chat "Summarize recent project updates"
```
110 changes: 96 additions & 14 deletions internal/commands/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import (
"github.com/sozercan/a365cli/internal/output"
)

const copilotChatTool = "copilot_chat"
const (
copilotChatTool = "copilot_chat"
copilotServiceErrorMaxRetries = 1
copilotServiceErrorRetryDelay = time.Second
)

// CopilotCmd groups all Copilot subcommands.
type CopilotCmd struct {
Expand All @@ -26,6 +30,15 @@ func copilotEndpoint() string {
return config.Endpoint("copilot")
}

type copilotServiceError struct {
message string
retryable bool
}

func (e *copilotServiceError) Error() string {
return e.message
}

// CopilotChatCmd searches internal M365 content using natural language.
type CopilotChatCmd struct {
Message string `arg:"" help:"Natural language question about your M365 content" optional:""`
Expand Down Expand Up @@ -119,22 +132,42 @@ func callCopilot(ctx *commands.Context, message, conversationID string) (map[str
args["conversationId"] = conversationID
}

resp, err := client.CallTool(ctx.Ctx, copilotChatTool, args)
if err != nil {
return nil, "", fmt.Errorf("copilot chat: %w", err)
}
for attempt := 0; ; attempt++ {
resp, err := client.CallTool(ctx.Ctx, copilotChatTool, args)
if err != nil {
return nil, "", fmt.Errorf("copilot chat: %w", err)
}

data, err := output.ExtractContent(resp)
if err != nil {
return nil, "", err
}
data, err := output.ExtractContent(resp)
if err != nil {
return nil, "", err
}

nextConversationID := findConversationID(data)
if ctx.Output.Format != output.FormatJSON {
data = normalizeCopilotResponse(data, nextConversationID)
}
if svcErr := copilotServiceErrorFromData(data); svcErr != nil {
if svcErr.retryable && attempt < copilotServiceErrorMaxRetries {
if ctx.Verbose {
fmt.Fprintf(os.Stderr, "--- Copilot returned a retryable service error; retrying (attempt %d/%d) after %v\n%s\n", attempt+1, copilotServiceErrorMaxRetries, copilotServiceErrorRetryDelay, svcErr.Error())
}

select {
case <-ctx.Ctx.Done():
return nil, "", ctx.Ctx.Err()
case <-time.After(copilotServiceErrorRetryDelay):
}

continue
}

return nil, "", fmt.Errorf("copilot chat: %w", svcErr)
}

return data, nextConversationID, nil
nextConversationID := findConversationID(data)
if ctx.Output.Format != output.FormatJSON {
data = normalizeCopilotResponse(data, nextConversationID)
}

return data, nextConversationID, nil
}
}

func printCopilotResponse(ctx *commands.Context, data map[string]any) error {
Expand Down Expand Up @@ -232,6 +265,55 @@ func cloneMap(data map[string]any) map[string]any {
return cloned
}

func copilotServiceErrorFromData(data map[string]any) *copilotServiceError {
_, message := extractPrimaryText(data)
message = sanitizeCopilotServiceMessage(message)
if message == "" {
return nil
}

lower := strings.ToLower(message)
if !strings.HasPrefix(lower, "error executing tool:") {
return nil
}

return &copilotServiceError{
message: message,
retryable: isRetryableCopilotServiceError(message),
}
}

func isRetryableCopilotServiceError(message string) bool {
lower := strings.ToLower(message)
return strings.Contains(lower, "timed out") || strings.Contains(lower, "timed-out") || strings.Contains(lower, "timeout")
}

func sanitizeCopilotServiceMessage(message string) string {
message = strings.ReplaceAll(message, "\r\n", "\n")
message = strings.ReplaceAll(message, "\r", "\n")

seen := map[string]struct{}{}
lines := strings.Split(message, "\n")
cleaned := make([]string, 0, len(lines))

for i, line := range lines {
line = strings.TrimSpace(line)
if i == 0 {
line = strings.TrimSpace(strings.TrimPrefix(line, "Error:"))
}
if line == "" {
continue
}
if _, ok := seen[line]; ok {
continue
}
seen[line] = struct{}{}
cleaned = append(cleaned, line)
}

return strings.TrimSpace(strings.Join(cleaned, "\n"))
}

func normalizeCopilotResponse(data map[string]any, conversationID string) map[string]any {
message := extractConversationMessage(data)
if message == "" {
Expand Down
164 changes: 164 additions & 0 deletions internal/commands/copilot/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,118 @@ func TestCopilotChatCmd_Run(t *testing.T) {
}
}

func TestCallCopilot_RetriesRetryableServiceError(t *testing.T) {
var toolCalls int
server := newCopilotToolServer(t, [][]map[string]any{
{
{"type": "text", "text": "Error: Error executing tool: Outgoing HTTP request timed-out.\r\nCorrelationId: retry-1, TimeStamp: 2025-12-17T17:58:01Z"},
{"type": "text", "text": "CorrelationId: retry-1, TimeStamp: 2025-12-17T17:58:01Z"},
},
{
{"type": "text", "text": `{"message":"Recovered answer","conversationId":"conv-123"}`},
},
}, &toolCalls)
t.Cleanup(func() { server.Close() })
t.Setenv("A365_ENDPOINT", server.URL+"/")

ctx := &commands.Context{
Ctx: context.Background(),
TokenProvider: func(context.Context) (string, error) {
return "test-token", nil
},
Output: &output.Formatter{Format: output.FormatHuman, Writer: io.Discard},
}

data, conversationID, err := callCopilot(ctx, "Summarize my week", "")
if err != nil {
t.Fatalf("callCopilot() error: %v", err)
}
if toolCalls != 2 {
t.Fatalf("expected 2 Copilot tool calls after retry, got %d", toolCalls)
}
if data["message"] != "Recovered answer" {
t.Fatalf("expected recovered message, got %v", data["message"])
}
if conversationID != "conv-123" {
t.Fatalf("expected conversation ID to round-trip, got %q", conversationID)
}
}

func TestCallCopilot_ReturnsRetryableServiceErrorAfterExhaustion(t *testing.T) {
var toolCalls int
server := newCopilotToolServer(t, [][]map[string]any{
{
{"type": "text", "text": "Error: Error executing tool: Outgoing HTTP request timed-out.\r\nCorrelationId: retry-1, TimeStamp: 2025-12-17T17:58:01Z"},
{"type": "text", "text": "CorrelationId: retry-1, TimeStamp: 2025-12-17T17:58:01Z"},
},
{
{"type": "text", "text": "Error: Error executing tool: Outgoing HTTP request timed-out.\r\nCorrelationId: retry-2, TimeStamp: 2025-12-17T17:58:02Z"},
{"type": "text", "text": "CorrelationId: retry-2, TimeStamp: 2025-12-17T17:58:02Z"},
},
}, &toolCalls)
t.Cleanup(func() { server.Close() })
t.Setenv("A365_ENDPOINT", server.URL+"/")

ctx := &commands.Context{
Ctx: context.Background(),
TokenProvider: func(context.Context) (string, error) {
return "test-token", nil
},
Output: &output.Formatter{Format: output.FormatHuman, Writer: io.Discard},
}

_, _, err := callCopilot(ctx, "Summarize my week", "")
if err == nil {
t.Fatal("expected retried timeout payload to surface as an error")
}
if toolCalls != 2 {
t.Fatalf("expected 2 Copilot tool calls before failing, got %d", toolCalls)
}
if !strings.Contains(err.Error(), "copilot chat: Error executing tool: Outgoing HTTP request timed-out.") {
t.Fatalf("expected timeout error message, got %v", err)
}
if strings.Count(err.Error(), "CorrelationId:") != 1 {
t.Fatalf("expected correlation metadata to be deduplicated, got %q", err.Error())
}
if !strings.Contains(err.Error(), "retry-2") {
t.Fatalf("expected final retry correlation ID, got %v", err)
}
}

func TestCallCopilot_ReturnsNonRetryableServiceErrorWithoutRetry(t *testing.T) {
var toolCalls int
server := newCopilotToolServer(t, [][]map[string]any{
{
{"type": "text", "text": "Error: Error executing tool: upstream unavailable.\r\nCorrelationId: fail-1, TimeStamp: 2025-12-17T17:58:01Z"},
{"type": "text", "text": "CorrelationId: fail-1, TimeStamp: 2025-12-17T17:58:01Z"},
},
{
{"type": "text", "text": `{"message":"unexpected retry"}`},
},
}, &toolCalls)
t.Cleanup(func() { server.Close() })
t.Setenv("A365_ENDPOINT", server.URL+"/")

ctx := &commands.Context{
Ctx: context.Background(),
TokenProvider: func(context.Context) (string, error) {
return "test-token", nil
},
Output: &output.Formatter{Format: output.FormatHuman, Writer: io.Discard},
}

_, _, err := callCopilot(ctx, "Summarize my week", "")
if err == nil {
t.Fatal("expected non-timeout tool failure to surface as an error")
}
if toolCalls != 1 {
t.Fatalf("expected non-retryable service error to avoid retry, got %d calls", toolCalls)
}
if !strings.Contains(err.Error(), "copilot chat: Error executing tool: upstream unavailable.") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestPrintCopilotResponse_Human(t *testing.T) {
var buf bytes.Buffer
ctx := &commands.Context{
Expand Down Expand Up @@ -176,6 +288,58 @@ func TestNormalizeCopilotResponse(t *testing.T) {
}
}

func newCopilotToolServer(t *testing.T, toolResponses [][]map[string]any, toolCalls *int) *httptest.Server {
t.Helper()

return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}

var req struct {
ID int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}

w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Mcp-Session-Id", "test-session-id")

switch req.Method {
case "initialize":
io.WriteString(w, "event: message\ndata: "+testutil.MustJSON(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "test", "version": "1.0"},
},
})+"\n\n")
case "tools/call":
idx := *toolCalls
*toolCalls++
if idx >= len(toolResponses) {
idx = len(toolResponses) - 1
}
io.WriteString(w, "event: message\ndata: "+testutil.MustJSON(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"content": toolResponses[idx],
},
})+"\n\n")
default:
http.Error(w, "unknown method", http.StatusBadRequest)
}
}))
}

func TestRunInteractiveLoop_ReusesConversationID(t *testing.T) {
var calls []map[string]any

Expand Down
34 changes: 34 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/url"
"os"
"strings"
"time"
)

const (
Expand All @@ -29,6 +30,12 @@ const (

// DefaultClientID is the default Entra app client ID (VS Code MCP extension).
DefaultClientID = "aebc6443-996d-45c2-90f0-388ff96faa56"

// DefaultMCPResponseHeaderTimeout is the default HTTP response-header timeout for MCP requests.
DefaultMCPResponseHeaderTimeout = 60 * time.Second

// DefaultCopilotResponseHeaderTimeout is longer because Copilot requests can take longer to start streaming.
DefaultCopilotResponseHeaderTimeout = 5 * time.Minute
)

// Servers maps friendly names to agent365 MCP server names.
Expand Down Expand Up @@ -73,6 +80,33 @@ func BaseURL() string {
return base
}

// MCPResponseHeaderTimeout returns the HTTP response-header timeout for MCP requests.
//
// A365_MCP_RESPONSE_HEADER_TIMEOUT overrides the default for all services.
// A365_COPILOT_RESPONSE_HEADER_TIMEOUT overrides the Copilot service specifically.
func MCPResponseHeaderTimeout(service string) time.Duration {
timeout := DefaultMCPResponseHeaderTimeout
if strings.EqualFold(service, "copilot") {
timeout = DefaultCopilotResponseHeaderTimeout
}

if v := strings.TrimSpace(os.Getenv("A365_MCP_RESPONSE_HEADER_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
timeout = d
}
}

if strings.EqualFold(service, "copilot") {
if v := strings.TrimSpace(os.Getenv("A365_COPILOT_RESPONSE_HEADER_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
timeout = d
}
}
}

return timeout
}

// ValidateEndpointURL rejects malformed endpoints and non-loopback plaintext HTTP.
func ValidateEndpointURL(raw string) error {
if raw == "" {
Expand Down
Loading