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") }},