diff --git a/AGENTS.md b/AGENTS.md
index 7bbb4c6..3a77075 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,10 +8,12 @@
## Build and Test
```bash
-make build # build
-make test # go test ./... -v
-go test ./... -cover # with coverage
-go vet ./... # lint
+make build # build
+make test # go test ./... -v
+go test ./... -cover # with coverage
+go vet ./... # vet checks
+golangci-lint run ./... # lint checks (matches CI)
+golangci-lint run ./... --fix # auto-fix supported lint issues
```
Binary is always called `a365`. Module is `github.com/sozercan/a365cli`.
@@ -56,7 +58,8 @@ Adding a new service = new directory in `internal/commands/`, register in `main.
- Use plan mode for architectural changes or new service additions
- Prefer `make build` over `go build`
- Run `go test ./... -count=1` after any code change
-- Run `go vet ./...` before committing
+- Run `go vet ./...` and `golangci-lint run ./...` before committing
+- If lint issues look auto-fixable, try `golangci-lint run ./... --fix` before making manual edits
- When adding a new M365 service, follow the pattern in CONTRIBUTING.md "Adding a New Service"
- For live testing, the default client ID works out of the box — override with `A365_CLIENT_ID` if needed
- Never post messages or modify data during testing without explicit user permission — use `--dry-run`
diff --git a/README.md b/README.md
index 401bf87..3fecd90 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@ a365 teams list # List your Teams
a365 mail search '?$top=5' # Recent emails
a365 cal list # Upcoming meetings
a365 copilot chat "Summarize my week" # Ask Copilot
+a365 copilot agents # List available Copilot agents
a365 copilot chat # Interactive Copilot prompt
a365 me whoami # Your profile
a365 odr ls # OneDrive files
@@ -106,14 +107,14 @@ CLI flags and env vars always take precedence over config file values.
| [SharePoint Lists](docs/sp-lists.md) | — | 13 | Lists, items, columns |
| [OneDrive](docs/onedrive-remote.md) | `odr` | 12 | Personal OneDrive file management |
| [Me](docs/me.md) | — | 5 | User profiles, org chart |
-| [Copilot](docs/copilot.md) | — | 1 | Natural language M365 search with interactive chat |
+| [Copilot](docs/copilot.md) | — | 2 | Natural language M365 search with agent-aware chat |
| [Word](docs/word.md) | — | 4 | Documents, comments |
| [Excel](docs/excel.md) | — | 4 | Workbooks, comments |
| [Admin](docs/admin.md) | — | 3 | Users, licenses |
| [Admin365](docs/admin365.md) | — | 14 | Agent policies, Copilot settings |
| [Triggers](docs/triggers.md) | — | 9 | Event-driven automation |
| [WebSearch](docs/websearch.md) | — | 1 | Web search |
-| [DASearch](docs/dasearch.md) | — | 1 | Discover Copilot agents |
+| [DASearch](docs/dasearch.md) | — | 1 | Low-level Copilot agent discovery (raw DASearch output) |
| [Knowledge](docs/knowledge.md) | — | 5 | Federated knowledge |
| [NLWeb](docs/nlweb.md) | — | 3 | Natural language search |
diff --git a/docs/copilot.md b/docs/copilot.md
index 2a314d2..f5e33c2 100644
--- a/docs/copilot.md
+++ b/docs/copilot.md
@@ -2,16 +2,25 @@
Ask natural language questions about all your Microsoft 365 content, including documents, emails, chats, and files. Copilot uses M365 intelligence to find and summarize information across your tenant.
+You can also inspect available Copilot agents and target a specific agent when chatting. Web search is enabled by default for `copilot chat`; use `--no-web-search` to send `enableWebSearch=false` to Copilot.
+
## Commands
| Command | Description | Key Arguments |
|---------|-------------|---------------|
-| `copilot chat` | Ask Copilot about your M365 content, or start an interactive prompt | `[message]`, `--conversation-id` |
+| `copilot chat` | Ask Copilot about your M365 content, or start an interactive prompt | `[message]`, `--conversation-id`, `--agent`, `--no-web-search` |
+| `copilot agents` | List available Copilot agents and their chat selectors | _(none)_ |
## Arguments
- **`[message]`** (optional) -- Natural language question about your M365 content. If omitted and stdin is interactive, `a365` starts a Copilot prompt.
- **`--conversation-id`** (optional) -- Conversation ID for follow-up queries. Use `--output json` when you need to inspect returned conversation IDs.
+- **`--agent`** (optional) -- Copilot agent name, selector, or title ID. Resolve available selectors with `a365 copilot agents`.
+- **`--no-web-search`** (optional) -- Request Copilot with web search disabled. By default, `copilot chat` sends `enableWebSearch=true`; with `--no-web-search`, `a365` sends `enableWebSearch=false`.
+
+## Behavior note
+
+`--no-web-search` controls the request sent by `a365`, not a strict guarantee about Copilot's final grounding behavior. In live testing, Copilot could still return public/external grounding or citations for some prompts even when `enableWebSearch=false`.
## Examples
@@ -19,12 +28,21 @@ Ask natural language questions about all your Microsoft 365 content, including d
# Ask a simple question
a365 copilot chat "What were the key decisions from last week's project standup?"
+# List available Copilot agents and selectors
+a365 copilot agents
+
+# Target a specific Copilot agent
+a365 copilot chat --agent "Researcher" "Summarize the latest customer escalations"
+
# Summarize recent emails from a colleague
a365 copilot chat "Summarize recent emails from Alice about the Q3 budget"
# Search across documents
a365 copilot chat "Find the latest sales forecast spreadsheet"
+# Ask Copilot with web search disabled
+a365 copilot chat --no-web-search "Summarize recent emails from Alice about the Q3 budget"
+
# Start an interactive Copilot session
a365 copilot chat
diff --git a/docs/dasearch.md b/docs/dasearch.md
index d099def..57f642c 100644
--- a/docs/dasearch.md
+++ b/docs/dasearch.md
@@ -1,16 +1,21 @@
# DASearch
-Declarative Agent search — discover available M365 Copilot agents.
+Declarative Agent search — low-level discovery of available M365 Copilot agents.
+
+For the normalized, chat-ready selector view used by `a365 copilot chat --agent`, prefer `a365 copilot agents`. `dasearch agents` remains useful when you want the raw DASearch payload.
## Commands
| Command | Description | Key Arguments |
|---------|-------------|---------------|
-| `dasearch agents` | List available M365 Copilot agents | _(none)_ |
+| `dasearch agents` | List available M365 Copilot agents from the raw DASearch response | _(none)_ |
## Examples
```bash
-# List all available Copilot agents in your tenant
+# List the raw DASearch agent payload
a365 dasearch agents
+
+# Prefer this when you want selectors for copilot chat --agent
+a365 copilot agents
```
diff --git a/internal/commands/copilot/agents.go b/internal/commands/copilot/agents.go
new file mode 100644
index 0000000..4810175
--- /dev/null
+++ b/internal/commands/copilot/agents.go
@@ -0,0 +1,559 @@
+package copilot
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/sozercan/a365cli/internal/commands"
+ "github.com/sozercan/a365cli/internal/config"
+ "github.com/sozercan/a365cli/internal/output"
+)
+
+const copilotAvailableAgentsTool = "M365_Copilot_Get_Available_Agents"
+
+// CopilotAgentsCmd lists available Copilot agents and their chat selectors.
+type CopilotAgentsCmd struct{}
+
+type agentInfo struct {
+ Name string
+ Selector string
+ TitleID string
+ TitleName string
+ Type string
+ DeveloperName string
+ Description string
+ Version string
+ AcquisitionState string
+ SharedSelector bool
+ SharedSelectorCount int
+}
+
+func (c *CopilotAgentsCmd) Run(ctx *commands.Context) error {
+ agents, err := fetchAvailableAgents(ctx)
+ if err != nil {
+ return err
+ }
+ return ctx.Output.PrintList("agents", output.CopilotAgentColumns, agentRows(agents))
+}
+
+func copilotAgentsEndpoint() string {
+ return config.Endpoint("dasearch")
+}
+
+func fetchAvailableAgents(ctx *commands.Context) ([]agentInfo, error) {
+ client := ctx.NewMCPClient(copilotAgentsEndpoint())
+ if err := client.Initialize(ctx.Ctx); err != nil {
+ return nil, fmt.Errorf("initialize: %w", err)
+ }
+
+ resp, err := client.CallTool(ctx.Ctx, copilotAvailableAgentsTool, map[string]any{})
+ if err != nil {
+ return nil, fmt.Errorf("list Copilot agents: %w", err)
+ }
+
+ data, err := output.ExtractContent(resp)
+ if err != nil {
+ return nil, err
+ }
+
+ return normalizeAvailableAgents(data), nil
+}
+
+func normalizeAvailableAgents(data map[string]any) []agentInfo {
+ rows := output.ToRows(data, "availableAgents")
+ if rows == nil {
+ rows = output.ToRows(data, "agents")
+ }
+
+ agents := make([]agentInfo, 0, len(rows))
+ seen := map[string]struct{}{}
+ for _, row := range rows {
+ agent := normalizeAgentRow(row)
+ key := agentDedupeKey(agent)
+ if key == "" {
+ continue
+ }
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ agents = append(agents, agent)
+ }
+
+ selectorCounts := map[string]int{}
+ for _, agent := range agents {
+ if agent.Selector != "" {
+ selectorCounts[agent.Selector]++
+ }
+ }
+ for i := range agents {
+ count := selectorCounts[agents[i].Selector]
+ agents[i].SharedSelectorCount = count
+ agents[i].SharedSelector = count > 1
+ }
+
+ sort.Slice(agents, func(i, j int) bool {
+ left := strings.ToLower(agentSortKey(agents[i]))
+ right := strings.ToLower(agentSortKey(agents[j]))
+ if left != right {
+ return left < right
+ }
+ return agents[i].TitleID < agents[j].TitleID
+ })
+
+ return agents
+}
+
+func normalizeAgentRow(row map[string]any) agentInfo {
+ selector := strings.TrimSpace(stringValue(row, "selector"))
+ if selector == "" {
+ selector = strings.TrimSpace(stringValue(row, "agentId"))
+ }
+
+ name := strings.TrimSpace(stringValue(row, "name"))
+ titleName := strings.TrimSpace(stringValue(row, "titleName"))
+ if name == "" {
+ name = titleName
+ }
+ if name == "" {
+ name = selector
+ }
+ if name == "" {
+ name = strings.TrimSpace(stringValue(row, "titleId"))
+ }
+
+ return agentInfo{
+ Name: name,
+ Selector: selector,
+ TitleID: strings.TrimSpace(stringValue(row, "titleId")),
+ TitleName: titleName,
+ Type: strings.TrimSpace(stringValue(row, "type")),
+ DeveloperName: strings.TrimSpace(stringValue(row, "developerName")),
+ Description: strings.TrimSpace(stringValue(row, "description")),
+ Version: strings.TrimSpace(stringValue(row, "version")),
+ AcquisitionState: strings.TrimSpace(stringValue(row, "acquisitionState")),
+ }
+}
+
+func agentDedupeKey(agent agentInfo) string {
+ if agent.TitleID != "" {
+ return agent.TitleID
+ }
+ if agent.Name == "" && agent.Selector == "" {
+ return ""
+ }
+ return agent.Name + "\x00" + agent.Selector
+}
+
+func agentSortKey(agent agentInfo) string {
+ if agent.Name != "" {
+ return agent.Name
+ }
+ if agent.TitleName != "" {
+ return agent.TitleName
+ }
+ if agent.Selector != "" {
+ return agent.Selector
+ }
+ return agent.TitleID
+}
+
+func stringValue(row map[string]any, key string) string {
+ value, ok := row[key]
+ if !ok || value == nil {
+ return ""
+ }
+ s, ok := value.(string)
+ if ok {
+ return s
+ }
+ return fmt.Sprintf("%v", value)
+}
+
+func agentRows(agents []agentInfo) []map[string]any {
+ rows := make([]map[string]any, 0, len(agents))
+ for _, agent := range agents {
+ status := "ok"
+ targetable := true
+ switch {
+ case agent.Selector == "":
+ status = "missing"
+ targetable = false
+ case agent.SharedSelector:
+ status = "shared"
+ targetable = false
+ }
+
+ rows = append(rows, map[string]any{
+ "name": agent.Name,
+ "selector": agent.Selector,
+ "agentId": agent.Selector,
+ "titleId": agent.TitleID,
+ "titleName": agent.TitleName,
+ "type": agent.Type,
+ "developerName": agent.DeveloperName,
+ "description": agent.Description,
+ "version": agent.Version,
+ "acquisitionState": agent.AcquisitionState,
+ "sharedSelector": agent.SharedSelector,
+ "sharedSelectorCount": agent.SharedSelectorCount,
+ "targetable": targetable,
+ "status": status,
+ })
+ }
+ return rows
+}
+
+func resolveAgentForChat(ctx *commands.Context, value string) (string, error) {
+ query := strings.TrimSpace(value)
+ if query == "" {
+ return "", nil
+ }
+
+ agents, err := fetchAvailableAgents(ctx)
+ if err != nil {
+ return "", fmt.Errorf("resolve Copilot agent %q: %w", query, err)
+ }
+
+ agent, err := resolveAgent(agents, query)
+ if err != nil {
+ return "", err
+ }
+ return agent.Selector, nil
+}
+
+func resolveAgent(agents []agentInfo, query string) (agentInfo, error) {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return agentInfo{}, fmt.Errorf("agent name or id is required")
+ }
+
+ if matches := exactNameMatches(agents, query); len(matches) > 0 {
+ return resolveMatchedAgent(query, matches, agents)
+ }
+ if matches := exactCaseInsensitiveNameMatches(agents, query); len(matches) > 0 {
+ return resolveMatchedAgent(query, matches, agents)
+ }
+ if matches := exactSelectorMatches(agents, query); len(matches) > 0 {
+ agent := matches[0]
+ if err := validateResolvedAgent(query, agent, agents); err != nil {
+ return agentInfo{}, err
+ }
+ return agent, nil
+ }
+ if matches := exactTitleIDMatches(agents, query); len(matches) > 0 {
+ return resolveMatchedAgent(query, matches, agents)
+ }
+ if matches := selectorPrefixMatches(agents, query); len(matches) > 0 {
+ if len(matches) > 1 {
+ return agentInfo{}, ambiguousAgentError(query, matches)
+ }
+ agent := matches[0]
+ if err := validateResolvedAgent(query, agent, agents); err != nil {
+ return agentInfo{}, err
+ }
+ return agent, nil
+ }
+ if matches := titleIDPrefixMatches(agents, query); len(matches) > 0 {
+ return resolveMatchedAgent(query, matches, agents)
+ }
+
+ return agentInfo{}, unknownAgentError(query, agents)
+}
+
+func exactNameMatches(agents []agentInfo, query string) []agentInfo {
+ var matches []agentInfo
+ for _, agent := range agents {
+ if agent.Name == query {
+ matches = append(matches, agent)
+ }
+ }
+ return matches
+}
+
+func exactCaseInsensitiveNameMatches(agents []agentInfo, query string) []agentInfo {
+ var matches []agentInfo
+ for _, agent := range agents {
+ if strings.EqualFold(agent.Name, query) {
+ matches = append(matches, agent)
+ }
+ }
+ return matches
+}
+
+func exactSelectorMatches(agents []agentInfo, query string) []agentInfo {
+ var matches []agentInfo
+ for _, agent := range agents {
+ if agent.Selector == query {
+ matches = append(matches, agent)
+ }
+ }
+ return matches
+}
+
+func exactTitleIDMatches(agents []agentInfo, query string) []agentInfo {
+ var matches []agentInfo
+ for _, agent := range agents {
+ if agent.TitleID == query {
+ matches = append(matches, agent)
+ }
+ }
+ return matches
+}
+
+func selectorPrefixMatches(agents []agentInfo, query string) []agentInfo {
+ query = strings.ToLower(query)
+ seen := map[string]struct{}{}
+ matches := make([]agentInfo, 0)
+ for _, agent := range agents {
+ selector := strings.ToLower(agent.Selector)
+ if selector == "" || !strings.HasPrefix(selector, query) {
+ continue
+ }
+ if _, ok := seen[agent.Selector]; ok {
+ continue
+ }
+ seen[agent.Selector] = struct{}{}
+ matches = append(matches, agent)
+ }
+ return matches
+}
+
+func titleIDPrefixMatches(agents []agentInfo, query string) []agentInfo {
+ query = strings.ToLower(query)
+ var matches []agentInfo
+ for _, agent := range agents {
+ titleID := strings.ToLower(agent.TitleID)
+ if titleID == "" || !strings.HasPrefix(titleID, query) {
+ continue
+ }
+ matches = append(matches, agent)
+ }
+ return matches
+}
+
+func resolveMatchedAgent(query string, matches []agentInfo, catalog []agentInfo) (agentInfo, error) {
+ if len(matches) > 1 {
+ return agentInfo{}, ambiguousAgentError(query, matches)
+ }
+ if err := validateResolvedAgent(query, matches[0], catalog); err != nil {
+ return agentInfo{}, err
+ }
+ return matches[0], nil
+}
+
+func validateResolvedAgent(query string, agent agentInfo, catalog []agentInfo) error {
+ if agent.Selector == "" {
+ return fmt.Errorf("copilot agent %q does not expose a usable chat selector. Run 'a365 copilot agents' to inspect available selectors", query)
+ }
+ if !agent.SharedSelector {
+ return nil
+ }
+
+ shared := sharedSelectorAgents(catalog, agent.Selector)
+ return fmt.Errorf(
+ "copilot agent %q resolves to shared selector %q, which is used by %d agents (%s). Individual targeting is not available for this selector; run 'a365 copilot agents' to inspect available selectors",
+ query,
+ agent.Selector,
+ len(shared),
+ joinAgentNames(shared, 6),
+ )
+}
+
+func ambiguousAgentError(query string, matches []agentInfo) error {
+ return fmt.Errorf(
+ "copilot agent %q is ambiguous; matches: %s. Use an exact name or selector, or run 'a365 copilot agents' to list available selectors",
+ query,
+ joinAgentChoices(matches, 6),
+ )
+}
+
+func unknownAgentError(query string, agents []agentInfo) error {
+ suggestions := suggestAgents(query, agents, 5)
+ if len(suggestions) == 0 {
+ return fmt.Errorf("unknown Copilot agent %q. Run 'a365 copilot agents' to list available selectors", query)
+ }
+ return fmt.Errorf(
+ "unknown Copilot agent %q. Suggestions: %s. Run 'a365 copilot agents' to list available selectors",
+ query,
+ strings.Join(suggestions, ", "),
+ )
+}
+
+func sharedSelectorAgents(catalog []agentInfo, selector string) []agentInfo {
+ var shared []agentInfo
+ for _, agent := range catalog {
+ if agent.Selector == selector {
+ shared = append(shared, agent)
+ }
+ }
+ return shared
+}
+
+func joinAgentChoices(agents []agentInfo, limit int) string {
+ choices := make([]string, 0, len(agents))
+ seen := map[string]struct{}{}
+ for _, agent := range agents {
+ choice := formatAgentChoice(agent)
+ if choice == "" {
+ continue
+ }
+ if _, ok := seen[choice]; ok {
+ continue
+ }
+ seen[choice] = struct{}{}
+ choices = append(choices, choice)
+ }
+ sort.Slice(choices, func(i, j int) bool {
+ return strings.ToLower(choices[i]) < strings.ToLower(choices[j])
+ })
+ return joinLimited(choices, limit)
+}
+
+func joinAgentNames(agents []agentInfo, limit int) string {
+ names := make([]string, 0, len(agents))
+ seen := map[string]struct{}{}
+ for _, agent := range agents {
+ name := agent.Name
+ if name == "" {
+ name = agent.TitleName
+ }
+ if name == "" {
+ name = agent.TitleID
+ }
+ if name == "" {
+ name = agent.Selector
+ }
+ if name == "" {
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ names = append(names, name)
+ }
+ sort.Slice(names, func(i, j int) bool {
+ return strings.ToLower(names[i]) < strings.ToLower(names[j])
+ })
+ return joinLimited(names, limit)
+}
+
+func joinLimited(values []string, limit int) string {
+ if len(values) == 0 {
+ return ""
+ }
+ if limit <= 0 || len(values) <= limit {
+ return strings.Join(values, ", ")
+ }
+ return strings.Join(values[:limit], ", ") + fmt.Sprintf(", +%d more", len(values)-limit)
+}
+
+func formatAgentChoice(agent agentInfo) string {
+ name := agent.Name
+ if name == "" {
+ name = agent.TitleName
+ }
+ if name == "" {
+ name = agent.TitleID
+ }
+ if name == "" {
+ name = agent.Selector
+ }
+ if name == "" {
+ return ""
+ }
+ if agent.Selector == "" || agent.Selector == name {
+ return name
+ }
+ if agent.SharedSelector {
+ return fmt.Sprintf("%s [%s, shared]", name, agent.Selector)
+ }
+ return fmt.Sprintf("%s [%s]", name, agent.Selector)
+}
+
+func suggestAgents(query string, agents []agentInfo, limit int) []string {
+ query = strings.ToLower(strings.TrimSpace(query))
+ if query == "" {
+ return nil
+ }
+
+ type suggestion struct {
+ label string
+ score int
+ }
+
+ best := map[string]int{}
+ for _, agent := range agents {
+ label := formatAgentChoice(agent)
+ if label == "" {
+ continue
+ }
+ score := suggestionScore(query, agent)
+ if score == 0 {
+ continue
+ }
+ if prev, ok := best[label]; !ok || score > prev {
+ best[label] = score
+ }
+ }
+
+ suggestions := make([]suggestion, 0, len(best))
+ for label, score := range best {
+ suggestions = append(suggestions, suggestion{label: label, score: score})
+ }
+ if len(suggestions) == 0 {
+ return nil
+ }
+
+ sort.Slice(suggestions, func(i, j int) bool {
+ if suggestions[i].score != suggestions[j].score {
+ return suggestions[i].score > suggestions[j].score
+ }
+ return strings.ToLower(suggestions[i].label) < strings.ToLower(suggestions[j].label)
+ })
+
+ if limit > 0 && len(suggestions) > limit {
+ suggestions = suggestions[:limit]
+ }
+
+ labels := make([]string, 0, len(suggestions))
+ for _, suggestion := range suggestions {
+ labels = append(labels, suggestion.label)
+ }
+ return labels
+}
+
+func suggestionScore(query string, agent agentInfo) int {
+ best := 0
+ for _, candidate := range []string{agent.Name, agent.Selector, agent.TitleID} {
+ candidate = strings.ToLower(candidate)
+ if candidate == "" {
+ continue
+ }
+ switch {
+ case candidate == query:
+ if best < 100 {
+ best = 100
+ }
+ case strings.HasPrefix(candidate, query):
+ if best < 80 {
+ best = 80
+ }
+ case strings.Contains(candidate, query):
+ if best < 60 {
+ best = 60
+ }
+ }
+ }
+ if best == 0 {
+ return 0
+ }
+ if agent.SharedSelector {
+ best -= 1
+ } else {
+ best += 1
+ }
+ return best
+}
diff --git a/internal/commands/copilot/agents_test.go b/internal/commands/copilot/agents_test.go
new file mode 100644
index 0000000..9763f6f
--- /dev/null
+++ b/internal/commands/copilot/agents_test.go
@@ -0,0 +1,387 @@
+package copilot
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sozercan/a365cli/internal/commands"
+ "github.com/sozercan/a365cli/internal/output"
+ "github.com/sozercan/a365cli/internal/testutil"
+)
+
+func TestNormalizeAvailableAgents_DedupesAndAnnotatesSelectors(t *testing.T) {
+ agents := normalizeAvailableAgents(map[string]any{
+ "availableAgents": []any{
+ map[string]any{"titleId": "title-alpha", "name": "Alpha Agent", "selector": "shared", "description": "alpha"},
+ map[string]any{"titleId": "title-alpha", "name": "Alpha Agent Duplicate", "selector": "shared", "description": "duplicate"},
+ map[string]any{"titleId": "title-beta", "titleName": "Beta Agent", "agentId": "shared"},
+ map[string]any{"titleId": "title-gamma", "name": "Gamma Agent", "selector": "gamma"},
+ map[string]any{"titleId": "title-missing", "name": "Missing Selector"},
+ map[string]any{"name": "No Title", "selector": "delta"},
+ },
+ })
+
+ if len(agents) != 5 {
+ t.Fatalf("expected 5 normalized agents after dedupe, got %d", len(agents))
+ }
+
+ wants := []struct {
+ name string
+ selector string
+ titleID string
+ shared bool
+ shareSize int
+ }{
+ {name: "Alpha Agent", selector: "shared", titleID: "title-alpha", shared: true, shareSize: 2},
+ {name: "Beta Agent", selector: "shared", titleID: "title-beta", shared: true, shareSize: 2},
+ {name: "Gamma Agent", selector: "gamma", titleID: "title-gamma", shared: false, shareSize: 1},
+ {name: "Missing Selector", selector: "", titleID: "title-missing", shared: false, shareSize: 0},
+ {name: "No Title", selector: "delta", titleID: "", shared: false, shareSize: 1},
+ }
+
+ for i, want := range wants {
+ got := agents[i]
+ if got.Name != want.name {
+ t.Fatalf("agent[%d] name = %q, want %q", i, got.Name, want.name)
+ }
+ if got.Selector != want.selector {
+ t.Fatalf("agent[%d] selector = %q, want %q", i, got.Selector, want.selector)
+ }
+ if got.TitleID != want.titleID {
+ t.Fatalf("agent[%d] titleID = %q, want %q", i, got.TitleID, want.titleID)
+ }
+ if got.SharedSelector != want.shared {
+ t.Fatalf("agent[%d] sharedSelector = %v, want %v", i, got.SharedSelector, want.shared)
+ }
+ if got.SharedSelectorCount != want.shareSize {
+ t.Fatalf("agent[%d] sharedSelectorCount = %d, want %d", i, got.SharedSelectorCount, want.shareSize)
+ }
+ }
+
+ if agents[0].Description != "alpha" {
+ t.Fatalf("expected duplicate titleId to keep the first row, got description %q", agents[0].Description)
+ }
+ if agents[1].TitleName != "Beta Agent" {
+ t.Fatalf("expected titleName fallback to be preserved, got %q", agents[1].TitleName)
+ }
+}
+
+func TestResolveAgent_SuccessCases(t *testing.T) {
+ agents := []agentInfo{
+ {Name: "Budget Bot", Selector: "budget", TitleID: "title-budget"},
+ {Name: "Case Match", Selector: "case-sel", TitleID: "title-case"},
+ {Name: "Project Pilot", Selector: "project-pilot", TitleID: "title-project"},
+ {Name: "Title Prefix", Selector: "selector-123", TitleID: "tp-001"},
+ {Name: "Shared One", Selector: "shared", TitleID: "title-shared-1", SharedSelector: true, SharedSelectorCount: 2},
+ {Name: "Shared Two", Selector: "shared", TitleID: "title-shared-2", SharedSelector: true, SharedSelectorCount: 2},
+ }
+
+ tests := []struct {
+ name string
+ query string
+ wantSelector string
+ wantTitleID string
+ }{
+ {name: "exact name", query: "Budget Bot", wantSelector: "budget", wantTitleID: "title-budget"},
+ {name: "exact case-insensitive name", query: "case match", wantSelector: "case-sel", wantTitleID: "title-case"},
+ {name: "exact selector", query: "budget", wantSelector: "budget", wantTitleID: "title-budget"},
+ {name: "exact title id", query: "tp-001", wantSelector: "selector-123", wantTitleID: "tp-001"},
+ {name: "selector prefix", query: "proj", wantSelector: "project-pilot", wantTitleID: "title-project"},
+ {name: "title id prefix", query: "tp-", wantSelector: "selector-123", wantTitleID: "tp-001"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := resolveAgent(agents, tc.query)
+ if err != nil {
+ t.Fatalf("resolveAgent(%q) error: %v", tc.query, err)
+ }
+ if got.Selector != tc.wantSelector {
+ t.Fatalf("resolveAgent(%q) selector = %q, want %q", tc.query, got.Selector, tc.wantSelector)
+ }
+ if got.TitleID != tc.wantTitleID {
+ t.Fatalf("resolveAgent(%q) titleID = %q, want %q", tc.query, got.TitleID, tc.wantTitleID)
+ }
+ })
+ }
+}
+
+func TestResolveAgent_ErrorCases(t *testing.T) {
+ agents := []agentInfo{
+ {Name: "Budget Bot", Selector: "budget", TitleID: "title-budget"},
+ {Name: "Missing Selector", TitleID: "title-missing"},
+ {Name: "Shared One", Selector: "shared", TitleID: "title-shared-1", SharedSelector: true, SharedSelectorCount: 2},
+ {Name: "Shared Two", Selector: "shared", TitleID: "title-shared-2", SharedSelector: true, SharedSelectorCount: 2},
+ {Name: "Duplicate Name", Selector: "dup-a", TitleID: "title-dup-a"},
+ {Name: "Duplicate Name", Selector: "dup-b", TitleID: "title-dup-b"},
+ }
+
+ tests := []struct {
+ name string
+ query string
+ contains []string
+ }{
+ {
+ name: "ambiguous exact name",
+ query: "Duplicate Name",
+ contains: []string{"ambiguous", "Duplicate Name [dup-a]", "Duplicate Name [dup-b]"},
+ },
+ {
+ name: "shared selector rejected",
+ query: "shared",
+ contains: []string{"shared selector \"shared\"", "Shared One, Shared Two"},
+ },
+ {
+ name: "missing selector rejected",
+ query: "Missing Selector",
+ contains: []string{"does not expose a usable chat selector"},
+ },
+ {
+ name: "unknown agent suggests matches",
+ query: "udget",
+ contains: []string{"unknown Copilot agent \"udget\"", "Suggestions: Budget Bot [budget]"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := resolveAgent(agents, tc.query)
+ if err == nil {
+ t.Fatalf("resolveAgent(%q) expected error", tc.query)
+ }
+ for _, want := range tc.contains {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("resolveAgent(%q) error = %q, want substring %q", tc.query, err.Error(), want)
+ }
+ }
+ })
+ }
+}
+
+func TestCopilotAgentsCmd_Run_JSON(t *testing.T) {
+ ctx, buf := testutil.SetupTestServer(t, map[string]string{
+ copilotAvailableAgentsTool: testutil.MustJSON(map[string]any{
+ "availableAgents": []map[string]any{
+ {"titleId": "title-alpha", "name": "Alpha Agent", "selector": "shared"},
+ {"titleId": "title-beta", "titleName": "Beta Agent", "agentId": "shared"},
+ {"titleId": "title-gamma", "name": "Gamma Agent", "selector": "gamma", "developerName": "Contoso", "type": "bot"},
+ {"titleId": "title-missing", "name": "Missing Selector"},
+ },
+ }),
+ })
+
+ cmd := &CopilotAgentsCmd{}
+ if err := cmd.Run(ctx); err != nil {
+ t.Fatalf("Run() error: %v", err)
+ }
+
+ rows := mustAgentRows(t, buf)
+ if len(rows) != 4 {
+ t.Fatalf("expected 4 agents in output, got %d", len(rows))
+ }
+
+ shared := mustFindAgentRow(t, rows, "title-alpha")
+ if shared["status"] != "shared" {
+ t.Fatalf("expected shared agent status=shared, got %v", shared["status"])
+ }
+ if shared["targetable"] != false {
+ t.Fatalf("expected shared agent targetable=false, got %v", shared["targetable"])
+ }
+ if shared["sharedSelector"] != true {
+ t.Fatalf("expected sharedSelector=true, got %v", shared["sharedSelector"])
+ }
+ if shared["sharedSelectorCount"] != float64(2) {
+ t.Fatalf("expected sharedSelectorCount=2, got %v", shared["sharedSelectorCount"])
+ }
+ if shared["agentId"] != "shared" {
+ t.Fatalf("expected agentId to mirror selector, got %v", shared["agentId"])
+ }
+
+ unique := mustFindAgentRow(t, rows, "title-gamma")
+ if unique["status"] != "ok" {
+ t.Fatalf("expected unique agent status=ok, got %v", unique["status"])
+ }
+ if unique["targetable"] != true {
+ t.Fatalf("expected unique agent targetable=true, got %v", unique["targetable"])
+ }
+ if unique["sharedSelector"] != false {
+ t.Fatalf("expected unique agent sharedSelector=false, got %v", unique["sharedSelector"])
+ }
+ if unique["sharedSelectorCount"] != float64(1) {
+ t.Fatalf("expected unique agent sharedSelectorCount=1, got %v", unique["sharedSelectorCount"])
+ }
+ if unique["developerName"] != "Contoso" {
+ t.Fatalf("expected developerName to round-trip, got %v", unique["developerName"])
+ }
+ if unique["type"] != "bot" {
+ t.Fatalf("expected type to round-trip, got %v", unique["type"])
+ }
+
+ missing := mustFindAgentRow(t, rows, "title-missing")
+ if missing["status"] != "missing" {
+ t.Fatalf("expected missing-selector agent status=missing, got %v", missing["status"])
+ }
+ if missing["targetable"] != false {
+ t.Fatalf("expected missing-selector agent targetable=false, got %v", missing["targetable"])
+ }
+ if missing["selector"] != "" {
+ t.Fatalf("expected missing-selector agent selector to be empty, got %v", missing["selector"])
+ }
+}
+
+func TestCopilotChatCmd_Run_ResolvesAgentAndPassesAgentID(t *testing.T) {
+ var agentCalls int
+ var chatCalls int
+ var chatArgs map[string]any
+
+ server := 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":
+ var params struct {
+ Name string `json:"name"`
+ Arguments map[string]any `json:"arguments"`
+ }
+ json.Unmarshal(req.Params, ¶ms) //nolint:errcheck
+
+ var respText string
+ switch params.Name {
+ case copilotAvailableAgentsTool:
+ agentCalls++
+ respText = testutil.MustJSON(map[string]any{
+ "availableAgents": []map[string]any{
+ {"titleId": "title-budget", "name": "Budget Bot", "selector": "budget"},
+ },
+ })
+ case copilotChatTool:
+ chatCalls++
+ chatArgs = params.Arguments
+ respText = `{"message":"Quarterly summary","conversationId":"conv-123"}`
+ default:
+ http.Error(w, "unknown tool", http.StatusBadRequest)
+ return
+ }
+
+ io.WriteString(w, "event: message\ndata: "+testutil.MustJSON(map[string]any{
+ "jsonrpc": "2.0",
+ "id": req.ID,
+ "result": map[string]any{
+ "content": []map[string]any{{"type": "text", "text": respText}},
+ },
+ })+"\n\n")
+ default:
+ http.Error(w, "unknown method", http.StatusBadRequest)
+ }
+ }))
+ t.Cleanup(func() { server.Close() })
+ t.Setenv("A365_ENDPOINT", server.URL+"/")
+
+ var buf bytes.Buffer
+ ctx := &commands.Context{
+ Ctx: context.Background(),
+ TokenProvider: func(context.Context) (string, error) {
+ return "test-token", nil
+ },
+ Output: &output.Formatter{Format: output.FormatJSON, Writer: &buf},
+ }
+
+ cmd := &CopilotChatCmd{Message: "Summarize my week", Agent: "Budget Bot"}
+ if err := cmd.Run(ctx); err != nil {
+ t.Fatalf("Run() error: %v", err)
+ }
+
+ if agentCalls != 1 {
+ t.Fatalf("expected 1 agent lookup call, got %d", agentCalls)
+ }
+ if chatCalls != 1 {
+ t.Fatalf("expected 1 Copilot chat call, got %d", chatCalls)
+ }
+ if chatArgs["agentId"] != "budget" {
+ t.Fatalf("expected chat call to include agentId=budget, got %v", chatArgs["agentId"])
+ }
+ if enabled, ok := chatArgs["enableWebSearch"].(bool); !ok || !enabled {
+ t.Fatalf("expected chat call to enable web search by default, got %v", chatArgs["enableWebSearch"])
+ }
+ if chatArgs["message"] != "Summarize my week" {
+ t.Fatalf("expected chat call to include message, got %v", chatArgs["message"])
+ }
+
+ var result map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
+ t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
+ }
+ if result["message"] != "Quarterly summary" {
+ t.Fatalf("expected message to round-trip, got %v", result["message"])
+ }
+ if result["conversationId"] != "conv-123" {
+ t.Fatalf("expected conversationId to round-trip, got %v", result["conversationId"])
+ }
+}
+
+func mustAgentRows(t *testing.T, buf *bytes.Buffer) []map[string]any {
+ t.Helper()
+
+ var result map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
+ t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
+ }
+
+ rawRows, ok := result["agents"].([]any)
+ if !ok {
+ t.Fatalf("expected top-level 'agents' array, got %T", result["agents"])
+ }
+
+ rows := make([]map[string]any, 0, len(rawRows))
+ for i, raw := range rawRows {
+ row, ok := raw.(map[string]any)
+ if !ok {
+ t.Fatalf("agents[%d] has type %T, want object", i, raw)
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
+func mustFindAgentRow(t *testing.T, rows []map[string]any, titleID string) map[string]any {
+ t.Helper()
+ for _, row := range rows {
+ if row["titleId"] == titleID {
+ return row
+ }
+ }
+ t.Fatalf("no agent row found for titleId %q", titleID)
+ return nil
+}
diff --git a/internal/commands/copilot/copilot.go b/internal/commands/copilot/copilot.go
index c6772b0..00d93b1 100644
--- a/internal/commands/copilot/copilot.go
+++ b/internal/commands/copilot/copilot.go
@@ -23,7 +23,8 @@ const (
// CopilotCmd groups all Copilot subcommands.
type CopilotCmd struct {
- Chat CopilotChatCmd `cmd:"" help:"Ask Copilot about your M365 content"`
+ Chat CopilotChatCmd `cmd:"" help:"Ask Copilot about your M365 content"`
+ Agents CopilotAgentsCmd `cmd:"" help:"List available Copilot agents and selectors"`
}
func copilotEndpoint() string {
@@ -43,22 +44,28 @@ func (e *copilotServiceError) Error() string {
type CopilotChatCmd struct {
Message string `arg:"" help:"Natural language question about your M365 content" optional:""`
ConversationID string `help:"Conversation ID for follow-up queries" name:"conversation-id" optional:""`
+ Agent string `help:"Copilot agent name or ID (resolved before chat)" name:"agent" optional:""`
+ NoWebSearch bool `help:"Disable web search for Copilot grounding" name:"no-web-search"`
}
func (c *CopilotChatCmd) Run(ctx *commands.Context) error {
- return runChat(ctx, c.Message, c.ConversationID)
+ agentSelector, err := resolveAgentForChat(ctx, c.Agent)
+ if err != nil {
+ return err
+ }
+ return runChat(ctx, c.Message, c.ConversationID, agentSelector, !c.NoWebSearch)
}
-func runChat(ctx *commands.Context, message, conversationID string) error {
+func runChat(ctx *commands.Context, message, conversationID, agentSelector string, enableWebSearch bool) error {
question := strings.TrimSpace(message)
if question == "" {
if !ctx.CanPrompt() {
return fmt.Errorf("question required in non-interactive mode")
}
- return runInteractiveLoop(ctx, os.Stdin, os.Stderr, conversationID)
+ return runInteractiveLoop(ctx, os.Stdin, os.Stderr, conversationID, agentSelector, enableWebSearch)
}
- data, _, err := callCopilot(ctx, question, conversationID)
+ data, _, err := callCopilot(ctx, question, conversationID, agentSelector, enableWebSearch)
if err != nil {
return err
}
@@ -66,7 +73,7 @@ func runChat(ctx *commands.Context, message, conversationID string) error {
return printCopilotResponse(ctx, data)
}
-func runInteractiveLoop(ctx *commands.Context, input io.Reader, promptWriter io.Writer, conversationID string) error {
+func runInteractiveLoop(ctx *commands.Context, input io.Reader, promptWriter io.Writer, conversationID, agentSelector string, enableWebSearch bool) error {
reader := bufio.NewReader(input)
currentConversationID := conversationID
@@ -93,7 +100,7 @@ func runInteractiveLoop(ctx *commands.Context, input io.Reader, promptWriter io.
return nil
}
- data, nextConversationID, askErr := callCopilot(ctx, question, currentConversationID)
+ data, nextConversationID, askErr := callCopilot(ctx, question, currentConversationID, agentSelector, enableWebSearch)
if askErr != nil {
fmt.Fprintf(promptWriter, "Error: %v\n", askErr)
if eof {
@@ -116,7 +123,7 @@ func runInteractiveLoop(ctx *commands.Context, input io.Reader, promptWriter io.
}
}
-func callCopilot(ctx *commands.Context, message, conversationID string) (map[string]any, string, error) {
+func callCopilot(ctx *commands.Context, message, conversationID, agentSelector string, enableWebSearch bool) (map[string]any, string, error) {
stopSpinner := startCopilotSpinner(ctx)
defer stopSpinner()
@@ -126,11 +133,15 @@ func callCopilot(ctx *commands.Context, message, conversationID string) (map[str
}
args := map[string]any{
- "message": message,
+ "enableWebSearch": enableWebSearch,
+ "message": message,
}
if conversationID != "" {
args["conversationId"] = conversationID
}
+ if agentSelector != "" {
+ args["agentId"] = agentSelector
+ }
for attempt := 0; ; attempt++ {
resp, err := client.CallTool(ctx.Ctx, copilotChatTool, args)
diff --git a/internal/commands/copilot/copilot_test.go b/internal/commands/copilot/copilot_test.go
index ba4a036..9240f42 100644
--- a/internal/commands/copilot/copilot_test.go
+++ b/internal/commands/copilot/copilot_test.go
@@ -15,9 +15,84 @@ import (
"github.com/sozercan/a365cli/internal/testutil"
)
+func setupCopilotChatTestContext(t *testing.T, onChat func(map[string]any)) (*commands.Context, *bytes.Buffer) {
+ t.Helper()
+
+ server := 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":
+ var params struct {
+ Name string `json:"name"`
+ Arguments map[string]any `json:"arguments"`
+ }
+ json.Unmarshal(req.Params, ¶ms) //nolint:errcheck
+
+ if params.Name != copilotChatTool {
+ http.Error(w, "unknown tool", http.StatusBadRequest)
+ return
+ }
+ if onChat != nil {
+ onChat(params.Arguments)
+ }
+
+ io.WriteString(w, "event: message\ndata: "+testutil.MustJSON(map[string]any{
+ "jsonrpc": "2.0",
+ "id": req.ID,
+ "result": map[string]any{
+ "content": []map[string]any{{"type": "text", "text": `{"message":"Quarterly summary","conversationId":"conv-123"}`}},
+ },
+ })+"\n\n")
+ default:
+ http.Error(w, "unknown method", http.StatusBadRequest)
+ }
+ }))
+ t.Cleanup(func() { server.Close() })
+ t.Setenv("A365_ENDPOINT", server.URL+"/")
+
+ var buf bytes.Buffer
+ ctx := &commands.Context{
+ Ctx: context.Background(),
+ TokenProvider: func(context.Context) (string, error) {
+ return "test-token", nil
+ },
+ Output: &output.Formatter{Format: output.FormatJSON, Writer: &buf},
+ }
+
+ return ctx, &buf
+}
+
func TestCopilotChatCmd_Run(t *testing.T) {
- ctx, buf := testutil.SetupTestServer(t, map[string]string{
- copilotChatTool: `{"message":"Quarterly summary","conversationId":"conv-123"}`,
+ var chatArgs map[string]any
+ ctx, buf := setupCopilotChatTestContext(t, func(args map[string]any) {
+ chatArgs = args
})
cmd := &CopilotChatCmd{Message: "Summarize my week"}
@@ -25,6 +100,13 @@ func TestCopilotChatCmd_Run(t *testing.T) {
t.Fatalf("Run() error: %v", err)
}
+ if enabled, ok := chatArgs["enableWebSearch"].(bool); !ok || !enabled {
+ t.Fatalf("expected enableWebSearch=true by default, got %v", chatArgs["enableWebSearch"])
+ }
+ if chatArgs["message"] != "Summarize my week" {
+ t.Fatalf("expected chat call to include message, got %v", chatArgs["message"])
+ }
+
var result map[string]any
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
@@ -37,6 +119,22 @@ func TestCopilotChatCmd_Run(t *testing.T) {
}
}
+func TestCopilotChatCmd_Run_DisablesWebSearchWhenRequested(t *testing.T) {
+ var chatArgs map[string]any
+ ctx, _ := setupCopilotChatTestContext(t, func(args map[string]any) {
+ chatArgs = args
+ })
+
+ cmd := &CopilotChatCmd{Message: "Summarize my week", NoWebSearch: true}
+ if err := cmd.Run(ctx); err != nil {
+ t.Fatalf("Run() error: %v", err)
+ }
+
+ if enabled, ok := chatArgs["enableWebSearch"].(bool); !ok || enabled {
+ t.Fatalf("expected enableWebSearch=false when --no-web-search is set, got %v", chatArgs["enableWebSearch"])
+ }
+}
+
func TestCallCopilot_RetriesRetryableServiceError(t *testing.T) {
var toolCalls int
server := newCopilotToolServer(t, [][]map[string]any{
@@ -59,7 +157,7 @@ func TestCallCopilot_RetriesRetryableServiceError(t *testing.T) {
Output: &output.Formatter{Format: output.FormatHuman, Writer: io.Discard},
}
- data, conversationID, err := callCopilot(ctx, "Summarize my week", "")
+ data, conversationID, err := callCopilot(ctx, "Summarize my week", "", "", true)
if err != nil {
t.Fatalf("callCopilot() error: %v", err)
}
@@ -97,7 +195,7 @@ func TestCallCopilot_ReturnsRetryableServiceErrorAfterExhaustion(t *testing.T) {
Output: &output.Formatter{Format: output.FormatHuman, Writer: io.Discard},
}
- _, _, err := callCopilot(ctx, "Summarize my week", "")
+ _, _, err := callCopilot(ctx, "Summarize my week", "", "", true)
if err == nil {
t.Fatal("expected retried timeout payload to surface as an error")
}
@@ -137,7 +235,7 @@ func TestCallCopilot_ReturnsNonRetryableServiceErrorWithoutRetry(t *testing.T) {
Output: &output.Formatter{Format: output.FormatHuman, Writer: io.Discard},
}
- _, _, err := callCopilot(ctx, "Summarize my week", "")
+ _, _, err := callCopilot(ctx, "Summarize my week", "", "", true)
if err == nil {
t.Fatal("expected non-timeout tool failure to surface as an error")
}
@@ -410,7 +508,7 @@ func TestRunInteractiveLoop_ReusesConversationID(t *testing.T) {
Output: &output.Formatter{Format: output.FormatHuman, Writer: &out},
}
- err := runInteractiveLoop(ctx, strings.NewReader("first\nsecond\nquit\n"), &prompt, "")
+ err := runInteractiveLoop(ctx, strings.NewReader("first\nsecond\nquit\n"), &prompt, "", "", true)
if err != nil {
t.Fatalf("runInteractiveLoop() error: %v", err)
}
@@ -418,6 +516,12 @@ func TestRunInteractiveLoop_ReusesConversationID(t *testing.T) {
if len(calls) != 2 {
t.Fatalf("expected 2 Copilot calls, got %d", len(calls))
}
+ if enabled, ok := calls[0]["enableWebSearch"].(bool); !ok || !enabled {
+ t.Fatalf("expected first call to enable web search, got %v", calls[0]["enableWebSearch"])
+ }
+ if enabled, ok := calls[1]["enableWebSearch"].(bool); !ok || !enabled {
+ t.Fatalf("expected second call to enable web search, got %v", calls[1]["enableWebSearch"])
+ }
if _, ok := calls[0]["conversationId"]; ok {
t.Fatalf("expected first call to start without a conversation ID, got %v", calls[0]["conversationId"])
}
@@ -484,7 +588,7 @@ func TestRunInteractiveLoop_ReturnsErrorOnEOFFailure(t *testing.T) {
Output: &output.Formatter{Format: output.FormatHuman, Writer: &out},
}
- err := runInteractiveLoop(ctx, strings.NewReader("first"), &prompt, "")
+ err := runInteractiveLoop(ctx, strings.NewReader("first"), &prompt, "", "", true)
if err == nil {
t.Fatal("expected EOF request failure to return an error")
}
diff --git a/internal/commands/dasearch/dasearch.go b/internal/commands/dasearch/dasearch.go
index 978500a..6ae6dbb 100644
--- a/internal/commands/dasearch/dasearch.go
+++ b/internal/commands/dasearch/dasearch.go
@@ -10,7 +10,7 @@ import (
// DASearchCmd groups Declarative Agent Search subcommands.
type DASearchCmd struct {
- Agents DASearchAgentsCmd `cmd:"" help:"List available M365 Copilot agents"`
+ Agents DASearchAgentsCmd `cmd:"" help:"List available M365 Copilot agents (raw DASearch output)"`
}
func dasearchEndpoint() string {
diff --git a/internal/output/columns.go b/internal/output/columns.go
index 6d8486b..ea5430f 100644
--- a/internal/output/columns.go
+++ b/internal/output/columns.go
@@ -10,8 +10,8 @@ import (
// Column defines a column for table/TSV output.
type Column struct {
- Header string // Display header, e.g. "DISPLAY NAME"
- Width int // Max chars for table display (0 = unlimited)
+ Header string // Display header, e.g. "DISPLAY NAME"
+ Width int // Max chars for table display (0 = unlimited)
Extract func(row map[string]any) string // Pull value from row
}
@@ -76,13 +76,13 @@ func truncate(s string, max int) string {
// stripHTML removes HTML tags and unescapes HTML entities,
// with special handling for Teams-specific markup.
var (
- htmlTagRe = regexp.MustCompile(`<[^>]*>`)
- emojiRe = regexp.MustCompile(`]*\balt="([^"]*)"[^>]*>.*?`)
- attachmentRe = regexp.MustCompile(`]*>.*?`)
- systemEventRe = regexp.MustCompile(`]*/>`)
- imgAltRe = regexp.MustCompile(`
]*\balt="([^"]*)"[^>]*>`)
- codeBlockRe = regexp.MustCompile(`]*>|`)
- anchorRe = regexp.MustCompile(`]*>(.*?)`)
+ htmlTagRe = regexp.MustCompile(`<[^>]*>`)
+ emojiRe = regexp.MustCompile(`]*\balt="([^"]*)"[^>]*>.*?`)
+ attachmentRe = regexp.MustCompile(`]*>.*?`)
+ systemEventRe = regexp.MustCompile(`]*/>`)
+ imgAltRe = regexp.MustCompile(`
]*\balt="([^"]*)"[^>]*>`)
+ codeBlockRe = regexp.MustCompile(`]*>|`)
+ anchorRe = regexp.MustCompile(`]*>(.*?)`)
)
func stripHTML(s string) string {
@@ -447,6 +447,36 @@ var UserColumns = []Column{
}},
}
+// CopilotAgentColumns defines display columns for Copilot agent lists.
+var CopilotAgentColumns = []Column{
+ {Header: "NAME", Width: 32, Extract: func(row map[string]any) string {
+ return truncate(getString(row, "name"), 32)
+ }},
+ {Header: "SELECTOR", Width: 20, Extract: func(row map[string]any) string {
+ selector := getString(row, "selector")
+ if selector != "" {
+ return selector
+ }
+ return getString(row, "agentId")
+ }},
+ {Header: "STATUS", Width: 10, Extract: func(row map[string]any) string {
+ status := getString(row, "status")
+ if status != "" {
+ return status
+ }
+ if shared, ok := row["sharedSelector"].(bool); ok && shared {
+ return "shared"
+ }
+ if targetable, ok := row["targetable"].(bool); ok && targetable {
+ return "ok"
+ }
+ return ""
+ }},
+ {Header: "TITLE ID", Width: 0, Extract: func(row map[string]any) string {
+ return getString(row, "titleId")
+ }},
+}
+
// --- API explorer columns ---
// APIServerColumns defines columns for the server list.
diff --git a/internal/output/render.go b/internal/output/render.go
index 699604e..3f5013c 100644
--- a/internal/output/render.go
+++ b/internal/output/render.go
@@ -9,6 +9,10 @@ import (
)
// RenderTable writes rows as an aligned table with headers using tabwriter.
+//
+// For columns with Width > 0, the configured width acts as the minimum display
+// width for alignment in addition to being the truncation width. Columns with
+// Width == 0 are auto-sized from their observed content.
func RenderTable(w io.Writer, columns []Column, rows []map[string]any) {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
defer tw.Flush()
@@ -16,7 +20,7 @@ func RenderTable(w io.Writer, columns []Column, rows []map[string]any) {
// Header row
headers := make([]string, len(columns))
for i, col := range columns {
- headers[i] = col.Header
+ headers[i] = padTableCell(col.Header, col, i < len(columns)-1)
}
fmt.Fprintln(tw, strings.Join(headers, "\t"))
@@ -28,12 +32,27 @@ func RenderTable(w io.Writer, columns []Column, rows []map[string]any) {
if col.Width > 0 {
v = truncate(v, col.Width)
}
- vals[i] = v
+ vals[i] = padTableCell(v, col, i < len(columns)-1)
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
}
+func padTableCell(value string, col Column, pad bool) string {
+ if !pad || col.Width <= 0 {
+ return value
+ }
+
+ width := col.Width
+ if len(col.Header) > width {
+ width = len(col.Header)
+ }
+ if len(value) >= width {
+ return value
+ }
+ return value + strings.Repeat(" ", width-len(value))
+}
+
// RenderTSV writes rows as tab-separated values with a header row. No alignment.
func RenderTSV(w io.Writer, columns []Column, rows []map[string]any) {
// Header row
diff --git a/internal/output/render_test.go b/internal/output/render_test.go
index 5ad56eb..78de965 100644
--- a/internal/output/render_test.go
+++ b/internal/output/render_test.go
@@ -33,6 +33,43 @@ func TestRenderTable(t *testing.T) {
}
}
+func TestRenderTable_UsesConfiguredColumnWidthForAlignment(t *testing.T) {
+ columns := []Column{
+ {Header: "NAME", Width: 10, Extract: func(r map[string]any) string { return getString(r, "name") }},
+ {Header: "ID", Width: 0, Extract: func(r map[string]any) string { return getString(r, "id") }},
+ }
+ rows := []map[string]any{
+ {"name": "Alice", "id": "1"},
+ {"name": "Bob", "id": "2"},
+ }
+
+ var buf bytes.Buffer
+ RenderTable(&buf, columns, rows)
+ lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("expected 3 lines, got %d", len(lines))
+ }
+
+ headerID := strings.Index(lines[0], "ID")
+ row1ID := strings.Index(lines[1], "1")
+ row2ID := strings.Index(lines[2], "2")
+
+ if headerID != 12 {
+ t.Fatalf("expected ID header to start at column 12, got %d in %q", headerID, lines[0])
+ }
+ if row1ID != headerID || row2ID != headerID {
+ t.Fatalf(
+ "expected ID column to align at index %d, got row1=%d row2=%d\nheader=%q\nrow1=%q\nrow2=%q",
+ headerID,
+ row1ID,
+ row2ID,
+ lines[0],
+ lines[1],
+ lines[2],
+ )
+ }
+}
+
func TestRenderTSV(t *testing.T) {
columns := []Column{
{Header: "NAME", Extract: func(r map[string]any) string { return getString(r, "name") }},