diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml index 6150e2af..91392a11 100644 --- a/.github/workflows/build-and-package.yml +++ b/.github/workflows/build-and-package.yml @@ -2,7 +2,7 @@ name: Build and Package (p2 ZIP) on: push: - branches: [ build, dev/anypoint-studio ] + branches: [ build ] workflow_dispatch: env: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3092f71..dcd1f9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased — MuleSoft Anypoint Studio Enhancements + +### Added + +**13 new slash commands** for MuleSoft-specific workflows (available in Copilot Agent Mode): + +| Command | Purpose | +|---|---| +| `/mule-code-review` | Flow naming, error handlers, global configs, APIkit route coverage, DataWeave | +| `/mule-security-review` | Hardcoded secrets, XXE, SQL injection, XPath injection, TLS, policy gaps | +| `/mule-performance-review` | DataWeave streaming, batch sizing, connector pooling, N+1 queries, caching | +| `/deployment-readiness` | CloudHub/RTF/on-prem checklist, health endpoints, log levels, smoke tests | +| `/api-spec-review` | RAML/OpenAPI governance, APIkit binding, error contracts, security schemes | +| `/generate-munit-tests` | Happy/negative/error path, async, batch, scatter-gather, transactional tests | +| `/dataweave-best-practices` | Null safety, streaming, map/filter/reduce patterns, module reuse | +| `/connector-governance` | Version compatibility, deprecated connectors, pooling, retry strategies | +| `/logging-observability` | Correlation IDs, structured logging, log levels, Anypoint Monitoring | +| `/error-handling-contract` | On Error Propagate/Continue rules, typed matchers, error response shape | +| `/api-led-architecture-review` | Experience/Process/System layer audit, call direction enforcement | +| `/batch-job-review` | maxRecordsPerBlock sizing, aggregator, step error handling, On Complete | +| `/async-flow-review` | Scheduler flows, VM/MQ patterns, async scope, graceful shutdown | + +**Smarter `@console` for Mule errors** — when Anypoint Studio console output contains a `MuleRuntimeException`, a structured `[Mule Error Summary]` block (error type, flow name, root cause, component) is prepended before the raw output. This reduces context noise and lets the AI orient to the error immediately. + +**Richer `mule_project_scan` output** — the scan tool now returns 10 additional fields: `hasApikit`, `hasSecureProperties`, `hasBatchJob`, `schedulerFlows`, `hasReconnectForever`, `log4j2RootLevel`, `hasDbPoolConfig`, `hasHttpRequestTimeout`, `flowsWithCorrelationId`, `flowErrorHandlerTypes` (typed/catch-all/none per flow). + +**New automatic diagnostics from scan** — the project scanner now auto-flags: `reconnect-forever` in production connector config, `until-successful` without `maxRetries`, root log level set to DEBUG/TRACE, missing DB connection pool config, and missing HTTP request timeout. + +**`mule_project_scan` detects per-flow error handler quality** — each flow is classified as `typed` (all error handlers have type matchers), `catch-all` (at least one handler has no type), or `none` (no error handler). Directly guides the `/error-handling-contract` review. + +**Correlation ID detection per flow** — scan now reports which flows contain a `set-variable variableName="correlationId"` or reference `X-Correlation-ID` in a DataWeave expression. + +**log4j2.xml root level scanning** — scan reads `src/main/resources/log4j2.xml` and flags DEBUG/TRACE root level before deployment. + +**Maven profile support in `run_mule_maven_tests`** — new optional `mavenProfile` input activates a Maven profile with `-P`, e.g., `dev` or `test`. Tool description now documents MUnit-specific flags (`-Dmunit.test=.xml`) and multi-module project support (`-pl `). + +**`layer` and `targetEnvironment` inputs** — code review, security review, and MUnit suggestion tools now accept an API-led layer (`experience`, `process`, `system`) and deployment target (`cloudhub`, `cloudhub2`, `rtf`, `standalone`) to tailor findings. + +**MuleSoft `copilot-instructions.md` scaffold template** — a ready-to-use template at `com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md` documents Mule runtime version, API-led layer, connector conventions, error handling, logging, and MUnit expectations for workspace-level custom instructions. + +**Deepened agent definitions** — `mulesoft-agent.agent.md` and `mulesoft-engineer.agent.md` expanded from ~65 to ~110 lines with concrete rules for API-led architecture (Experience/Process/System hierarchy), error handling contract, DataWeave standards, logging discipline, connector governance, and security non-negotiables. + +**Enriched tool descriptions** — six tool `description` fields updated with Mulesoft-specific patterns, common findings, and when/how to invoke each tool: `mule_project_scan`, `mule_code_review`, `mule_security_review`, `api_schema_analyze`, `munit_validate_flow_tests`, `munit_full_review`. + +### Changed + +- `CONSOLE_CONTEXT_ENABLED` default changed from `false` to `true` — Anypoint Studio developers rely on the console for runtime error context; enabling by default eliminates a manual preference step. +- `WORKSPACE_CONTEXT_ENABLED` default changed from `false` to `true` — multi-project Mule workspaces frequently reference shared RAML specs and DataWeave modules across projects; workspace context is essential for correct cross-project reasoning. +- MuleSoft MCP preference page **Region** field changed from free-text to a dropdown (`PROD_US`, `PROD_EU`, `PROD_CA`, `PROD_JP`) to prevent typos that cause silent MCP registration failures. A status guidance label is added below the field. +- `summarize_mule_project` tool output now includes `hasApikit`, `hasSecureProperties`, `hasBatchJob`, `hasReconnectForever`, `log4j2RootLevel`, `hasDbPoolConfig`, `hasHttpRequestTimeout`, scheduler-triggered flows, flows with correlationId set, and a diagnostic count. + ## 0.17.1 ### Added - ℹ️ Prepare for the [upcoming usage-based billing](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/). We strongly recommend upgrading to this version as soon as possible. [#203](https://github.com/microsoft/copilot-for-eclipse/issues/203) diff --git a/MULESOFT_COPILOT_GUIDE.md b/MULESOFT_COPILOT_GUIDE.md new file mode 100644 index 00000000..1669f736 --- /dev/null +++ b/MULESOFT_COPILOT_GUIDE.md @@ -0,0 +1,632 @@ +# MuleSoft Copilot Guide — GitHub Copilot for Anypoint Studio + +This guide documents every MuleSoft-specific capability in the Copilot for Eclipse plugin, explains why each feature exists, and shows how to use it effectively. + +--- + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Slash Commands Reference](#slash-commands-reference) +3. [Smart Console Error Parsing](#smart-console-error-parsing) +4. [Project Scanning — What the AI Sees](#project-scanning--what-the-ai-sees) +5. [Agent Behavior and Built-in Rules](#agent-behavior-and-built-in-rules) +6. [Workspace Custom Instructions Template](#workspace-custom-instructions-template) +7. [MuleSoft MCP Server Setup](#mulesoft-mcp-server-setup) +8. [Preferences and Defaults](#preferences-and-defaults) +9. [Tool Reference](#tool-reference) +10. [Typical Workflows](#typical-workflows) + +--- + +## Quick Start + +1. Open a Mule 4 project in Anypoint Studio. +2. Open the Copilot chat panel. +3. Type `/` to see all available MuleSoft slash commands. +4. Run your first review: + ``` + /mule-code-review + ``` +5. The agent will automatically scan the project, run a code review, and return prioritized findings with recommended fixes. + +For best results, add a [`copilot-instructions.md`](#workspace-custom-instructions-template) to your project before starting. + +--- + +## Slash Commands Reference + +All commands run in **Agent Mode** and invoke the appropriate local Mule tools automatically. You do not need to specify a project path — the agent uses the open project in the workspace. + +### `/mule-code-review` + +**Purpose**: General code quality review across all Mule XML, DataWeave, properties, and MUnit suites. + +**What it checks**: +- Flow naming conventions (camelCase verb-noun: `getCustomerByIdFlow`) +- Sub-flows vs. private flows — when each is appropriate +- On Error Propagate presence on all HTTP-facing flows +- On Error Continue misuse as a catch-all +- Correlation ID set at HTTP Listener source, propagated in outbound headers +- Global config duplication and hardcoded values +- Property placeholder externalization (`${secure::}` for secrets) +- DataWeave output type declarations and null-safe field access +- APIkit route coverage vs. RAML/OpenAPI spec endpoints + +**Example invocation**: +``` +/mule-code-review +``` + +--- + +### `/mule-security-review` + +**Purpose**: Security vulnerability scan for Mule-specific threats. + +**What it checks**: +- Hardcoded credentials in XML attributes or property files +- Missing `${secure::}` prefix on sensitive properties +- Missing Mule Secure Configuration Properties module dependency +- **SQL injection**: DB connector queries with string concatenation instead of `:variable` syntax +- **XPath injection**: XPath expressions with user-controlled input +- **XXE**: XML parsing without secure parser settings +- Insecure HTTP Listener endpoints (missing TLS) +- Outbound HTTP Request configs with `insecure="true"` or no TLS context +- Missing authentication mechanism on HTTP-facing flows +- Full payload logging (exposes PII or secrets) +- API policy gaps (rate limiting, authentication enforcement) + +**Example invocation**: +``` +/mule-security-review +``` + +--- + +### `/mule-performance-review` + +**Purpose**: Performance and scalability analysis. + +**What it checks**: +- DataWeave transforms that materialize large payloads — recommends `streaming=true` +- Nested `map` over large collections (O(n²)) — recommends `groupBy` + lookup +- Inline regex compiled per iteration — recommends pre-compile to a variable +- Batch job `maxRecordsPerBlock` sizing (default 100 is rarely optimal) +- `maxConcurrency` on CPU-intensive vs. IO-bound flows +- DB connector N+1 query patterns (`` inside ``) +- Missing `minPoolSize`/`maxPoolSize` on DB connector config +- Missing `responseTimeout` on HTTP Request configs +- Missing `reconnect-forever` (infinite retry blocks a thread) +- `` without `maxRetries` +- Caching opportunities for repeated calls to static/slow-changing APIs + +**Example invocation**: +``` +/mule-performance-review +``` + +--- + +### `/deployment-readiness` + +**Purpose**: Pre-deployment checklist tailored to the target platform. + +**What it checks (all platforms)**: +- `mule-artifact.json` present with correct `minMuleVersion` +- Maven plugin version compatible with target runtime +- All MUnit tests passing +- No hardcoded secrets +- Environment-specific properties externalized to `config-.yaml` +- Log level set to INFO or WARN in production log4j2.xml +- Health endpoint present and returning `{"status": "UP"}` + +**Additional checks by platform**: +- **CloudHub 1.0**: Worker sizing, persistent queues, static IP +- **CloudHub 2.0 / Runtime Fabric**: Resource requests/limits, replica count (≥2 for HA), liveness probes +- **On-premises**: Cluster config, JVM heap sizing, process user permissions + +**Example invocation**: +``` +/deployment-readiness +``` +The agent will ask which platform you are targeting if it cannot infer it. + +--- + +### `/api-spec-review` + +**Purpose**: API contract governance and APIkit compatibility. + +**What it checks**: +- Required metadata (title, version, baseUri/servers) +- All request/response bodies use named schemas (no inline anonymous objects) +- Examples present and valid against their schema +- Error responses defined (400, 401, 404, 500 at minimum) +- Security scheme defined AND applied to all non-public endpoints +- URL versioning (e.g., `/v1/`) present and consistent +- APIkit route coverage: every spec endpoint has a router flow; no orphaned router flows +- RAML library fragments pinned to specific Exchange versions + +**Example invocation**: +``` +/api-spec-review +``` + +--- + +### `/generate-munit-tests` + +**Purpose**: Generate comprehensive MUnit test coverage. + +**What it generates**: +- Happy path, invalid input (400), connector failure simulation, error-response contract — per flow +- Async flow testing via `munit:run-flow` direct invocation (not scheduler-dependent) +- Batch job tests: unit-test steps in isolation + integration-test with fixture dataset +- Scatter-gather tests: each route mocked independently; one test with a failing route +- Transactional rollback tests: second connector mocked to fail, first write verified rolled back +- Choice router branch tests: one test per when-condition + otherwise +- Correlation ID assertion in Logger calls + +After generating, the agent runs `munit_validate_flow_tests` and `run_mule_maven_tests` to confirm correctness. + +**Example invocation**: +``` +/generate-munit-tests +``` +Specify a flow name to generate tests for a single flow: +``` +/generate-munit-tests for getCustomerByIdFlow +``` + +--- + +### `/dataweave-best-practices` + +**Purpose**: DataWeave-specific quality review. There is no other platform with DataWeave — this prompt covers Mule-unique patterns. + +**What it checks**: +- Output type declaration on every script +- Null-safe access via `default` operator on all optional fields +- Functional style: `map`, `filter`, `reduce`, `groupBy` over imperative `if/else` +- Nested `map` performance (O(n²)) — recommends `groupBy` indexing +- Inline regex compiled inside `map` — recommends pre-compile to variable +- Unnecessary serialization round-trips (`write` → `read`) +- Streaming appropriateness for large unknown-size payloads +- Repeated DW logic across transforms — recommends extracting to `.dwl` module +- Missing input/output type documentation on complex scripts + +**Example invocation**: +``` +/dataweave-best-practices +``` + +--- + +### `/connector-governance` + +**Purpose**: Connector version, configuration, and authentication audit. + +**What it checks**: +- Connector versions vs. Mule runtime compatibility matrix +- Deprecated connectors: HTTP v1, File Connector v1, Scripting Module (Groovy/JS/Python) +- Redundant connector configs (two `db:config` pointing to the same DB) +- DB connector: `minPoolSize`, `maxPoolSize`, `maxWait` present +- HTTP connector: `responseTimeout`, `connectionIdleTimeout` present +- `reconnect-forever` (blocks threads in production) +- `reconnect` without finite `count` and `frequency` +- `until-successful` without `maxRetries` and `millisBetweenRetries` +- Authentication method consistency (same upstream service should use same auth type) +- API key passed as query parameter instead of header + +**Example invocation**: +``` +/connector-governance +``` + +--- + +### `/logging-observability` + +**Purpose**: Logging quality and Anypoint Monitoring setup. + +**What it checks**: +- Correlation ID set at HTTP Listener from `X-Correlation-ID` header (fallback `uuid()`) +- Correlation ID propagated in all outbound HTTP Request headers +- Correlation ID included in all Logger calls in error handlers +- Log levels: INFO for flow entry/exit, ERROR for error handlers, DEBUG disabled in prod +- Structured JSON format in Logger `message` expressions (not string concatenation) +- Full payload logged at INFO (flag as PII/performance risk) +- Passwords/tokens/API keys logged without masking +- log4j2.xml root level (DEBUG/TRACE in production is flagged automatically by the scan) +- Anypoint Monitoring enabled for the deployed application + +**Example invocation**: +``` +/logging-observability +``` + +--- + +### `/error-handling-contract` + +**Purpose**: Dedicated review of error handling quality. Consolidates rules scattered across code review, logging, and security prompts. + +**What it checks**: +- Every HTTP-facing flow has per-flow `` (not just a global handler) +- `` vs. `` used correctly +- All handlers have typed error matchers (`type="HTTP:CONNECTIVITY"` etc.) +- Correlation ID logged in every error handler +- Error responses return consistent JSON shape: `{ "code", "message", "correlationId" }` +- HTTP status codes correct: 400/401/403/404/500/503 (never always 500) +- Raw Mule stack traces not returned to API consumers + +Uses `flowErrorHandlerTypes` data from the scan (typed/catch-all/none per flow) to focus on the worst offenders first. + +**Example invocation**: +``` +/error-handling-contract +``` + +--- + +### `/api-led-architecture-review` + +**Purpose**: Validates whether the project correctly implements its API-led layer. + +**Layer definitions enforced**: +- **Experience API**: consumer-facing, calls Process APIs only +- **Process API**: orchestrates System APIs, no direct backend connectors +- **System API**: one backend system, no business logic, no calls to other APIs + +**What it checks**: +- Correct call direction (Experience → Process → System, never upward) +- System API with multiple outbound HTTP connectors (likely doing Process API work) +- Process or Experience API with backend connector configs (DB, Salesforce) — System API layer missing +- Flow naming inconsistent with declared layer +- API spec vocabulary reflects the layer (business terms for xAPI/pAPI, backend terms for sAPI) + +**Example invocation**: +``` +/api-led-architecture-review +``` + +--- + +### `/batch-job-review` + +**Purpose**: Dedicated review for Mule batch processing. + +**What it checks**: +- `` structure: `batch:input`, at least one `batch:step`, `batch:on-complete` +- `maxRecordsPerBlock` sizing (default 100 — flags if not explicitly set) +- `` with explicit `size` and `streaming="true"` for large sets +- Step-level error handling: On Error Continue for per-record failures, On Error Propagate to abort +- On Complete phase logging: `loadedRecords`, `successfulRecords`, `failedRecords` +- DataWeave inside steps: nested maps, inline regex, unnecessary serialization +- Recommended test fixture: valid record + failing record + boundary record + +Only runs when `hasBatchJob=true` in the scan output. + +**Example invocation**: +``` +/batch-job-review +``` + +--- + +### `/async-flow-review` + +**Purpose**: Reviews scheduler flows, async scopes, VM queues, and Anypoint MQ listeners. + +**What it checks**: +- Scheduler flows: error handler present, correlation ID generated with `uuid()`, INFO logger at entry +- `` scopes: business-critical logic inside async (data loss risk), no internal error handler +- VM connector: no corresponding listener for published messages, no ack/nack strategy +- Anypoint MQ: `acknowledgementMode` and explicit `ack`/`nack` usage, dead-letter queue configured +- Thread pool impact of heavy DataWeave inside async operations +- Graceful shutdown: `shutdownTimeout` appropriate for message processing time + +Uses `schedulerFlows` data from the scan to target the right flows. + +**Example invocation**: +``` +/async-flow-review +``` + +--- + +## Smart Console Error Parsing + +When you use `@console` in the chat, the plugin automatically enriches the console output if it contains a Mule runtime exception. + +**Before enrichment** (raw dump sent to AI): +``` +2026-05-22 10:14:32,401 ERROR ... [MuleContainerSystemClassLoader] ... +org.mule.runtime.api.exception.DefaultMuleException: HTTP POST on resource 'https://...' failed: Connection refused. +org.mule.extension.http.api.error.HttpRequestFailedException + at org.mule.extension.http.internal... + ... 47 more lines of stack trace +``` + +**After enrichment** (what the AI receives first): +``` +[Mule Error Summary] +Error type: HTTP:CONNECTIVITY +Flow: processOrderFlow +Root cause: Connection refused to https://api.inventory.internal:8081 +Component: HTTP_Request @ processOrderFlow/processors/3 + +[Console Context] +Console: Anypoint Studio Console +Truncated: no +Output: + +... full raw output ... + +``` + +The AI immediately knows what failed, in which flow, and what the root cause is — without spending context tokens parsing 50 lines of Java stack trace. + +**How to use**: Simply prefix your message with `@console`: +``` +@console Why is my flow failing? +``` + +--- + +## Project Scanning — What the AI Sees + +Every slash command starts with `mule_project_scan`. Understanding what the scan returns helps you interpret agent responses. + +### Standard Scan Output + +| Field | What it contains | +|---|---| +| `runtimeVersion` | Mule 4.x version from `mule-artifact.json` or `pom.xml` | +| `flows` | All flow names across all XML files | +| `subFlows` | All sub-flow names | +| `globalConfigs` | Global config element names (connector configs, error handlers) | +| `connectors` | Connector artifact IDs from `pom.xml` | +| `munitFiles` | MUnit suite file paths | +| `apiSpecFiles` | RAML/OpenAPI/WSDL/XSD files found in `src/main/resources` | +| `deploymentPlugins` | CloudHub/RTF Maven plugin detected in `pom.xml` | +| `propertyPlaceholders` | All `${...}` placeholder keys found in XML | + +### New Fields (Round 2) + +| Field | Why it matters | +|---|---| +| `hasApikit` | APIkit router detected — spec/route coverage checks apply | +| `hasSecureProperties` | Secure Configuration Properties module present | +| `hasBatchJob` | `` detected — `/batch-job-review` is relevant | +| `schedulerFlows` | Names of scheduler-triggered flows — need `uuid()` correlation ID, direct MUnit invocation | +| `hasReconnectForever` | `reconnect-forever` detected — production reliability risk | +| `log4j2RootLevel` | Root log level from `log4j2.xml` — DEBUG/TRACE is flagged | +| `hasDbPoolConfig` | DB connector has `minPoolSize`/`maxPoolSize` configured | +| `hasHttpRequestTimeout` | HTTP Request connector has `responseTimeout` configured | +| `flowsWithCorrelationId` | Flows where a `set-variable variableName="correlationId"` is detected | +| `flowErrorHandlerTypes` | Per-flow: `typed`, `catch-all`, or `none` | +| `untilSuccessfulWithoutMaxRetries` | `` without `maxRetries` — runaway retry risk | + +### Automatic Diagnostics + +The scan automatically flags these issues without requiring a separate review command: + +| Severity | Condition | Recommendation | +|---|---|---| +| Medium | `reconnect-forever` detected | Replace with finite `reconnect` | +| Medium | `until-successful` without `maxRetries` | Set `maxRetries` and `millisBetweenRetries` | +| Medium | `log4j2RootLevel` is DEBUG or TRACE | Set root level to INFO before deploying | +| Medium | DB connector present, no pool config | Add `minPoolSize`, `maxPoolSize`, `maxWait` | +| Medium | HTTP connector present, no timeout | Add `responseTimeout` to `http:request-config` | + +--- + +## Agent Behavior and Built-in Rules + +The Mulesoft agent (`mulesoft-agent.agent.md`) enforces these rules automatically without needing to be asked: + +### API-Led Architecture +``` +Experience API → Process API → System API +``` +- Never suggests a System API calling a Process or Experience API +- Flags when a project's connectors don't match its declared layer +- Uses layer-appropriate naming conventions in generated flows + +### Error Handling Contract +- All HTTP-facing generated flows include `` with typed matchers +- Error handlers always log `correlationId`, `flow.name`, `error.errorType`, `error.description` +- Error responses use `{ "code", "message", "correlationId" }` with correct HTTP status codes +- Never returns a raw Mule error description as the API response body + +### DataWeave Standards +- Always reads `mule_read_transform` before modifying a Transform Message +- Generated DataWeave always declares `output` type +- Optional field accesses use `default` operator +- Large-payload transforms use `streaming=true` + +### Logging Discipline +- Generated Logger components at INFO include `correlationId` and `flowName` +- No generated code logs `payload` at INFO level +- Structured JSON format in all generated Logger `message` expressions +- DEBUG logging disabled in production + +### Connector Governance +- Suggests connector versions compatible with the project's `minMuleVersion` +- Never suggests `reconnect-forever` +- Generated DB configs include pool settings; generated HTTP configs include `responseTimeout` + +--- + +## Workspace Custom Instructions Template + +For the best Copilot experience in a Mule project, add a `copilot-instructions.md` to the project. This file is read automatically on every chat turn. + +**Setup**: +1. Copy the template from the plugin: + ``` + com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md + ``` +2. Place it at `.github/copilot-instructions.md` in your Mule project root. +3. Fill in the placeholders (runtime version, layer, deployment target, connectors). + +**What the template includes**: +- Mule runtime version and API-led layer declaration +- Flow and sub-flow naming conventions +- Secure property usage rules +- Error handling expectations +- Logging format and level expectations +- MUnit coverage requirements +- Connector versions in use + +Copilot reads this file automatically and tailors all suggestions to match your project's conventions. + +--- + +## MuleSoft MCP Server Setup + +The MuleSoft MCP server enables Copilot to interact directly with the Anypoint Platform — generating flows, deploying applications, searching Exchange, and running DataWeave scripts. + +### Prerequisites + +- [Node.js](https://nodejs.org/) installed and on `PATH` for the Studio process +- A [MuleSoft Connected App](https://docs.mulesoft.com/access-management/connected-apps-overview) in Anypoint Platform with appropriate scopes + +### Configuration Steps + +1. **Open preferences**: **Window → Preferences → Copilot → MuleSoft MCP** +2. **Enable**: Check **Enable MuleSoft MCP Server registration** +3. **Enter credentials**: + - **Client ID**: Your Connected App client ID + - **Client Secret**: Stored in Eclipse secure storage + - **Region**: Select from dropdown — `PROD_US`, `PROD_EU`, `PROD_CA`, or `PROD_JP` +4. **Save**: Click **Apply and Close** +5. **Approve**: Open **Preferences → Copilot → MCP Servers** and approve the `mulesoft` server entry + +> **Environment variable fallback**: Leave fields blank to use `ANYPOINT_CLIENT_ID`, `ANYPOINT_CLIENT_SECRET`, and `ANYPOINT_REGION` from the Studio process environment. + +### Available MCP Tools + +Once registered, the following MuleSoft tools become available in Agent Mode: + +- `mulesoft/create_mule_project` — scaffold a new Mule 4 project +- `mulesoft/generate_mule_flow` — generate flows from a description +- `mulesoft/run_local_mule_application` — run the app locally +- `mulesoft/generate_api_spec` / `implement_api_spec` / `mock_api_spec` +- `mulesoft/dataweave_run_script_tool` — test DataWeave scripts +- `mulesoft/generate_or_modify_munit_test` — create or update MUnit suites +- `mulesoft/deploy_mule_application` / `update_mule_application` +- `mulesoft/search_asset` — search Anypoint Exchange +- `mulesoft/manage_api_instance_policy` — apply policies +- `mulesoft/get_platform_insights` — application monitoring data +- And 15+ more tools for Exchange assets, Flex Gateway, and Runtime Fabric + +--- + +## Preferences and Defaults + +### Changed Defaults + +| Preference | Old default | New default | Why | +|---|---|---|---| +| Console context (`@console`) | `false` | `true` | Mule developers rely on console for runtime error context | +| Workspace context (`@workspace`) | `false` | `true` | Multi-project Mule workspaces reference shared RAML specs and DataWeave modules | + +### Key Preferences + +- **Window → Preferences → Copilot → General**: + - *Workspace context*: Enables `@workspace` for cross-project RAML/DWL references + - *Console context*: Enables `@console` for runtime error analysis (now on by default) + +- **Window → Preferences → Copilot → Chat**: + - *Enable sub-agents*: Allows the agent to spawn sub-agents for parallel tasks + - *Maximum agent requests*: Default 25; increase for complex project-wide reviews + +- **Window → Preferences → Copilot → MuleSoft MCP**: + - Region dropdown (PROD_US/EU/CA/JP) — replaces the free-text field + +--- + +## Tool Reference + +These tools are available in Copilot Agent Mode and are invoked automatically by slash commands. You can also ask the agent to use them explicitly. + +| Tool | What it returns | +|---|---| +| `mule_project_scan` | Full project metadata + all new diagnostic fields (see [Project Scanning](#project-scanning--what-the-ai-sees)) | +| `mule_code_review` | Code quality findings by severity with file/line references | +| `mule_security_review` | Security findings classified critical/high/medium/low | +| `mule_read_transform` | DataWeave scripts from a specific Transform Message component | +| `mule_write_transform` | Updates DataWeave in a Transform Message (requires confirmation) | +| `api_schema_analyze` | Governance diagnostics for RAML, OpenAPI, WSDL, XSD, AsyncAPI, GraphQL | +| `munit_validate_flow_tests` | MUnit structure validation: namespaces, config, assertions, mock coverage | +| `munit_full_review` | Full suite audit: scenario coverage, assertion quality, test duplication | +| `munit_improvement_suggestions` | Cadence recommendations for happy/negative/edge/failure scenarios | +| `summarize_mule_project` | Human-readable text summary including all new boolean flags and diagnostic count | +| `get_mule_project_errors` | Live project diagnostics from Studio problem markers | +| `run_mule_maven_tests` | Runs Maven tests; supports `mavenProfile` (`-P dev`), MUnit filtering (`-Dmunit.test=`), multi-module (`-pl`) | + +--- + +## Typical Workflows + +### New Feature: End-to-End Workflow + +``` +1. /api-spec-review — validate the RAML/OpenAPI contract first +2. /mule-code-review — check flow structure, error handlers, naming +3. /mule-security-review — scan for injection risks and credential exposure +4. /generate-munit-tests — generate tests for the new flow +5. /deployment-readiness — confirm all checklist items before push +``` + +### Investigating a Production Error + +``` +1. Paste the console output using @console + → Copilot extracts [Mule Error Summary] automatically +2. Ask: "Why is this error happening and how do I fix it?" +3. Follow up with /error-handling-contract to improve error handling +``` + +### Pre-Merge Code Review + +``` +1. /mule-code-review — code quality and architecture +2. /mule-security-review — security vulnerabilities +3. /munit_full_review — test quality and coverage gaps +``` + +### DataWeave Optimization + +``` +1. Ask: "Review the Transform Message in getOrdersFlow for performance" + → Agent uses mule_read_transform automatically +2. /dataweave-best-practices — comprehensive DW quality review +``` + +### Batch Job Implementation + +``` +1. /batch-job-review — review existing structure or get guidance for new batch job +2. /generate-munit-tests — generate batch step unit tests and integration test fixture +3. /mule-performance-review — verify block sizing and aggregator config +``` + +--- + +## File Locations Reference + +| File | Purpose | +|---|---| +| `com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md` | Main MuleSoft agent definition — API-led, error handling, DataWeave, logging, connector governance rules | +| `com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md` | Engineer agent variant — same rules, slightly different framing | +| `com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md` | Project-level custom instructions scaffold — copy to `.github/copilot-instructions.md` | +| `com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/*.prompt.md` | All 13 slash command prompt definitions | +| `com.microsoft.copilot.eclipse.ui/src/.../chat/services/MuleConsoleParser.java` | Mule error parser that enriches `@console` output | +| `com.microsoft.copilot.eclipse.ui/src/.../chat/tools/MuleProjectAnalyzer.java` | Core Mule project scanner and review engine | +| `com.microsoft.copilot.eclipse.ui/src/.../chat/tools/MuleProjectAnalysis.java` | Data model for scan results | +| `com.microsoft.copilot.eclipse.anypoint/src/.../MuleSoftMcpPreferencePage.java` | MCP credentials preference page (region dropdown) | +| `com.microsoft.copilot.eclipse.ui/src/.../preferences/CopilotPreferenceInitializer.java` | Default preferences (console and workspace context now `true`) | diff --git a/README.md b/README.md index 85b58983..b8ecf1ae 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ GitHub Copilot for Eclipse brings AI-assisted coding to the Eclipse IDE with the Starting from version **0.18.0**, we have added internal support for the upcoming [usage-based billing experience](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/), including experience updates to the usage panel, usage notifications, and model picker. These changes will become visible once usage-based billing is rolled out. -To ensure compatibility with the new billing experience, we strongly recommend upgrading to the plugin to **0.18.0 or later** as soon as possible. +To ensure compatibility with the new billing experience, we strongly recommend upgrading the plugin to **0.18.0 or later** as soon as possible. Clients using older plugin versions will continue to function. However, the billing and usage experience may not be optimal and may not accurately reflect the latest usage-based billing experience. @@ -87,7 +87,54 @@ For other available features in Eclipse, see the [Copilot feature matrix](https: ## Anypoint Studio Integration -This plugin includes a dedicated integration for [MuleSoft Anypoint Studio](https://www.mulesoft.com/platform/studio), enabling AI-assisted Mule application development through Copilot's Agent Mode and MCP. +This plugin includes a dedicated integration for [MuleSoft Anypoint Studio](https://www.mulesoft.com/platform/studio), enabling AI-assisted Mule 4 application development through Copilot's Agent Mode and MCP. + +### Slash Commands for MuleSoft Workflows + +Type `/` in the Copilot chat to see all available MuleSoft slash commands. Each command runs a pre-configured agent workflow tailored to a specific Mule development task: + +| Command | What it does | +|---|---| +| `/mule-code-review` | Reviews flow naming, error handlers, global configs, DataWeave, and APIkit route coverage | +| `/mule-security-review` | Detects hardcoded secrets, SQL/XPath injection risks, missing TLS, authentication gaps, and policy coverage | +| `/mule-performance-review` | Identifies DataWeave materialization, batch sizing issues, missing connector pooling, N+1 queries, and caching opportunities | +| `/deployment-readiness` | Platform-specific deployment checklist for CloudHub, Runtime Fabric, or standalone; includes health endpoints and smoke tests | +| `/api-spec-review` | Validates RAML/OpenAPI governance, APIkit router binding, security scheme implementation, and error response contracts | +| `/generate-munit-tests` | Generates MUnit tests covering happy path, error path, connector failure, async flows, batch jobs, and scatter-gather patterns | +| `/dataweave-best-practices` | Reviews Transform Message components for null safety, streaming, functional patterns, and module reuse | +| `/connector-governance` | Audits connector versions against the Mule runtime, flags deprecated connectors, missing pooling, and retry strategy gaps | +| `/logging-observability` | Reviews correlation ID propagation, structured log format, log levels, PII exposure, and Anypoint Monitoring setup | +| `/error-handling-contract` | Audits On Error Propagate/Continue usage, typed matchers, correlation ID in error handlers, and HTTP status codes | +| `/api-led-architecture-review` | Validates API-led layer assignment (Experience/Process/System), call direction rules, and connector placement | +| `/batch-job-review` | Reviews batch job structure, block sizing, aggregator config, step error handling, and On Complete logging | +| `/async-flow-review` | Reviews scheduler flows, VM/MQ listener patterns, async scope usage, and graceful shutdown configuration | + +### Smart Console Error Parsing + +When you use `@console` in the chat with Anypoint Studio console output that contains a Mule runtime exception, the plugin automatically prepends a structured summary before the raw output: + +``` +[Mule Error Summary] +Error type: HTTP:CONNECTIVITY +Flow: get-customer-main +Root cause: Connection refused to https://api.example.com:443 +Component: HTTP_Request @ get-customer-main/processors/2 +``` + +This lets the AI immediately understand the error context without parsing through Java stack trace noise. + +### Project Scanning + +The `mule_project_scan` tool (invoked automatically by most slash commands) returns rich Mule-specific analysis including: + +- Runtime version, flows, sub-flows, global configs, connectors, MUnit suite coverage +- Per-flow error handler classification: `typed` (has type matchers), `catch-all` (no type), or `none` +- Flows where a correlation ID is set at the source +- Scheduler-triggered flows (require different MUnit strategy from HTTP flows) +- `log4j2RootLevel` — flags DEBUG/TRACE in production +- DB connection pool config presence and HTTP request timeout presence +- `reconnect-forever` and `until-successful` without `maxRetries` detection +- `hasBatchJob`, `hasApikit`, `hasSecureProperties` presence flags ### MuleSoft MCP Server @@ -98,7 +145,7 @@ The plugin can automatically register the official [`mulesoft-mcp-server`](https - Running DataWeave scripts and generating sample data - Creating, running, and reviewing MUnit tests - Searching Anypoint Exchange assets -- Running local Mule applications +- Deploying to CloudHub, Runtime Fabric, and Flex Gateway ### Configuring MuleSoft MCP @@ -107,14 +154,30 @@ The plugin can automatically register the official [`mulesoft-mcp-server`](https 3. Enter your **Anypoint Platform Connected App** credentials: - **Client ID** – the connected app client ID. - **Client Secret** – stored securely in Eclipse secure storage. - - **Region** *(optional)* – one of `PROD_US`, `PROD_EU`, `PROD_CA`, or `PROD_JP`. Defaults to `PROD_US` when omitted. -4. Click **Apply and Close**. + - **Region** *(optional)* – select from the dropdown: `PROD_US`, `PROD_EU`, `PROD_CA`, or `PROD_JP`. Defaults to `PROD_US` when left blank. +4. Click **Apply and Close**, then approve the `mulesoft` server entry in **Preferences → Copilot → MCP Servers**. > **Tip:** If any field is left blank, the integration falls back to the `ANYPOINT_CLIENT_ID`, `ANYPOINT_CLIENT_SECRET`, and `ANYPOINT_REGION` environment variables set in the Studio process environment. +### Workspace-Level Custom Instructions + +For best results, add a `copilot-instructions.md` file to your Mule project at `.github/copilot-instructions.md`. This file is read automatically on every chat turn and tells Copilot about your project's runtime version, API-led layer, connector conventions, error handling strategy, and MUnit expectations. + +A ready-to-use template is bundled at: +``` +com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md +``` +Copy it to `.github/copilot-instructions.md` in your Mule project and fill in the placeholders. + ### MuleSoft Agent Template -A pre-built agent template (`mulesoft-agent.agent.md`) is bundled with the plugin. It configures a specialized Copilot agent scoped to MuleSoft development workflows, automatically wiring the relevant MuleSoft MCP tools alongside built-in Copilot capabilities such as project scanning, code review, and security review. +A pre-built agent template (`mulesoft-agent.agent.md`) is bundled with the plugin. It configures a specialized Copilot agent scoped to Mule 4 development, automatically wiring the relevant MuleSoft MCP tools alongside built-in tools. The agent enforces: + +- **API-led architecture** — Experience → Process → System call direction rules +- **Error handling contract** — typed On Error Propagate handlers, correlation ID logging, consistent error response shape +- **DataWeave standards** — output type declaration, null-safe access, streaming for large payloads +- **Logging discipline** — structured JSON format, correlation IDs at INFO, no PII in logs +- **Connector governance** — version compatibility, pooling config, no `reconnect-forever` in production ### Prerequisites for MuleSoft MCP diff --git a/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpPreferencePage.java b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpPreferencePage.java index 5e9a2386..11c1b60a 100644 --- a/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpPreferencePage.java +++ b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpPreferencePage.java @@ -9,6 +9,7 @@ import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Label; @@ -16,16 +17,20 @@ import org.eclipse.ui.IWorkbench; import org.eclipse.ui.IWorkbenchPreferencePage; +import com.microsoft.copilot.eclipse.ui.preferences.PreferencePageUtils; + /** * Preferences for the MuleSoft MCP bridge used by Anypoint Studio. */ public class MuleSoftMcpPreferencePage extends PreferencePage implements IWorkbenchPreferencePage { public static final String ID = "com.microsoft.copilot.eclipse.anypoint.preferences.MuleSoftMcpPreferencePage"; + private static final String[] REGION_OPTIONS = { "", "PROD_US", "PROD_EU", "PROD_CA", "PROD_JP" }; + private Button enabledButton; private Text clientIdText; private Text clientSecretText; - private Text regionText; + private Combo regionCombo; @Override public void init(IWorkbench workbench) { @@ -42,7 +47,6 @@ protected Control createContents(Composite parent) { enabledButton = new Button(container, SWT.CHECK); enabledButton.setText("Enable MuleSoft MCP Server registration"); enabledButton.setLayoutData(spanTwoColumns()); - useParentBackground(enabledButton); createLabel(container, "Client ID:"); clientIdText = createText(container, SWT.BORDER); @@ -51,8 +55,15 @@ protected Control createContents(Composite parent) { clientSecretText = createText(container, SWT.BORDER | SWT.PASSWORD); createLabel(container, "Region:"); - regionText = createText(container, SWT.BORDER); - regionText.setMessage("Optional: PROD_US, PROD_EU, PROD_CA, or PROD_JP"); + regionCombo = new Combo(container, SWT.DROP_DOWN | SWT.READ_ONLY); + regionCombo.setItems(REGION_OPTIONS); + regionCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + Label statusLabel = new Label(container, SWT.WRAP); + statusLabel.setText("Status: MCP server registration is managed in Preferences → Copilot → MCP Servers. " + + "After saving, approve the mulesoft server entry in that page for it to become active."); + statusLabel.setLayoutData(spanTwoColumns()); + useParentBackground(statusLabel); Label note = new Label(container, SWT.WRAP); note.setText("Credentials are stored in Eclipse secure storage. If a field is blank, the integration also checks " @@ -66,10 +77,11 @@ protected Control createContents(Composite parent) { @Override public boolean performOk() { + String selectedRegion = regionCombo.getSelectionIndex() > 0 ? regionCombo.getText() : ""; MuleSoftMcpSettings.save(enabledButton.getSelection(), clientIdText.getText(), clientSecretText.getText(), - regionText.getText()); + selectedRegion); if (enabledButton.getSelection() && MuleSoftMcpConfiguration.buildJson(clientIdText.getText(), - clientSecretText.getText(), regionText.getText()).isEmpty()) { + clientSecretText.getText(), selectedRegion).isEmpty()) { MessageDialog.openWarning(getShell(), "MuleSoft MCP Server", "MuleSoft MCP is enabled, but Client ID and Client Secret are empty. " + "Set them here or in the Studio process environment before approving the MCP server."); @@ -81,7 +93,14 @@ private void loadSettings() { enabledButton.setSelection(MuleSoftMcpSettings.isEnabled()); clientIdText.setText(MuleSoftMcpSettings.getClientId()); clientSecretText.setText(MuleSoftMcpSettings.getClientSecret()); - regionText.setText(MuleSoftMcpSettings.getRegion()); + String savedRegion = MuleSoftMcpSettings.getRegion(); + regionCombo.select(0); + for (int i = 0; i < REGION_OPTIONS.length; i++) { + if (REGION_OPTIONS[i].equals(savedRegion)) { + regionCombo.select(i); + break; + } + } } private static void createLabel(Composite container, String text) { @@ -93,6 +112,7 @@ private static void createLabel(Composite container, String text) { private static Text createText(Composite container, int style) { Text text = new Text(container, style); text.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(text); return text; } diff --git a/com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md b/com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md new file mode 100644 index 00000000..8c57bd17 --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md @@ -0,0 +1,53 @@ +# Copilot Instructions — MuleSoft Mule 4 Project + + + +## Project Context + +- **Mule runtime version**: 4.x (update to match mule-artifact.json `minMuleVersion`) +- **API-led layer**: Experience API / Process API / System API *(choose one)* +- **Deployment target**: CloudHub 1.0 / CloudHub 2.0 / Runtime Fabric / On-premises *(choose one)* +- **Primary connector(s)**: HTTP, Database, Salesforce, Anypoint MQ *(list the connectors in use)* + +## Coding Conventions + +- Flow names use camelCase verb-noun format: `getCustomerByIdFlow`, `processOrderFlow`. +- Sub-flow names use the same convention with a descriptive qualifier: `validateOrderSubFlow`. +- All environment-specific values use `${property.name}` placeholders resolved from `config-.yaml`. +- All sensitive values (passwords, tokens, keys) use `${secure::property.name}` backed by the Mule Secure Configuration Properties module. +- DataWeave scripts declare `output` type on every transform. Optional fields use `default` operator: `payload.name default ""`. + +## Error Handling + +- Every HTTP-facing flow has an `` with typed matchers (e.g., `type="HTTP:CONNECTIVITY"`). +- All error handlers log `correlationId`, `flow.name`, `error.errorType`, and `error.description` in structured JSON format. +- Error responses follow this shape: `{ "code": "...", "message": "...", "correlationId": "..." }` with the correct HTTP status code (400/401/403/404/500/503). +- Correlation IDs are set at the HTTP Listener from the `X-Correlation-ID` header (fallback `uuid()`) and propagated in all outbound HTTP Request headers. + +## Logging + +- INFO level: flow entry/exit with `correlationId`, `flowName`, and key input identifiers. No full payload logging at INFO. +- DEBUG level: connector call details and DataWeave diagnostics. Disabled in production (log4j2.xml root level = INFO). +- Never log passwords, tokens, API keys, or PII fields without masking. +- Log format: structured JSON via DataWeave `output application/json` in Logger `message` expressions. + +## Testing + +- Every public flow (HTTP Listener, Scheduler, MQ listener) has MUnit tests covering: happy path, invalid input (400), connector failure simulation, and error-response contract. +- All external connectors are mocked with `munit-tools:mock-when` by `doc:name`. Sub-flows are not mocked. +- Each `` router branch has its own test, including the otherwise branch. +- Maven command to run all tests: `mvn test` +- Maven command to run a single suite: `mvn test -Dmunit.test=.xml` + +## Connector Preferences + +- HTTP connector version: +- Database connector: +- Anypoint MQ: +- *(Add other connectors and their versions here)* + +## API Specification + +- API spec format: RAML 1.0 / OpenAPI 3.0 *(choose one)* +- API spec location: `src/main/resources/api/api.raml` *(or update path)* +- Security scheme: OAuth 2.0 Client Credentials / API Key / None *(choose one)* diff --git a/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md b/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md index 235f0561..c10745ad 100644 --- a/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md +++ b/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md @@ -5,46 +5,106 @@ tools: - api_schema_analyze - mule_code_review - mule_security_review + - mule_read_transform + - mule_write_transform + - mule_read_dwl_file + - mule_write_dwl_file + - mule_optimize_dwl - munit_validate_flow_tests - munit_full_review - munit_improvement_suggestions - - mulesoft: create_mule_project - - mulesoft: generate_mule_flow - - mulesoft: validate_project - - mulesoft: run_local_mule_application - - mulesoft: create_api_spec_project - - mulesoft: generate_api_spec - - mulesoft: implement_api_spec - - mulesoft: mock_api_spec - - mulesoft: search_asset - - mulesoft: dataweave_run_script_tool - - mulesoft: dataweave_create_sample_data - - mulesoft: dataweave_get_project_metadata - - mulesoft: dataweave_get_module_metadata - - mulesoft: dataweave_create_documentation - - mulesoft: generate_or_modify_munit_test - summarize_mule_project - get_mule_project_errors - run_mule_maven_tests + - mulesoft/create_mule_project + - mulesoft/generate_mule_flow + - mulesoft/run_local_mule_application + - mulesoft/create_api_spec_project + - mulesoft/generate_api_spec + - mulesoft/implement_api_spec + - mulesoft/mock_api_spec + - mulesoft/search_asset + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_create_sample_data + - mulesoft/dataweave_get_project_metadata + - mulesoft/dataweave_get_module_metadata + - mulesoft/dataweave_create_documentation + - mulesoft/generate_or_modify_munit_test + - mulesoft/deploy_mule_application + - mulesoft/update_mule_application + - mulesoft/list_applications + - mulesoft/create_and_manage_api_instances + - mulesoft/list_api_instances + - mulesoft/manage_api_instance_policy + - mulesoft/create_and_manage_assets + - mulesoft/get_reuse_metrics + - mulesoft/get_flex_gateway_policy_example + - mulesoft/manage_flex_gateway_policy_project + - mulesoft/create_install_runtime_fabric + - mulesoft/upgrade_runtime_fabric + - mulesoft/delete_runtime_fabric + - mulesoft/create_and_run_task + - mulesoft/get_platform_insights --- Use this agent for MuleSoft and Anypoint Studio work. Prefer MuleSoft MCP tools for API specs, Mule flow generation, DataWeave, Exchange assets, governance, deployment, policy, monitoring, and agent-network tasks. Use local Studio tools to inspect Mule XML, understand project structure, read problem markers, and run Maven or MUnit validation. -Treat Mule XML as executable integration configuration. Before editing flows, inspect namespaces, global configs, -property placeholders, connectors, existing flow names, and MUnit coverage. Keep generated changes consistent with -the project's Mule runtime, connector versions, property conventions, and API-led layering. +Always run `mule_project_scan` before editing flows or making claims about project structure. Treat Mule XML as +executable integration configuration — namespace-aware, connector-version-sensitive, and environment-parameterized. -Preserve API-led architecture boundaries. Do not duplicate flows, sub-flows, APIkit route mappings, or global -configuration. Never hardcode credentials, tokens, certificates, private keys, or passwords. Use secure properties or -external secret references, redact PII and secrets from logs, and call out security and performance risks. +## API-Led Architecture +Mulesoft applications follow a three-layer architecture. Preserve boundaries; never let a lower layer call a higher layer: +- **Experience API**: Consumer-facing, returns consumer-friendly payloads, handles protocol translation. Calls Process APIs only. +- **Process API**: Orchestrates business logic across multiple System APIs. Handles transformations, error aggregation, and routing. Does not call Experience APIs. +- **System API**: Thin adapter over a single backend system (SAP, Salesforce, DB). Exposes backend capabilities in a standard REST/SOAP contract. Does not call other System APIs. -When reviewing or generating code, state assumptions, propose minimal diffs, include validation commands, recommend -MUnit coverage, and prioritize critical and high findings before style or maintainability improvements. +When generating flows: identify which layer the request belongs to, confirm the target layer's connectors are appropriate, and enforce that routing logic (flow-refs, HTTP calls) respects the layer hierarchy. -When reviewing MUnit suites, validate that each test has a real purpose, executes the intended flow, uses MUnit and -MUnit Tools namespaces correctly, asserts meaningful outputs or side effects, and covers the flow components, branch -paths, external connector mocks, and error outcomes that matter for the integration. -Use `munit_full_review` for broad suite reviews and `munit_improvement_suggestions` to propose a practical cadence: -happy path, negative path, edge data, connector failure, and error-contract tests. +## Global Configuration Rules +- One global config per logical target: one `` per upstream host, one `` per logical database. +- All sensitive values must use `${secure::property.name}`. All environment-specific values (hosts, ports, paths) must use `${property.name}`. Never hardcode either in XML. +- Connector versions must be compatible with the project's `minMuleVersion` in `mule-artifact.json`. Do not suggest connector versions that are newer than what the declared runtime supports. + +## Error Handling Contract +- Every flow exposed via HTTP Listener or a message source must have an `` error handler with at least one typed error (`type="HTTP:CONNECTIVITY"`, `type="DB:QUERY_EXECUTION"`, etc.). Global catch-all error handlers are a fallback, not a substitute. +- `` is only appropriate when the flow must complete successfully despite the error (e.g., optional enrichment that fails gracefully). Default to ``. +- Error handlers must log: `correlationId`, `flow.name`, `error.errorType`, and `error.description`. Never log full payload in error handlers. +- All HTTP-facing error handlers must return a consistent JSON error shape: `{ "code": "...", "message": "...", "correlationId": "..." }` with the appropriate HTTP status (400, 401, 404, 500 — never always 500). +- Correlation ID must be set at the HTTP Listener (from `X-Correlation-ID` header or `uuid()` if absent) and propagated in all outbound calls and log messages. + +## Standalone DataWeave Module Files +- Use `mule_read_dwl_file` to read `.dwl` module files in `src/main/resources/dwl/` before editing or reviewing them. +- Run `mule_optimize_dwl` before rewriting a DWL module to surface performance issues (nested maps, inline regex, round-trip serialization), null-safety gaps, and missing output declarations. +- Use `mule_write_dwl_file` to update a `.dwl` module after confirming the optimized script with the user. +- Always run `mulesoft/dataweave_run_script_tool` after writing to validate the updated script against representative sample data. + +## DataWeave Best Practices +- Always run `mule_read_transform` before modifying any Transform Message component to understand the current script and output type. +- Output directive is mandatory: every script must start with `%dw 2.0` and declare `output application/json` (or appropriate type). +- Null-safe access required: use `default` operator on all optional field accesses (`payload.field default ""`). +- Use `map`, `filter`, `reduce`, `groupBy` over imperative `if/else` loops. Flag nested `map` over large collections — pre-index with `groupBy` instead. +- For payloads over 1 MB, use `output application/json streaming=true`. Streaming transforms cannot use `sizeOf()`, `[-1]`, or `reverse()`. +- Repeated DataWeave logic across multiple transforms should be extracted to a `.dwl` module in `src/main/resources/dwl/`. +- After writing a transform, validate with `mule_write_transform` only after confirming the target element (`ee:set-payload`, `ee:set-attributes`, or `ee:set-variable`) and running diagnostics or Maven tests. + +## Logging Discipline +- Log at INFO on entry and exit of public flows. Log message must include: `correlationId`, `flowName`, and key input identifier (e.g., order ID, customer ID). Never log full payloads at INFO. +- Log at DEBUG for connector call details and DataWeave diagnostics. DEBUG must be disabled in production. +- Log at ERROR in every `` with: `correlationId`, `flowName`, `errorType`, `errorDescription`. +- Never log passwords, tokens, API keys, or PII fields. For unavoidable cases, mask: `email[0..2] ++ "***"`. +- Use structured JSON log format in Logger `message` expressions — not string concatenation. + +## Connector Governance +- All connector versions must align with the Mule runtime compatibility matrix. Flag deprecated connectors (HTTP v1, File Connector v1, Scripting Module for Groovy). +- Database connector global configs must set `minPoolSize`, `maxPoolSize`, and `maxWait`. HTTP Request configs must set `responseTimeout`. +- All outbound HTTP Request configs must use HTTPS and have TLS context configured. Never set `insecure="true"`. +- Retry strategy: use `reconnect` with finite `count` and `frequency`. Flag `reconnect-forever` in production deployments. + +## MUnit Testing +- Every public flow requires tests covering: happy path, negative/invalid input, connector failure simulation, and error-response contract. +- Use `munit:mock-when` on all external connector calls by `doc:name`. Do not mock sub-flow calls. +- Each `` router branch requires its own test, including the otherwise branch. +- Run `munit_validate_flow_tests` after generating tests to confirm namespace, config, execution, assertion, and coverage completeness. +- Use `mulesoft/generate_or_modify_munit_test` to create or update MUnit suites with full scenario coverage. diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/build.properties b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/build.properties index 5f22cdd4..bb9c4456 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/build.properties +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/build.properties @@ -1 +1,2 @@ -bin.includes = META-INF/ +bin.includes = META-INF/,\ + copilot-agent/ diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml index 117088d0..1f14125f 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/build.properties b/com.microsoft.copilot.eclipse.core.agent.linux.x64/build.properties index 5f22cdd4..bb9c4456 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.x64/build.properties +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/build.properties @@ -1 +1,2 @@ -bin.includes = META-INF/ +bin.includes = META-INF/,\ + copilot-agent/ diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml index ee9bd89b..abc90aa3 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/build.properties b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/build.properties index 5f22cdd4..bb9c4456 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/build.properties +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/build.properties @@ -1 +1,2 @@ -bin.includes = META-INF/ +bin.includes = META-INF/,\ + copilot-agent/ diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml index a66d50df..9a042bfa 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/build.properties b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/build.properties index 5f22cdd4..bb9c4456 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/build.properties +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/build.properties @@ -1 +1,2 @@ -bin.includes = META-INF/ +bin.includes = META-INF/,\ + copilot-agent/ diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml index 08d287fb..51f1f9b0 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/build.properties b/com.microsoft.copilot.eclipse.core.agent.win32/build.properties index 5f22cdd4..bb9c4456 100644 --- a/com.microsoft.copilot.eclipse.core.agent.win32/build.properties +++ b/com.microsoft.copilot.eclipse.core.agent.win32/build.properties @@ -1 +1,2 @@ -bin.includes = META-INF/ +bin.includes = META-INF/,\ + copilot-agent/ diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml b/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml index 5ae84249..f7d25ee7 100644 --- a/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java index c87449dc..9b3b1c4b 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java @@ -60,6 +60,14 @@ void testGetCopilotDefaultTabCharAndSizeForUnknownLanguage() { assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); } + @Test + void testGetCopilotDefaultTabCharAndSizeForDataWeave() { + when(mockFile.getFileExtension()).thenReturn("dwl"); + + assertTrue(formatOptionProvider.useSpace(mockFile)); + assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); + } + @Test void testGetCopilotDefaultTabCharAndSizeForNoExtensionFile() { when(mockFile.getFileExtension()).thenReturn(null); diff --git a/com.microsoft.copilot.eclipse.core/plugin.xml b/com.microsoft.copilot.eclipse.core/plugin.xml index 8557c57b..f5065057 100644 --- a/com.microsoft.copilot.eclipse.core/plugin.xml +++ b/com.microsoft.copilot.eclipse.core/plugin.xml @@ -25,5 +25,10 @@ languageId="dummy" contentType="com.microsoft.copilot.eclipse.dummy"> + + diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 165d17cf..d83ecfbe 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -26,6 +26,8 @@ private Constants() { public static final String PROXY_KERBEROS_SP = "proxyKerberosSp"; public static final String GITHUB_ENTERPRISE = "githubEnterprise"; public static final String WORKSPACE_CONTEXT_ENABLED = "workspaceContextEnabled"; + public static final String CONSOLE_CONTEXT_ENABLED = "consoleContextEnabled"; + public static final String TRANSFORM_CONTEXT_ENABLED = "transformContextEnabled"; public static final String SUB_AGENT_ENABLED = "subAgentEnabled"; public static final String AGENT_MAX_REQUESTS = "agentMaxRequests"; public static final String ENABLE_SKILLS = "enableSkills"; @@ -54,6 +56,16 @@ private Constants() { public static final String GITHUB_JOBS_VIEW_ID = "com.microsoft.copilot.eclipse.ui.jobs.JobsView"; public static final String SUPPRESS_TERMINAL_DEPENDENCY_DIALOG = "suppressTerminalDependencyDialog"; + // Auto-Approve settings + public static final String AUTO_APPROVE_TERMINAL_RULES = "autoApproveTerminalRules"; + public static final String AUTO_APPROVE_UNMATCHED_TERMINAL = "autoApproveUnmatchedTerminal"; + public static final String AUTO_APPROVE_FILE_OP_RULES = "autoApproveEditRules"; + public static final String AUTO_APPROVE_UNMATCHED_FILE_OP = "autoApproveUnmatchedFileOp"; + public static final String AUTO_APPROVE_MCP_SERVERS = "autoApproveMcpServers"; + public static final String AUTO_APPROVE_MCP_TOOLS = "autoApproveMcpTools"; + public static final String AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS = "autoApproveTrustToolAnnotations"; + public static final String AUTO_APPROVE_YOLO_MODE = "autoApproveYoloMode"; + // Base excluded file types shared by both // Copied from InelliJ, excluded file extension list // https://github.com/microsoft/copilot-intellij/blob/main/core/src/main/kotlin/com/github/copilot/chat/references/FileSearchService.kt diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java index 1e9a6bf0..f54e0bfa 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java @@ -25,6 +25,10 @@ public class FeatureFlags { private boolean customAgentPolicyEnabled = true; + private boolean autoApprovalTokenEnabled = true; + + private boolean autoApprovalPolicyEnabled = true; + public boolean isAgentModeEnabled() { return agentModeEnabled; } @@ -84,6 +88,25 @@ public void setCustomAgentPolicyEnabled(boolean customAgentPolicyEnabled) { this.customAgentPolicyEnabled = customAgentPolicyEnabled; } + /** + * Returns true if the auto-approval feature is available. + * Requires both the server token ({@code agent_mode_auto_approval}) and + * the organization policy ({@code agentMode.autoApproval.enabled}) to permit it. + * + * @return true if auto-approval is permitted + */ + public boolean isAutoApprovalEnabled() { + return autoApprovalTokenEnabled && autoApprovalPolicyEnabled; + } + + public void setAutoApprovalTokenEnabled(boolean autoApprovalTokenEnabled) { + this.autoApprovalTokenEnabled = autoApprovalTokenEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + public boolean isClientPreviewFeatureEnabled() { return clientPreviewFeatureEnabled; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java index 079628bf..b7624930 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java @@ -5,8 +5,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; +import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.service.BuiltInChatModeService; /** @@ -60,10 +62,25 @@ public BuiltInChatMode getBuiltInModeById(String id) { } /** - * Reloads built-in chat modes from the LSP API. This should be called when the user switches - * to ensure the latest modes are available for the current user context. + * Reloads built-in chat modes from the LSP API synchronously. Blocks the calling thread until + * the LSP responds. Prefer {@link #reloadModesAsync()} to avoid blocking the UI thread. */ public void reloadModes() { loadModesSync(); } + + /** + * Reloads built-in chat modes from the LSP API asynchronously. Safe to call from the UI thread. + * The returned future completes once the modes list has been updated. + */ + public CompletableFuture reloadModesAsync() { + return service.loadBuiltInModes().thenAccept(modes -> { + if (modes != null && !modes.isEmpty()) { + this.builtInModes = new CopyOnWriteArrayList<>(modes); + } + }).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to reload built-in modes asynchronously", ex); + return null; + }); + } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java new file mode 100644 index 00000000..07f10d92 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Represents a button action in the confirmation UI. Each action has a label, + * a decision (accept or dismiss), a persistence scope, and optional metadata + * for the handler to know what to persist. + */ +public class ConfirmationAction { + + /** Metadata key for the action type enum name. */ + public static final String META_ACTION = "action"; + + private final String label; + private final boolean accept; + private final ConfirmationActionScope scope; + private final Map metadata; + private final boolean primary; + + /** + * Creates a new confirmation action. + * + * @param label the button label + * @param accept true for accept, false for dismiss + * @param scope the persistence scope (null for dismiss actions) + * @param metadata extra data for the handler (e.g., command names, server name) + * @param primary whether this is the primary/default button + */ + public ConfirmationAction(String label, boolean accept, + ConfirmationActionScope scope, Map metadata, + boolean primary) { + this.label = label; + this.accept = accept; + this.scope = scope; + this.metadata = metadata != null ? metadata : Map.of(); + this.primary = primary; + } + + public String getLabel() { + return label; + } + + public boolean isAccept() { + return accept; + } + + public ConfirmationActionScope getScope() { + return scope; + } + + public Map getMetadata() { + return metadata; + } + + public boolean isPrimary() { + return primary; + } + + /** Creates a primary accept action (scope = ONCE). */ + public static ConfirmationAction allowOnce(String label) { + return new ConfirmationAction(label, true, + ConfirmationActionScope.ONCE, null, true); + } + + /** Creates a dismiss action. */ + public static ConfirmationAction skip(String label) { + return new ConfirmationAction(label, false, null, null, false); + } + + @Override + public int hashCode() { + return Objects.hash(accept, label, metadata, primary, scope); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationAction other = (ConfirmationAction) obj; + return accept == other.accept + && Objects.equals(label, other.label) + && Objects.equals(metadata, other.metadata) + && primary == other.primary && scope == other.scope; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("label", label) + .append("accept", accept) + .append("scope", scope) + .append("metadata", metadata) + .append("primary", primary) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java new file mode 100644 index 00000000..37fc6d88 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +/** + * Scope of how a confirmation decision should be persisted. + */ +public enum ConfirmationActionScope { + /** One-time acceptance for this single invocation. */ + ONCE, + /** Remember for the current conversation session (in-memory). */ + SESSION, + /** Remember globally (application-level persistent, synced to CLS). */ + GLOBAL +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java new file mode 100644 index 00000000..abc588fc --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Complete content for rendering a confirmation UI. Returned by handlers when a tool call + * needs user confirmation. Contains the display text and the list of action buttons. + */ +public class ConfirmationContent { + + private final String title; + private final String message; + private final List actions; + + /** + * Creates a new confirmation content. + * + * @param title bold title text displayed at the top + * @param message description text (may be null) + * @param actions list of button actions for the confirmation UI + */ + public ConfirmationContent(String title, String message, + List actions) { + this.title = title; + this.message = message; + this.actions = actions; + } + + public String getTitle() { + return title; + } + + public String getMessage() { + return message; + } + + public List getActions() { + return actions; + } + + @Override + public int hashCode() { + return Objects.hash(actions, message, title); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationContent other = (ConfirmationContent) obj; + return Objects.equals(actions, other.actions) + && Objects.equals(message, other.message) + && Objects.equals(title, other.title); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("title", title) + .append("message", message) + .append("actions", actions) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java new file mode 100644 index 00000000..b8553435 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Evaluates whether a tool confirmation request can be auto-approved. + * Each implementation handles a specific category of tool (terminal, file operations, MCP, etc.). + */ +public interface ConfirmationHandler { + + /** + * Evaluates whether the given confirmation request should be auto-approved. + * + * @param params the confirmation request parameters from CLS + * @return ConfirmationResult.AUTO_APPROVED if the tool call can proceed without user + * confirmation, or ConfirmationResult.NEEDS_CONFIRMATION if the user must approve + */ + ConfirmationResult evaluate(InvokeClientToolConfirmationParams params); +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java new file mode 100644 index 00000000..a496ab10 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of evaluating an auto-approve confirmation request. + * Either AUTO_APPROVED (no UI needed) or NEEDS_CONFIRMATION with content for the dialog. + */ +public class ConfirmationResult { + + /** Auto-approved, no user confirmation needed. */ + public static final ConfirmationResult AUTO_APPROVED = new ConfirmationResult(true, false, null); + + /** Dismissed — malformed or unhandleable request; CLS should be told to skip the tool. */ + public static final ConfirmationResult DISMISSED = new ConfirmationResult(false, true, null); + + private final boolean autoApproved; + private final boolean dismissed; + private final ConfirmationContent content; + + private ConfirmationResult(boolean autoApproved, boolean dismissed, ConfirmationContent content) { + this.autoApproved = autoApproved; + this.dismissed = dismissed; + this.content = content; + } + + /** Creates a result that requires user confirmation with the given content. */ + public static ConfirmationResult needsConfirmation( + ConfirmationContent content) { + return new ConfirmationResult(false, false, content); + } + + public boolean isAutoApproved() { + return autoApproved; + } + + /** Returns true if the request should be dismissed without showing UI. */ + public boolean isDismissed() { + return dismissed; + } + + /** Returns the confirmation content, or null if auto-approved or using defaults. */ + public ConfirmationContent getContent() { + return content; + } + + @Override + public int hashCode() { + return Objects.hash(autoApproved, dismissed, content); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationResult other = (ConfirmationResult) obj; + return autoApproved == other.autoApproved + && dismissed == other.dismissed + && Objects.equals(content, other.content); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApproved", autoApproved) + .append("dismissed", dismissed) + .append("content", content) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java new file mode 100644 index 00000000..9f5c819d --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A single file-operation auto-approve rule mapping a glob pattern to an allow/deny decision. + */ +public class FileOperationAutoApproveRule { + private String pattern; + private String description; + private boolean autoApprove; + private transient boolean isDefault; + + /** + * Creates a new rule. + * + * @param pattern the glob pattern (e.g., "**\/.github/instructions/*") + * @param description human-readable description of what this pattern matches + * @param autoApprove true to auto-approve, false to always require confirmation + */ + public FileOperationAutoApproveRule(String pattern, String description, boolean autoApprove) { + this(pattern, description, autoApprove, false); + } + + /** + * Creates a new rule. + * + * @param pattern the glob pattern + * @param description human-readable description + * @param autoApprove true to auto-approve, false to always require confirmation + * @param isDefault true if this is a CLS default rule (non-removable) + */ + public FileOperationAutoApproveRule(String pattern, String description, + boolean autoApprove, boolean isDefault) { + this.pattern = pattern; + this.description = description; + this.autoApprove = autoApprove; + this.isDefault = isDefault; + } + + /** Default constructor for Gson deserialization. */ + public FileOperationAutoApproveRule() { + } + + public String getPattern() { + return pattern; + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(boolean autoApprove) { + this.autoApprove = autoApprove; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean isDefault) { + this.isDefault = isDefault; + } + + @Override + public int hashCode() { + return Objects.hash(pattern, description, autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileOperationAutoApproveRule other = (FileOperationAutoApproveRule) obj; + return Objects.equals(pattern, other.pattern) + && Objects.equals(description, other.description) + && autoApprove == other.autoApprove; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("description", description) + .append("autoApprove", autoApprove) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java new file mode 100644 index 00000000..b70a2797 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A single terminal auto-approve rule mapping a command name or regex pattern to an + * allow/deny decision. + */ +public class TerminalAutoApproveRule { + private String command; + private boolean autoApprove; + + /** + * Creates a new rule. + * + * @param command the command name or regex pattern + * @param autoApprove true to auto-approve, false to always require confirmation + */ + public TerminalAutoApproveRule(String command, boolean autoApprove) { + this.command = command; + this.autoApprove = autoApprove; + } + + /** Default constructor for Gson deserialization. */ + public TerminalAutoApproveRule() { + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public boolean isAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(boolean autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove, command); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TerminalAutoApproveRule other = (TerminalAutoApproveRule) obj; + return autoApprove == other.autoApprove + && Objects.equals(command, other.command); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("command", command) + .append("autoApprove", autoApprove) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java index 8b06a164..1a79fba2 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java @@ -26,6 +26,7 @@ public class FormatOptionProvider { private static final String CPP_LANGUAGE_ID = "cpp"; private static final String[] CPP_LANGUAGE_EXTENSIONS = new String[] { "cpp", "c++", "cc", "cp", "cxx", "h", "h++", "hh", ".hpp", ".hxx", ".inc", ".inl", ".ipp", ".tcc", ".tpp" }; + private static final String DATAWEAVE_LANGUAGE_ID = "dataweave"; private static final boolean DEFAULT_USE_SPACE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_CHAR.equals("space"); private static final int DEFAULT_TAB_SIZE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_SIZE; @@ -46,6 +47,7 @@ private void initializeLanguageExtensionToIdMap() { for (String extension : CPP_LANGUAGE_EXTENSIONS) { languageExtensionToIdMap.put(extension, CPP_LANGUAGE_ID); } + languageExtensionToIdMap.put("dwl", DATAWEAVE_LANGUAGE_ID); } /** diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index c3897ad9..1e40287a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -310,6 +310,7 @@ public void onDidChangeFeatureFlags(DidChangeFeatureFlagsParams params) { flags.setMcpEnabled(params.isMcpEnabled()); flags.setByokEnabled(params.isByokEnabled()); flags.setClientPreviewFeatureEnabled(params.isClientPreviewFeaturesEnabled()); + flags.setAutoApprovalTokenEnabled(params.isAutoApprovalEnabled()); } if (eventBroker != null) { @@ -337,6 +338,7 @@ public void onDidChangePolicy(DidChangePolicyParams params) { flags.setCustomAgentPolicyEnabled(params.isCustomAgentEnabled()); eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_CUSTOM_AGENT_POLICY, params.isCustomAgentEnabled()); } + flags.setAutoApprovalPolicyEnabled(params.isAutoApprovalPolicyEnabled()); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java index aae71950..8f74d5dd 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java @@ -36,6 +36,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsResult; @@ -285,6 +286,13 @@ public interface CopilotLanguageServer extends LanguageServer { @JsonRequest("githubApi/searchPR") CompletableFuture searchPr(SearchPrParams params); + /** + * Get the default file safety rules from CLS. + */ + @JsonRequest("getDefaultFileSafetyRules") + CompletableFuture getDefaultFileSafetyRules( + NullParams params); + /** * Notify that an inline edit was shown. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java index 7820652b..85e26f98 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java @@ -54,6 +54,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.ModelInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; @@ -153,6 +154,16 @@ public CompletableFuture checkQuota() { return this.languageServerWrapper.execute(fn); } + /** + * Get the default file safety rules from CLS. + */ + public CompletableFuture getDefaultFileSafetyRules() { + Function> fn = + server -> ((CopilotLanguageServer) server) + .getDefaultFileSafetyRules(new NullParams()); + return this.languageServerWrapper.execute(fn); + } + /** * Get single completion for the given parameters. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java index 5a54d5e1..fed79ba5 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; +import java.util.List; +import java.util.Map; import java.util.Objects; import com.google.gson.annotations.SerializedName; @@ -16,9 +18,148 @@ public class CopilotAgentSettings { @SerializedName("maxToolCallingLoop") private int agentMaxRequests; private boolean enableSkills; + @SerializedName("toolConfirmAutoApprove") + private List toolConfirmAutoApprove = List.of(); private String transcriptDirectory; + @SerializedName("autoApproveUnmatchedTerminal") + private boolean autoApproveUnmatchedTerminal; + + @SerializedName("autoApproveUnmatchedFileOp") + private boolean autoApproveUnmatchedFileOp; + + // Tells CLS to always send confirmation requests to the editor + @SerializedName("editorHandlesAllConfirmation") + private boolean editorHandlesAllConfirmation = true; + + private ToolsSettings tools; + + /** + * Setting shape expected by the Copilot language server for MCP tool auto-approval configuration. + */ + public record McpAutoApproveSetting(String serverName, boolean isServerAllowed, List allowedTools) { + } + + /** Nested tools settings matching CLS agent.tools structure. */ + public static class ToolsSettings { + private TerminalSettings terminal; + private EditSettings edit; + + /** Gets terminal settings, creating if needed. */ + public TerminalSettings getTerminal() { + if (terminal == null) { + terminal = new TerminalSettings(); + } + return terminal; + } + + /** Gets edit settings, creating if needed. */ + public EditSettings getEdit() { + if (edit == null) { + edit = new EditSettings(); + } + return edit; + } + + @Override + public int hashCode() { + return Objects.hash(terminal, edit); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ToolsSettings other = (ToolsSettings) obj; + return Objects.equals(terminal, other.terminal) && Objects.equals(edit, other.edit); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("terminal", terminal) + .append("edit", edit) + .toString(); + } + } + + /** Terminal auto-approve rules: command/pattern -> allow(true)/deny(false). */ + public static class TerminalSettings { + private Map autoApprove; + + public Map getAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(Map autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((TerminalSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } + } + + /** Edit (file operation) auto-approve rules: pattern -> allow(true)/deny(false). */ + public static class EditSettings { + private Map autoApprove; + + public Map getAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(Map autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((EditSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } + } + public int getAgentMaxRequests() { return agentMaxRequests; } @@ -50,9 +191,46 @@ public void setTranscriptDirectory(String transcriptDirectory) { this.transcriptDirectory = transcriptDirectory; } + public List getToolConfirmAutoApprove() { + return toolConfirmAutoApprove; + } + + public void setToolConfirmAutoApprove(List toolConfirmAutoApprove) { + this.toolConfirmAutoApprove = toolConfirmAutoApprove == null ? List.of() : List.copyOf(toolConfirmAutoApprove); + } + + public boolean isEditorHandlesAllConfirmation() { + return editorHandlesAllConfirmation; + } + + public boolean isAutoApproveUnmatchedTerminal() { + return autoApproveUnmatchedTerminal; + } + + public void setAutoApproveUnmatchedTerminal(boolean autoApproveUnmatchedTerminal) { + this.autoApproveUnmatchedTerminal = autoApproveUnmatchedTerminal; + } + + public boolean isAutoApproveUnmatchedFileOp() { + return autoApproveUnmatchedFileOp; + } + + public void setAutoApproveUnmatchedFileOp(boolean autoApproveUnmatchedFileOp) { + this.autoApproveUnmatchedFileOp = autoApproveUnmatchedFileOp; + } + + /** Gets tools settings, creating if needed. */ + public ToolsSettings getTools() { + if (tools == null) { + tools = new ToolsSettings(); + } + return tools; + } + @Override public int hashCode() { - return Objects.hash(agentMaxRequests, enableSkills, transcriptDirectory); + return Objects.hash(agentMaxRequests, enableSkills, toolConfirmAutoApprove, transcriptDirectory, + editorHandlesAllConfirmation, autoApproveUnmatchedTerminal, autoApproveUnmatchedFileOp, tools); } @Override @@ -68,7 +246,12 @@ public boolean equals(Object obj) { } CopilotAgentSettings other = (CopilotAgentSettings) obj; return agentMaxRequests == other.agentMaxRequests && enableSkills == other.enableSkills - && Objects.equals(transcriptDirectory, other.transcriptDirectory); + && Objects.equals(toolConfirmAutoApprove, other.toolConfirmAutoApprove) + && Objects.equals(transcriptDirectory, other.transcriptDirectory) + && editorHandlesAllConfirmation == other.editorHandlesAllConfirmation + && autoApproveUnmatchedTerminal == other.autoApproveUnmatchedTerminal + && autoApproveUnmatchedFileOp == other.autoApproveUnmatchedFileOp + && Objects.equals(tools, other.tools); } @Override @@ -76,7 +259,12 @@ public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("agentMaxRequests", agentMaxRequests); builder.append("enableSkills", enableSkills); + builder.append("toolConfirmAutoApprove", toolConfirmAutoApprove); builder.append("transcriptDirectory", transcriptDirectory); + builder.append("editorHandlesAllConfirmation", editorHandlesAllConfirmation); + builder.append("autoApproveUnmatchedTerminal", autoApproveUnmatchedTerminal); + builder.append("autoApproveUnmatchedFileOp", autoApproveUnmatchedFileOp); + builder.append("tools", tools); return builder.toString(); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java index 44dd3e4c..cdd0e839 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java @@ -76,6 +76,15 @@ public boolean isClientPreviewFeaturesEnabled() { return !disabled; } + /** + * Checks if the auto-approval feature is enabled. + * Disabled only when the feature flag "agent_mode_auto_approval" is set to "0". + */ + public boolean isAutoApprovalEnabled() { + boolean disabled = featureFlags != null && "0".equals(featureFlags.get("agent_mode_auto_approval")); + return !disabled; + } + @Override public int hashCode() { return Objects.hash(activeExps, featureFlags, envelope, byokEnabled); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java new file mode 100644 index 00000000..90348463 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A file safety rule as returned by CLS {@code getDefaultFileSafetyRules}. + * + *

Field names match the CLS JSON-RPC response exactly.

+ */ +public class FileSafetyRuleInfo { + + private String pattern; + private boolean requiresConfirmation; + private String description; + + /** Default constructor for Gson deserialization. */ + public FileSafetyRuleInfo() { + } + + /** + * Creates a new FileSafetyRuleInfo. + * + * @param pattern the glob pattern + * @param requiresConfirmation whether the file requires confirmation + * @param description description of the rule + */ + public FileSafetyRuleInfo(String pattern, boolean requiresConfirmation, + String description) { + this.pattern = pattern; + this.requiresConfirmation = requiresConfirmation; + this.description = description; + } + + public String getPattern() { + return pattern; + } + + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public boolean isRequiresConfirmation() { + return requiresConfirmation; + } + + public void setRequiresConfirmation(boolean requiresConfirmation) { + this.requiresConfirmation = requiresConfirmation; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public int hashCode() { + return Objects.hash(description, pattern, requiresConfirmation); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileSafetyRuleInfo other = (FileSafetyRuleInfo) obj; + return Objects.equals(description, other.description) + && Objects.equals(pattern, other.pattern) + && requiresConfirmation == other.requiresConfirmation; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("requiresConfirmation", requiresConfirmation) + .append("description", description) + .toString(); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java new file mode 100644 index 00000000..a88e2c6c --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of the {@code getDefaultFileSafetyRules} CLS request. + */ +public class GetDefaultFileSafetyRulesResult { + + private List defaultRules; + + /** Default constructor for Gson deserialization. */ + public GetDefaultFileSafetyRulesResult() { + } + + /** + * Creates a new result with the given default rules. + * + * @param defaultRules the list of default file safety rules + */ + public GetDefaultFileSafetyRulesResult( + List defaultRules) { + this.defaultRules = defaultRules; + } + + public List getDefaultRules() { + return defaultRules; + } + + public void setDefaultRules(List defaultRules) { + this.defaultRules = defaultRules; + } + + @Override + public int hashCode() { + return Objects.hash(defaultRules); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + GetDefaultFileSafetyRulesResult other = (GetDefaultFileSafetyRulesResult) obj; + return Objects.equals(defaultRules, other.defaultRules); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("defaultRules", defaultRules) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java index ba40b954..2066c74c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java @@ -52,6 +52,16 @@ public class InvokeClientToolConfirmationParams { */ private String toolCallId; + /** + * The MCP tool annotations describing tool behavior hints. + */ + private ToolAnnotations annotations; + + /** + * Additional metadata associated with the tool invocation. + */ + private ToolMetadata toolMetadata; + public String getName() { return name; } @@ -116,9 +126,26 @@ public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; } + public ToolAnnotations getAnnotations() { + return annotations; + } + + public void setAnnotations(ToolAnnotations annotations) { + this.annotations = annotations; + } + + public ToolMetadata getToolMetadata() { + return toolMetadata; + } + + public void setToolMetadata(ToolMetadata toolMetadata) { + this.toolMetadata = toolMetadata; + } + @Override public int hashCode() { - return Objects.hash(conversationId, input, message, name, roundId, title, toolCallId, turnId); + return Objects.hash(annotations, conversationId, input, message, name, roundId, title, toolCallId, toolMetadata, + turnId); } @Override @@ -133,10 +160,11 @@ public boolean equals(Object obj) { return false; } InvokeClientToolConfirmationParams other = (InvokeClientToolConfirmationParams) obj; - return Objects.equals(conversationId, other.conversationId) && Objects.equals(input, other.input) + return Objects.equals(annotations, other.annotations) + && Objects.equals(conversationId, other.conversationId) && Objects.equals(input, other.input) && Objects.equals(message, other.message) && Objects.equals(name, other.name) && roundId == other.roundId && Objects.equals(title, other.title) && Objects.equals(toolCallId, other.toolCallId) - && Objects.equals(turnId, other.turnId); + && Objects.equals(toolMetadata, other.toolMetadata) && Objects.equals(turnId, other.turnId); } @Override @@ -150,6 +178,8 @@ public String toString() { builder.append("turnId", turnId); builder.append("roundId", roundId); builder.append("toolCallId", toolCallId); + builder.append("annotations", annotations); + builder.append("toolMetadata", toolMetadata); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java new file mode 100644 index 00000000..a59f3598 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * MCP tool annotations describing tool behavior hints (e.g., readOnly, destructive). + * Provided by CLS, originally sourced from the MCP server's tool definition. + */ +public class ToolAnnotations { + private boolean readOnlyHint; + private boolean destructiveHint; + private boolean idempotentHint; + private boolean openWorldHint; + + public boolean isReadOnlyHint() { + return readOnlyHint; + } + + public void setReadOnlyHint(boolean readOnlyHint) { + this.readOnlyHint = readOnlyHint; + } + + public boolean isDestructiveHint() { + return destructiveHint; + } + + public void setDestructiveHint(boolean destructiveHint) { + this.destructiveHint = destructiveHint; + } + + public boolean isIdempotentHint() { + return idempotentHint; + } + + public void setIdempotentHint(boolean idempotentHint) { + this.idempotentHint = idempotentHint; + } + + public boolean isOpenWorldHint() { + return openWorldHint; + } + + public void setOpenWorldHint(boolean openWorldHint) { + this.openWorldHint = openWorldHint; + } + + @Override + public int hashCode() { + return Objects.hash(readOnlyHint, destructiveHint, idempotentHint, openWorldHint); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ToolAnnotations other = (ToolAnnotations) obj; + return readOnlyHint == other.readOnlyHint + && destructiveHint == other.destructiveHint + && idempotentHint == other.idempotentHint + && openWorldHint == other.openWorldHint; + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("readOnlyHint", readOnlyHint); + builder.append("destructiveHint", destructiveHint); + builder.append("idempotentHint", idempotentHint); + builder.append("openWorldHint", openWorldHint); + return builder.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java new file mode 100644 index 00000000..9883a504 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Arrays; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Tool-specific metadata provided by CLS as part of the confirmation request, used by the + * auto-approve system to make confirmation decisions. + */ +public class ToolMetadata { + + private TerminalCommandData terminalCommandData; + private SensitiveFileData sensitiveFileData; + + public TerminalCommandData getTerminalCommandData() { + return terminalCommandData; + } + + public void setTerminalCommandData(TerminalCommandData terminalCommandData) { + this.terminalCommandData = terminalCommandData; + } + + public SensitiveFileData getSensitiveFileData() { + return sensitiveFileData; + } + + public void setSensitiveFileData(SensitiveFileData sensitiveFileData) { + this.sensitiveFileData = sensitiveFileData; + } + + @Override + public int hashCode() { + return Objects.hash(terminalCommandData, sensitiveFileData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ToolMetadata other = (ToolMetadata) obj; + return Objects.equals(terminalCommandData, other.terminalCommandData) + && Objects.equals(sensitiveFileData, other.sensitiveFileData); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("terminalCommandData", terminalCommandData); + builder.append("sensitiveFileData", sensitiveFileData); + return builder.toString(); + } + + /** + * Data describing a terminal command and its sub-commands. + */ + public static class TerminalCommandData { + private String[] subCommands; + private String[] commandNames; + + public String[] getSubCommands() { + return subCommands; + } + + public void setSubCommands(String[] subCommands) { + this.subCommands = subCommands; + } + + public String[] getCommandNames() { + return commandNames; + } + + public void setCommandNames(String[] commandNames) { + this.commandNames = commandNames; + } + + @Override + public int hashCode() { + return Objects.hash(Arrays.hashCode(subCommands), Arrays.hashCode(commandNames)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TerminalCommandData other = (TerminalCommandData) obj; + return Arrays.equals(subCommands, other.subCommands) + && Arrays.equals(commandNames, other.commandNames); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("subCommands", subCommands); + builder.append("commandNames", commandNames); + return builder.toString(); + } + } + + /** + * Data describing a sensitive file that requires confirmation. + */ + public static class SensitiveFileData { + private String filePath; + private String matchingRule; + private String ruleDescription; + private boolean isGlobal; + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public String getMatchingRule() { + return matchingRule; + } + + public void setMatchingRule(String matchingRule) { + this.matchingRule = matchingRule; + } + + public String getRuleDescription() { + return ruleDescription; + } + + public void setRuleDescription(String ruleDescription) { + this.ruleDescription = ruleDescription; + } + + public boolean isGlobal() { + return isGlobal; + } + + public void setGlobal(boolean isGlobal) { + this.isGlobal = isGlobal; + } + + @Override + public int hashCode() { + return Objects.hash(filePath, matchingRule, ruleDescription, isGlobal); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + SensitiveFileData other = (SensitiveFileData) obj; + return Objects.equals(filePath, other.filePath) + && Objects.equals(matchingRule, other.matchingRule) + && Objects.equals(ruleDescription, other.ruleDescription) + && isGlobal == other.isGlobal; + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("filePath", filePath); + builder.append("matchingRule", matchingRule); + builder.append("ruleDescription", ruleDescription); + builder.append("isGlobal", isGlobal); + return builder.toString(); + } + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java index b5087205..3d21772a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java @@ -22,6 +22,9 @@ public class DidChangePolicyParams { @SerializedName("customAgent.enabled") private boolean customAgentEnabled = true; + @SerializedName("agentMode.autoApproval.enabled") + private boolean autoApprovalPolicyEnabled = true; + public boolean isMcpContributionPointEnabled() { return mcpContributionPointEnabled; } @@ -46,9 +49,18 @@ public void setCustomAgentEnabled(boolean customAgentEnabled) { this.customAgentEnabled = customAgentEnabled; } + public boolean isAutoApprovalPolicyEnabled() { + return autoApprovalPolicyEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + @Override public int hashCode() { - return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled); + return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled, + autoApprovalPolicyEnabled); } @Override @@ -65,7 +77,8 @@ public boolean equals(Object obj) { DidChangePolicyParams other = (DidChangePolicyParams) obj; return mcpContributionPointEnabled == other.mcpContributionPointEnabled && subAgentEnabled == other.subAgentEnabled - && customAgentEnabled == other.customAgentEnabled; + && customAgentEnabled == other.customAgentEnabled + && autoApprovalPolicyEnabled == other.autoApprovalPolicyEnabled; } @Override @@ -74,6 +87,7 @@ public String toString() { builder.append("mcpContributionPointEnabled", mcpContributionPointEnabled); builder.append("subAgentEnabled", subAgentEnabled); builder.append("customAgentEnabled", customAgentEnabled); + builder.append("autoApprovalPolicyEnabled", autoApprovalPolicyEnabled); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md new file mode 100644 index 00000000..2a76f5f9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md @@ -0,0 +1,342 @@ +# File Operation Auto Approve + +## Overview +Tests the file-operation (read/write) auto-approve feature end-to-end: +configuring glob-pattern rules in the preference page, attaching context +files, then triggering Agent Mode tool calls and observing whether the +confirmation dialog appears or the operation runs automatically. + +Each test case exercises the full stack: preference store → CLS sync → +Agent Mode prompt → tool confirmation request → `ConfirmationService` → +`FileOperationConfirmationHandler` → dialog (or auto-approve) → file +operation execution. This mirrors the real user workflow: tweak settings, +attach files, chat with Copilot, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve** — the file + operation rule table (add / remove / toggle / reset). +- **Agent Mode chat** — sending prompts that trigger `copilot.read_file`, + `copilot.editFile`, `copilot.createFile`, or `copilot.deleteFile` tool + calls. +- **Confirmation dialog** — the button with session/global allow actions. +- **Attached files** — the context panel for user-attached files. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- A workspace with at least one Java project open (e.g., `demo`). +- No previous file-operation auto-approve rules beyond the defaults + (reset via "Reset to Defaults" before each scenario). +- "Auto approve file operations not covered by rules" is **unchecked** + unless the test specifies otherwise. + +--- + +## 1. Default behavior and dialog UI + +### TC-001: File read in workspace triggers confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Default rules are active (not modified). +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Verify the "File Operation Auto Approve" section is visible with a + table showing default deny rules (e.g., `.github/instructions/*`, + `github-copilot/**/*`). +3. **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). Close preferences. +4. Open the **Copilot Chat** view, select **Agent** mode. +5. Type: `read the file src/demo/App.java and summarize it`. +6. Wait for the agent to invoke the `copilot.read_file` tool. +7. Observe the confirmation dialog. Verify it shows: + - Title: **"Read file"**, message mentioning the file name. + - **"Allow Once"** button with dropdown, and a **"Skip"** button. +8. Click the dropdown arrow. Verify the menu contains: + - "Allow this file in this Session" + - "Always Allow" +9. Click **"Skip"**. +10. Verify the agent receives a dismiss result — no file content. + +#### Expected Result +- No matching allow rule → confirmation dialog appears. +- Dialog renders correctly with session/global actions. +- Skip prevents execution. + +#### 📸 Key Screenshots +- [ ] Preference page with default rules. +- [ ] Confirmation dialog with dropdown expanded. +- [ ] Agent turn after skip. + +--- + +## 2. Attached file auto-approval + +### TC-002: Attached file auto-approves; deny rule does not block attached files + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add a deny rule `**/*.java` → **Deny**. + Apply and close. +2. Open `src/demo/App.java` in the editor. +3. Open the **Copilot Chat** view, select **Agent** mode. +4. Attach `App.java` via the context panel (paperclip / "Add Context"). +5. Type: `read App.java and explain what it does`. +6. Observe **no confirmation dialog** — the file is auto-approved + because it is attached, even though the deny rule matches. +7. Verify the agent reads and summarizes the file content. +8. In the **same conversation**, type: `now read Helper.java`. +9. Observe **confirmation dialog appears** — `Helper.java` is not + attached and the deny rule matches. + +#### Expected Result +- Attached file auto-approval takes precedence over deny rules. +- Non-attached files still respect rules normally. + +#### 📸 Key Screenshots +- [ ] Context panel showing attached `App.java`. +- [ ] Agent auto-approved read without dialog. +- [ ] Dialog appears for non-attached `Helper.java`. + +--- + +## 3. Session-level file approval + +### TC-003: Session approval — read, then write, then new conversation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it so the initial read triggers a dialog). + +#### Steps +1. Ensure no custom rules exist for the test file. +2. In Agent Mode, type: `read src/demo/App.java`. +3. Confirmation dialog appears. +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read successfully. +6. In the **same conversation**, type: `read src/demo/App.java again`. +7. Observe **auto-approved** — session cache hit. +8. In the **same conversation**, type: `add a comment "// test" to + the top of src/demo/App.java`. +9. The agent invokes `copilot.editFile` for `App.java`. +10. Observe **auto-approved** — session approval is path-based, covers + both reads and writes. +11. Start a **new conversation** (click "New Chat"). +12. Type: `read src/demo/App.java`. +13. Observe **confirmation dialog appears** — session approvals do not + carry to new conversations. + +#### Expected Result +- Session approval: same file re-read auto-approves. +- Session approval: write to same file auto-approves. +- New conversation: resets session state. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow this file in this Session". +- [ ] Write auto-approved in same conversation. +- [ ] New conversation: dialog reappears. + +--- + +## 4. Global rules — allow, deny, and "Always Allow" from dialog + +### TC-004: Glob allow and deny rules with unmatched toggle + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Add rule: `**/*.java` → **Allow**. Click OK. +3. Add rule: `**/secret/**` → **Deny**. Click OK. +4. Enable **"Auto approve file operations not covered by rules"**. +5. Click **"Apply and Close"**. +6. In Agent Mode, type: `read src/demo/App.java`. +7. Observe **auto-approved** — matches `**/*.java` allow rule. +8. Type: `read src/secret/config.properties`. +9. Observe **confirmation dialog** — matches `**/secret/**` deny rule, + even though unmatched is enabled. +10. Click **"Skip"**. +11. Type: `read README.md`. +12. Observe **auto-approved** — no rule matches, unmatched fallback. +13. Open preferences, **uncheck** "Auto approve file operations not + covered by rules". Apply and close. +14. Type: `read README.md`. +15. Observe **confirmation dialog** — unmatched now disabled. + +#### Expected Result +- Allow glob rule auto-approves matching files. +- Deny glob rule blocks matching files even with unmatched enabled. +- Unmatched toggle controls fallback for non-matching files. + +#### 📸 Key Screenshots +- [ ] Preference page with both rules. +- [ ] `.java` auto-approved, `secret/**` denied, `README.md` varies. + +--- + +### TC-005: "Always Allow" persists as global rule and overrides deny + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add deny rule for the file's absolute path + (e.g., `C:\\demo\src\demo\App.java`) → **Deny**. Apply and close. +2. In Agent Mode, trigger a file read for `App.java`. +3. Confirmation dialog appears (deny rule matches). +4. Click dropdown → **"Always Allow"**. +5. The file is read. +6. Open **Preferences → Tool Auto Approve**. +7. Verify the rule changed from **Deny → Allow** (no duplicate). +8. Close preferences. +9. Start a **new conversation**. +10. Type: `read src/demo/App.java`. +11. Observe **auto-approved** — the updated global rule persists. + +#### Expected Result +- "Always Allow" writes/updates the file path as a global allow rule. +- Overrides existing deny rule (case-insensitive match, no duplicates). +- Persists across conversations. + +#### 📸 Key Screenshots +- [ ] Preference page: deny → allow transition. +- [ ] New conversation: auto-approved. + +--- + +## 5. Outside-workspace files and folder-level approval + +### TC-006: Outside-workspace file requires confirmation with folder +approval + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Enable "Auto approve file operations not covered by rules". +2. Add allow rule `**/*`. Apply and close. +3. In Agent Mode, type: `read C:\temp\test\file1.txt` + (path outside workspace). +4. Observe **confirmation dialog** — outside-workspace files always + require confirmation regardless of rules. +5. Verify the dialog offers folder-level approval: + - "Allow Once" + - "Allow files in 'test' folder in this Session" + - "Skip" +6. Click dropdown → **"Allow files in 'test' folder in this Session"**. +7. The file is read. +8. In the same conversation, trigger read for `C:\temp\test\file2.txt`. +9. Observe **auto-approved** — same folder. +10. Trigger read for `C:\temp\other\file3.txt`. +11. Observe **confirmation dialog** — different folder. + +#### Expected Result +- Outside-workspace files bypass rules, always show dialog. +- Folder-level session approval covers sibling files. +- Different folders still require confirmation. + +#### 📸 Key Screenshots +- [ ] Dialog with folder-level action. +- [ ] Same-folder auto-approved. +- [ ] Different-folder dialog. + +--- + +## 6. Session approval overrides deny rule + +### TC-007: Session approval overrides deny rule within conversation + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Add deny rule `**/App.java` in preferences. Apply and close. +2. In Agent Mode, trigger a read for `src/demo/App.java`. +3. Confirmation dialog appears (deny rule). +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read. +6. In the same conversation, trigger another read for `App.java`. +7. Observe **auto-approved** — session approval checked before rules. + +#### Expected Result +- Session-level approval overrides global deny rules. + +--- + +## 7. Subagent inherits parent session approvals + +### TC-008: Session file approval applies to subagent + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom rules for the test file. +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger a read for `App.java`. +2. Confirmation dialog appears. +3. Select **"Allow this file in this Session"**. +4. The file is read. +5. In the **same conversation**, send a prompt that spawns a subagent + (e.g., `use a subagent to analyze App.java`). +6. The subagent invokes `copilot.read_file` for `App.java`. +7. Observe **auto-approved** — session approval carries to subagent. + +#### Expected Result +- Subagent shares parent conversation's session scope. + +--- + +## 8. Reset to Defaults + +### TC-009: Reset clears custom rules and reverts behavior + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Add custom rules: `**/*.java` → Allow, `**/secret/*` → Deny. + Apply and close. +2. Verify in Agent Mode: `.java` files auto-approve. +3. Open preferences, click **"Reset to Defaults"** and confirm. +4. **Manually uncheck** "Auto approve file operations not covered by rules" + (Reset to Defaults only clears rules, it does not reset this checkbox). + Apply and close. +5. Verify only default deny rules remain (`.github/instructions/*`, + `github-copilot/**/*`). +5. Apply and close. +6. In Agent Mode, trigger a `.java` file read. +7. Observe **confirmation dialog** — custom allow rule removed. + +#### Expected Result +- Reset removes all custom rules and restores defaults. + +#### 📸 Key Screenshots +- [ ] Preference page after reset — only defaults. +- [ ] `.java` file shows confirmation dialog post-reset. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md new file mode 100644 index 00000000..09cf4171 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md @@ -0,0 +1,125 @@ +# Global Auto-Approve + +## Overview + +Tests the Global Auto-Approve (YOLO) feature: enabling/disabling it via the +preference page and verifying that all tool confirmations are bypassed when +active. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → Global Auto-Approve** — + the "Automatically approve ALL tool invocations" checkbox with its + confirmation dialog. +- **Agent Mode chat** — any tool call (terminal, file operation, MCP) to + observe confirmation bypass. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- Global Auto-Approve is **disabled** at the start of each scenario. + +--- + +## 1. Enable Global Auto-Approve — confirmation dialog required + +### TC-001: Enable Global Auto-Approve → confirmation dialog required +→ all tools skip confirmation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox. +3. Observe that a **confirmation dialog immediately appears** asking the user + to confirm this dangerous setting. +4. Verify the dialog title and message warn about the risk. +5. Click **Cancel** — verify the checkbox remains **unchecked**. +6. Click the checkbox again, then click **OK** in the confirmation dialog. +7. Verify the checkbox is now **checked**. +8. Click **"Apply and Close"**. +9. In Agent Mode, send a prompt that would normally trigger a confirmation + (e.g., an MCP tool call or a terminal command). +10. Observe that **no confirmation dialog appears** — all tools auto-approve. + +#### Expected Result +- Enabling YOLO mode requires an explicit confirmation dialog. +- Cancelling the dialog keeps the checkbox unchecked. +- When enabled, all tool confirmations (terminal, file operations, MCP) + are bypassed. + +#### 📸 Key Screenshots +- [ ] Confirmation dialog when enabling YOLO mode. +- [ ] Checkbox unchecked after Cancel. +- [ ] Checkbox checked after OK. +- [ ] Agent Mode: tool runs without any confirmation dialog. + +--- + +## 2. Disable Global Auto-Approve — no confirmation needed + +### TC-002: Disable Global Auto-Approve → no dialog → tools require +confirmation again + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox to + uncheck it. +3. Observe that **no confirmation dialog appears** — turning it off is safe + and does not require confirmation. +4. Verify the checkbox is now **unchecked**. +5. Click **"Apply and Close"**. +6. In Agent Mode, trigger any tool. +7. Observe that the **confirmation dialog appears** — YOLO mode is off. + +#### Expected Result +- Disabling YOLO mode does not require a confirmation dialog. +- Tools require confirmation again after disabling. + +#### 📸 Key Screenshots +- [ ] Checkbox unchecked without any dialog. +- [ ] Tool shows confirmation dialog again. + +--- + +## 3. Global Auto-Approve overrides all tool categories + +### TC-003: Global Auto-Approve bypasses terminal deny rules, MCP +rules, and file operation rules + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. +- Terminal has a custom **Deny** rule for `curl`. + +#### Steps +1. In Agent Mode, trigger a `curl` terminal command (normally blocked by the + deny rule). +2. Observe **auto-approved** — YOLO mode bypasses the deny rule. +3. Trigger an MCP tool call with no prior MCP approval. +4. Observe **auto-approved** — YOLO mode bypasses MCP confirmation. +5. Trigger a file operation on a file not in the attached context. +6. Observe **auto-approved** — YOLO mode bypasses file operation confirmation. + +#### Expected Result +- Global Auto-Approve bypasses ALL tool categories regardless of individual + rules or approval lists. + +#### 📸 Key Screenshots +- [ ] `curl` auto-approved despite deny rule. +- [ ] MCP tool auto-approved without prior approval. +- [ ] File operation auto-approved. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md new file mode 100644 index 00000000..0659ec51 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md @@ -0,0 +1,265 @@ +# MCP Auto-Approve + +## Overview + +Tests the MCP tool auto-approve feature end-to-end: configuring rules in the +preference page, then triggering Agent Mode tool calls and observing whether +the confirmation dialog appears or the tool runs automatically. + +Each test case exercises the full stack: preference store → +`McpConfirmationHandler` → dialog (or auto-approve) → tool execution. This +mirrors the real user workflow: tweak settings, chat with Copilot via an MCP +tool, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → MCP Configuration** — + the "Trust MCP tool annotations" checkbox and the server/tool tree. +- **Agent Mode chat** — sending prompts that trigger MCP tool calls. +- **Confirmation dialog** — the split-dropdown button with session/global + allow actions for tool and server scope. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **At least one MCP server configured** in the Copilot MCP settings, with + at least one tool available. Note the server name and a tool name for use + in the prompts below. +- **Agent Mode** selected in the chat mode dropdown. +- All MCP auto-approve preferences at their defaults before each scenario: + no globally approved servers/tools, "Trust MCP tool annotations" unchecked. + +--- + +## 1. Default behavior: confirmation dialog appears for MCP tools + +### TC-001: MCP tool call with no rules → confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Navigate to the **MCP Configuration** section. +3. Verify the server/tool tree shows no tools checked. +4. Confirm "Trust MCP tool annotations" is unchecked. +5. Close preferences. +6. Open the **Copilot Chat** view and select **Agent** mode. +7. Send a prompt that triggers the known MCP tool (e.g., `use to + `). +8. Wait for the Copilot turn — the agent should invoke the MCP tool. +9. Observe the **confirmation dialog** that appears in the chat panel. +10. Verify the dialog shows: + - Bold title: `Run '' tool from '' MCP server`. + - A description of the MCP tool call. + - A blue **"Allow Once ▾"** split-dropdown button and a **"Skip"** button. +11. Click the dropdown arrow on "Allow Once ▾". +12. Verify the dropdown contains: + - "Allow '' in this Session" + - "Always Allow ''" + - "Allow tools from '' in this Session" + - "Always Allow tools from ''" +13. Click **"Skip"**. +14. Verify the tool was **NOT** executed. + +#### Expected Result +- Confirmation dialog appears for MCP tools with no auto-approve rules. +- Dropdown shows tool-level and server-level scoped actions. +- Skipping prevents execution. + +#### 📸 Key Screenshots +- [ ] MCP preference section with no checked tools. +- [ ] Confirmation dialog with dropdown expanded showing all actions. +- [ ] Agent turn after skip — no tool output. + +--- + +## 2. Session allow for a specific tool + +### TC-002: "Allow tool in Session" → same tool auto-approves → new +conversation resets + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown arrow and select **"Allow '' in this + Session"**. +4. The tool executes. +5. In the **same conversation**, send another prompt that triggers the same + tool. +6. Observe that **no confirmation dialog appears** — the tool is + session-approved. +7. Start a **new conversation** (click "New Chat" or equivalent). +8. Send a prompt that triggers the same MCP tool. +9. Observe that the **confirmation dialog appears again** — session approvals + do not carry over. + +#### Expected Result +- Session approval auto-approves the same tool within the conversation. +- New conversation resets session approvals. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow '' in this Session". +- [ ] Second invocation (same conversation): auto-approved, no dialog. +- [ ] New conversation: dialog reappears. + +--- + +## 3. Session allow for an entire server + +### TC-003: "Allow all tools from server in Session" → all tools from +that server auto-approve + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers any tool from the target + MCP server. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Allow all tools from '' + in this Session"**. +4. The tool executes. +5. In the **same conversation**, send prompts that trigger **different tools** + from the same server. +6. Observe that **none of them** show a confirmation dialog. +7. Start a **new conversation**. +8. Trigger any tool from the same server. +9. Observe that the **confirmation dialog appears again**. + +#### Expected Result +- Server-level session approval covers all tools from that server. +- New conversation resets the session approval. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Allow tools from '' in this Session". +- [ ] Second tool from same server: auto-approved. +- [ ] New conversation: dialog reappears. + +--- + +## 4. "Always Allow" for a specific tool — global persistence + +### TC-004: "Always Allow ''" → persists across conversations → +visible in preferences tree + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow ''"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the specific tool is **checked** in the server/tool tree. +7. Close preferences. +8. Start a **new conversation**. +9. Send a prompt that triggers the same MCP tool. +10. Observe that **no confirmation dialog appears** — the global rule persists. + +#### Expected Result +- "Always Allow" writes the tool key to the global preference store. +- The tool appears checked in the preference tree. +- The approval persists across conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Always Allow ''". +- [ ] Preference tree: tool checked. +- [ ] New conversation: tool auto-approved without dialog. + +--- + +## 5. "Always Allow" for an entire server — global persistence + +### TC-005: "Always Allow all tools from ''" → server shown +checked in preferences + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- No global approved servers or tools (clear any rules written by TC-004 + if running in sequence). +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger any MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow all tools from + ''"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the server node is **checked** in the tree. +7. Close preferences. +8. Start a **new conversation** and trigger different tools from the same + server. +9. Observe all tools **auto-approve without dialog**. + +#### Expected Result +- "Always Allow" for server writes to the global servers list. +- The server row appears checked in the preference tree. +- All tools from the server auto-approve in new conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: "Always Allow all tools from ''". +- [ ] Preference tree: server node checked. +- [ ] New conversation: all server tools auto-approved. + +--- + +## 6. Trust MCP tool annotations — read-only tools auto-approve + +### TC-006: Enable "Trust MCP tool annotations" → tools with +readOnlyHint=true auto-approve + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- An MCP tool is available with `readOnlyHint=true` and `openWorldHint=false` + in its annotations. + +#### Steps +1. Open **Preferences → Tool Auto Approve → MCP Configuration**. +2. Check **"Trust MCP tool annotations"**. +3. Click **"Apply and Close"**. +4. In Agent Mode, send a prompt that triggers the read-only MCP tool. +5. Observe that **no confirmation dialog appears** — the tool auto-approves + because it is annotated as read-only and closed-world. +6. Send a prompt that triggers an MCP tool that does NOT have + `readOnlyHint=true` (or has `openWorldHint=true`). +7. Observe that the **confirmation dialog appears** — only strictly + read-only + closed-world tools bypass confirmation. + +#### Expected Result +- Tools with `readOnlyHint=true` AND `openWorldHint=false` auto-approve. +- All other tools still show the confirmation dialog. + +#### 📸 Key Screenshots +- [ ] Preference: "Trust MCP tool annotations" checked. +- [ ] Read-only tool: auto-approved. +- [ ] Non-read-only tool: confirmation dialog shown. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md new file mode 100644 index 00000000..180783a4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md @@ -0,0 +1,551 @@ +# Terminal Auto Approve + +## Overview +Tests the terminal command auto-approve feature end-to-end: configuring rules +in the preference page, then triggering Agent Mode tool calls and observing +whether the confirmation dialog appears or the command runs automatically. + +Each test case exercises the full stack: preference store → CLS sync → +Agent Mode prompt → tool confirmation request → `ConfirmationService` → +`TerminalConfirmationHandler` → dialog (or auto-approve) → terminal +execution. This mirrors the real user workflow: tweak settings, chat with +Copilot, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve** — the terminal + rule table (add / remove / toggle / reset). +- **Agent Mode chat** — sending prompts that trigger `run_in_terminal` + tool calls. +- **Confirmation dialog** — the split-dropdown button with session/global + allow actions. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- No previous auto-approve rules beyond the defaults (reset via + "Reset to Defaults" before each scenario). + +--- + +## 1. Default deny rules block dangerous commands + +### TC-001: Default deny rules + Agent Mode terminal call + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Default rules are active (not modified). +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Verify the "Terminal Auto Approve" section is visible with a table + showing default deny rules (rm, rmdir, del, kill, curl, wget, eval, + chmod, chown, and the regex rules for subshells / backticks / braces). +3. Confirm "Auto approve commands not covered by rules" is unchecked. +4. Close preferences. +5. Open the **Copilot Chat** view and select **Agent** mode. +6. Wait for the model picker to resolve. +7. Type the prompt: `please run curl https://example.com`. +8. Wait for a Copilot turn to stream — the agent should invoke the + `run_in_terminal` tool with a `curl` command. +9. Observe the confirmation dialog that appears in the chat panel. +10. Verify the dialog shows: + - Bold title: **"Run command in terminal"** + - Message: *"The tool is about to run the following command in the + terminal."* + - The command text in a scrollable panel with `bg-command-panel` + background. + - A blue **"Allow Once ▾"** split-dropdown button and a **"Skip"** + button. +11. Click the dropdown arrow on "Allow Once ▾". +12. Verify the dropdown menu contains: + - "Allow 'curl' in this Session" + - "Always Allow 'curl'" + - "Allow this exact command in this Session" (if the full command + differs from "curl") + - "Always Allow this exact command" + - "Allow all commands in this Session" +13. Click **"Skip"**. +14. Verify the command was NOT executed — the agent receives a dismiss + result and continues without terminal output. + +#### Expected Result +- Default deny rule for `curl` causes the confirmation dialog to appear. +- The dialog renders correctly with split-dropdown actions. +- Skipping prevents execution. + +#### 📸 Key Screenshots +- [ ] Preference page with default rules visible. +- [ ] Confirmation dialog with dropdown expanded showing all actions. +- [ ] Agent turn after skip — no terminal output. + +--- + +## 2. Custom allow rule auto-approves matching commands + +### TC-002: Add allow rule → Agent auto-approves → verify execution + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Click **"Add..."** in the Terminal Auto Approve section. +3. Enter `systeminfo` as command, select **"Allow"**, click OK. +4. Verify `systeminfo` appears in the table with "Allow" status. +5. Click **"Apply and Close"**. +6. In Agent Mode chat, type: `run systeminfo to show my computer info`. +7. Wait for the agent to invoke `run_in_terminal`. +8. Observe that **no confirmation dialog appears** — the command runs + directly. +9. Wait for the tool call to complete — the agent should report terminal + output containing system information. +10. Open preferences again and verify the rule is still present. + +#### Expected Result +- The custom allow rule causes the `systeminfo` command to auto-approve. +- No confirmation dialog is shown. +- The terminal runs the command and the agent receives output. + +#### 📸 Key Screenshots +- [ ] Preference page with `systeminfo` Allow rule added. +- [ ] Agent turn showing "✔ Ran run_in_terminal tool" without a + confirmation dialog in between. +- [ ] Agent response containing system information output. + +--- + +## 3. Session "Allow command name" persists within conversation + +### TC-003: Allow name in Session → same command auto-approves → new +conversation resets + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Ensure no custom rules for `echo` exist (only defaults). +2. In Agent Mode, type: `run echo hello world`. +3. Confirmation dialog appears (echo has no matching rule and unmatched + is disabled). +4. Click the dropdown arrow and select **"Allow 'echo' in this Session"**. +5. The command executes. +6. In the **same conversation**, type: `run echo second message`. +7. Observe that **no confirmation dialog appears** — the command + auto-approves because `echo` is session-approved. +8. Verify the agent shows terminal output for both commands. +9. Start a **new conversation** (click "New Chat" or equivalent). +10. Type: `run echo third message`. +11. Observe that the **confirmation dialog appears again** — session + approvals do not carry over to new conversations. + +#### Expected Result +- First invocation: dialog shown, user selects session allow. +- Second invocation (same conversation): auto-approved. +- Third invocation (new conversation): dialog shown again. + +#### 📸 Key Screenshots +- [ ] First dialog with dropdown showing "Allow 'echo' in this Session". +- [ ] Second invocation auto-approved — no dialog. +- [ ] New conversation — dialog reappears. + +--- + +## 4. "Always Allow" persists globally + +### TC-004: Always Allow command name → persists across conversations +and appears in preferences + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Ensure no custom rules for `dir` exist. +2. In Agent Mode, type: `list files in current directory`. +3. Confirmation dialog appears for the `dir` (or `ls`) command. +4. Click the dropdown and select **"Always Allow 'dir'"**. +5. The command executes. +6. Open **Preferences → Tool Auto Approve**. +7. Verify `dir` appears in the rules table with **"Allow"** status. +8. Close preferences. +9. Start a **new conversation**. +10. Type: `show me what files are here` (triggers `dir` again). +11. Observe that **no confirmation dialog appears** — the global rule + persists. + +#### Expected Result +- The "Always Allow" action writes the command name to the preference + store. +- The rule appears in the preference page UI. +- The rule survives across conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown selection: "Always Allow 'dir'". +- [ ] Preference page showing `dir` as Allow rule. +- [ ] New conversation: `dir` auto-approved. + +--- + +## 5. Exact command vs. command name distinction + +### TC-005: Exact command Session approval only matches the same +command line + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, type: `run echo hello world`. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow this exact command in this + Session"**. +4. The command executes. +5. In the same conversation, type: `run echo hello world` again. +6. Observe **auto-approved** — exact same command line. +7. In the same conversation, type: `run echo different text`. +8. Observe **confirmation dialog appears** — different command line, + even though command name `echo` is the same. + +#### Expected Result +- Exact command approval is strict: only the identical command line + is auto-approved. +- A different argument string requires separate confirmation. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow this exact command in this Session". +- [ ] Same command: auto-approved. +- [ ] Different arguments: dialog appears again. + +--- + +## 6. Simple command hides redundant exact-command actions + +### TC-006: Single-word command shows minimal dropdown + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger a single-word command like `ipconfig` + (or `hostname`). +2. Confirmation dialog appears. +3. Click the dropdown arrow. +4. Count the menu items. + +#### Expected Result +- "Allow 'ipconfig' in this Session" — present. +- "Always Allow 'ipconfig'" — present. +- "Allow this exact command in this Session" — **NOT present** + (redundant: exact command = command name for single-word commands). +- "Always Allow this exact command" — **NOT present**. +- "Allow all commands in this Session" — present. + +--- + +## 7. Unmatched auto-approve toggle + +### TC-007: Enable unmatched auto-approve → unknown commands pass +through → disable → they require confirmation again + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Check **"Auto approve commands not covered by rules"**. +3. Click **"Apply and Close"**. +4. In Agent Mode, type: `run hostname`. +5. Observe **auto-approved** — `hostname` has no matching rule but + unmatched auto-approve is enabled. +6. Open preferences again. +7. **Uncheck** "Auto approve commands not covered by rules". +8. Click **"Apply and Close"**. +9. In the same or new conversation, type: `run hostname`. +10. Observe **confirmation dialog appears**. + +#### Expected Result +- With unmatched enabled: unknown commands auto-approve. +- With unmatched disabled: unknown commands require confirmation. + +#### 📸 Key Screenshots +- [ ] Preference: "Auto approve commands not covered by rules" checked. +- [ ] `hostname` auto-approved. +- [ ] Preference: unchecked. +- [ ] `hostname` shows confirmation dialog. + +--- + +## 8. Regex rule integration + +### TC-008: Add a case-insensitive regex allow rule → verify it +matches in Agent Mode + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Click **"Add..."**, enter `/^npm\b/i`, select **"Allow"**, click OK. +3. Click **"Apply and Close"**. +4. In Agent Mode, type: `install lodash using npm`. +5. Agent invokes `npm install lodash`. +6. Observe **auto-approved** — regex `/^npm\b/i` matches. +7. Type: `run NPM run build` (uppercase). +8. Observe **auto-approved** — case-insensitive flag `/i` matches. + +#### Expected Result +- The regex allow rule auto-approves matching commands. +- Case-insensitive flag works correctly. + +--- + +## 9. "Allow all commands in this Session" — blanket approval + +### TC-009: Allow all → every subsequent terminal call auto-approves +in this conversation + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger any terminal command. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow all commands in this Session"**. +4. The command executes. +5. In the same conversation, send multiple prompts that trigger + different terminal commands (e.g., "run dir", "run echo test", + "run ipconfig"). +6. Observe that **none of them** show a confirmation dialog. +7. Start a **new conversation**. +8. Trigger any terminal command. +9. Observe that the **confirmation dialog appears again**. + +#### Expected Result +- "Allow all" is a blanket session-scoped approval. +- Every terminal command in the same conversation auto-approves. +- New conversations start fresh. + +--- + +## 10. Reset to Defaults clears custom rules + +### TC-010: Add custom rules → Reset → verify Agent Mode behavior +reverts + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Open preferences and add `echo` as Allow, `javac` as Allow. +2. Apply and close. +3. Verify in Agent Mode: `echo hello` auto-approves. +4. Open preferences again. +5. Click **"Reset to Defaults"** and confirm. +6. Verify only default deny rules remain — `echo` and `javac` are gone. +7. Apply and close. +8. In Agent Mode, trigger `echo hello`. +9. Observe **confirmation dialog appears** — the custom allow rule was + removed. + +#### Expected Result +- Reset removes all custom rules. +- Commands that were previously allowed now require confirmation. + +#### 📸 Key Screenshots +- [ ] Preference page after adding custom rules. +- [ ] Preference page after reset — only defaults. +- [ ] `echo hello` shows confirmation dialog post-reset. + +--- + +## 11. Already-approved names filtered from dropdown + +### TC-011: Session-approved command name hidden from dropdown +actions in multi-command scenario + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger a command that includes `echo` + (e.g., "run echo hello"). +2. Confirmation dialog appears. +3. Select **"Allow 'echo' in this Session"** from the dropdown. +4. The command executes. +5. In the same conversation, send a prompt that triggers a + multi-command like `echo test && curl https://example.com`. +6. Confirmation dialog appears (because `curl` is a default deny + rule). +7. Click the dropdown arrow. + +#### Expected Result +- The dropdown shows **"Allow 'curl' in this Session"** and + **"Always Allow 'curl'"**. +- `echo` does **NOT** appear in the command-name actions — it was + already session-approved. +- "Allow all commands in this Session" is still shown. +- Exact command actions (if shown) refer to the full multi-command + string, not individual parts. + +#### 📸 Key Screenshots +- [ ] First dialog: approving `echo` in session. +- [ ] Second dialog: dropdown showing only `curl`, not `echo`. + +--- + +### TC-012: Global-approved command name hidden from dropdown + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Add `echo` as **Allow** rule. Apply and close. +3. In Agent Mode, trigger `echo hello && hostname`. +4. Confirmation dialog appears (because `hostname` has no matching + rule and unmatched is disabled). +5. Click the dropdown arrow. + +#### Expected Result +- The dropdown shows **"Allow 'hostname' in this Session"** and + **"Always Allow 'hostname'"**. +- `echo` does **NOT** appear — it is globally allowed. +- "Allow all commands in this Session" is still shown. + +--- + +## 13. Subagent inherits parent session approvals + +### TC-013: Session approval in main agent applies to subagent calls + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom allow rules for `echo` or `cat` (only defaults). +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers `echo hello`. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow 'echo' in this Session"**. +4. The command executes. +5. In the **same conversation**, send a complex prompt that causes the + agent to spawn a **subagent** (e.g., `use a subagent to run echo + from subagent`). +6. The subagent invokes `run_in_terminal` with an `echo` command. +7. Observe that **no confirmation dialog appears** — the session + approval from the main agent carries over to the subagent. +8. Still in the subagent context, the subagent invokes a different + command (e.g., `cat somefile.txt`). +9. Observe that a **confirmation dialog appears** — `cat` was not + session-approved. + +#### Expected Result +- Subagent tool calls use the parent conversation's session rules. +- Commands approved in the main agent conversation auto-approve in + subagent context. +- Commands NOT approved still require confirmation in subagent context. + +#### 📸 Key Screenshots +- [ ] Main agent: approving `echo` in session. +- [ ] Subagent: `echo` auto-approved — no dialog. +- [ ] Subagent: `cat` shows confirmation dialog. + +--- + +### TC-014: Session approval in subagent carries back to main agent + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom allow rules for `hostname`. +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, send a complex prompt that triggers a **subagent**. +2. The subagent invokes `run_in_terminal` with `hostname`. +3. Confirmation dialog appears. +4. Click dropdown and select **"Allow 'hostname' in this Session"**. +5. The command executes in the subagent context. +6. After the subagent completes, continue in the **same conversation** + with the main agent. +7. Send a prompt that triggers `hostname` again (e.g., `what is my + hostname?`). +8. Observe that **no confirmation dialog appears** — the session + approval made during the subagent call is shared with the main + conversation. + +#### Expected Result +- Session approvals made during subagent execution are stored under + the parent conversation's session scope. +- The main agent benefits from approvals granted in subagent context. + +#### 📸 Key Screenshots +- [ ] Subagent: approving `hostname` in session. +- [ ] Main agent: `hostname` auto-approved — no dialog. + +--- + +### TC-015: "Allow all commands in this Session" in main agent covers subagent + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Steps +1. In Agent Mode, trigger any terminal command. +2. Confirmation dialog appears. +3. Select **"Allow all commands in this Session"** from the dropdown. +4. In the **same conversation**, send a prompt that spawns a subagent + which runs multiple different terminal commands. +5. Observe that **none of them** show a confirmation dialog — the + blanket session approval covers subagent calls too. + +#### Expected Result +- "Allow all commands in this Session" is a blanket approval that + applies to both main agent and subagent tool calls within the + same conversation. + +--- + +## 14. Edge Cases + +### TC-016: "Always Allow" overrides existing deny rule in preferences + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Click **"Add..."**, enter `curl`, select **"Deny"**, click OK. +3. Click **"Apply and Close"**. +4. In Agent Mode, type a prompt that triggers `curl` (e.g., `fetch + https://example.com using curl`). +5. Confirmation dialog appears (deny rule blocks it). +6. Click dropdown and select **"Always Allow curl"**. +7. The command executes. +8. Open **Preferences → Tool Auto Approve** again. +9. Verify the `curl` rule has been changed from **Deny → Allow**. +10. Start a **new conversation**, trigger `curl` again. +11. Observe **auto-approved** — the global allow rule now applies. + +#### Expected Result +- Clicking "Always Allow" in the dialog overrides an existing deny + rule in preferences, changing it from deny to allow. +- The updated rule persists across conversations. +- The preferences table reflects the updated rule. + +#### 📸 Key Screenshots +- [ ] Preferences: `curl → Deny` rule before override. +- [ ] Confirmation dialog: showing "Always Allow curl" action. +- [ ] Preferences: `curl → Allow` rule after override. +- [ ] New conversation: `curl` auto-approved without dialog. diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF index 34feade7..cb7ac817 100644 --- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF @@ -18,6 +18,7 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", com.microsoft.copilot.eclipse.anypoint;bundle-version="0.15.0", org.eclipse.ui;bundle-version="3.205.0", + org.eclipse.ui.console, org.eclipse.ui.ide, org.eclipse.ui.workbench.texteditor, org.eclipse.ui.editors, diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java new file mode 100644 index 00000000..1b903806 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import org.eclipse.jface.text.contentassist.ICompletionProposal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.UserPreferenceService; + +@ExtendWith(MockitoExtension.class) +class ChatAssistProcessorTest { + @Mock + private ChatServiceManager chatServiceManager; + + @Mock + private ChatCompletionService chatCompletionService; + + @Mock + private UserPreferenceService userPreferenceService; + + private ChatAssistProcessor processor; + + @BeforeEach + void setUp() { + when(chatServiceManager.getChatCompletionService()).thenReturn(chatCompletionService); + when(chatServiceManager.getUserPreferenceService()).thenReturn(userPreferenceService); + processor = new ChatAssistProcessor(null, chatServiceManager); + } + + @Test + void consoleProposalIsAvailableInBuiltInAskAgentAndPlanModes() { + assertConsoleProposal("Ask"); + assertConsoleProposal("Agent"); + assertConsoleProposal("Plan"); + } + + @Test + void consoleProposalIsNotAvailableForCustomAgents() { + String customModeId = "file:///workspace/.github/agents/custom.agent.md"; + when(userPreferenceService.getActiveModeNameOrId()).thenReturn(customModeId); + when(chatCompletionService.isConsoleContextCommandAvailable(customModeId)).thenReturn(false); + when(chatCompletionService.isAgentsReady()).thenReturn(false); + + ICompletionProposal[] proposals = processor.createCopilotCompletionAgentProposals("con"); + + assertEquals(0, proposals.length); + } + + @Test + void consoleProposalIsNotAvailableWhenPreferenceIsDisabled() { + when(userPreferenceService.getActiveModeNameOrId()).thenReturn("Ask"); + when(chatCompletionService.isConsoleContextCommandAvailable("Ask")).thenReturn(false); + when(chatCompletionService.isAgentsReady()).thenReturn(false); + + ICompletionProposal[] proposals = processor.createCopilotCompletionAgentProposals("con"); + + assertEquals(0, proposals.length); + } + + private void assertConsoleProposal(String modeName) { + when(userPreferenceService.getActiveModeNameOrId()).thenReturn(modeName); + when(chatCompletionService.isConsoleContextCommandAvailable(modeName)).thenReturn(true); + when(chatCompletionService.isAgentsReady()).thenReturn(false); + + ICompletionProposal[] proposals = processor.createCopilotCompletionAgentProposals("con"); + + assertEquals(1, proposals.length); + assertEquals("@console", proposals[0].getDisplayString()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java new file mode 100644 index 00000000..5d9b9573 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java @@ -0,0 +1,737 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.SensitiveFileData; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileOperationConfirmationHandlerTests { + + private static final String CONV_ID = "conv-1"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private AttachedFileRegistry attachedFileRegistry; + private FileOperationConfirmationHandler handler; + + @BeforeEach + void setUp() { + attachedFileRegistry = new AttachedFileRegistry(); + handler = new FileOperationConfirmationHandler( + preferenceStore, attachedFileRegistry); + } + + // --- glob matching behavior (tested via evaluate + rules) --- + + @Test + void evaluate_globExactPathMatchCaseInsensitive() { + // Rule uses forward slash + lowercase; evaluate uses backslash + uppercase + stubRules(List.of( + new FileOperationAutoApproveRule("C:/Users/test.java", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("C:\\Users\\test.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globStarStarPatternMatches() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globPatternNoMatch() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + assertFalse(evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globBackslashPathNormalized() { + // Rule uses forward slashes; file path uses backslashes + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("C:\\project\\.github\\instructions\\file.md", false), + CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_invalidGlobRuleFallsThrough() { + // Invalid glob should not match; falls through to unmatched setting + stubRules(List.of( + new FileOperationAutoApproveRule("[invalid", "", true))); + stubUnmatched(true); + + assertTrue(evaluate( + buildParams("/a/b.java", false), CONV_ID).isAutoApproved()); + } + + // --- evaluate: attached files --- + + @Test + void evaluate_autoApprovedWhenFileAttachedViaPending() { + attachedFileRegistry.addPending(List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenFileAttachedToConversation() { + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_attachedFilePathNormalized() { + // Attached with backslashes + uppercase + attachedFileRegistry.addPending( + List.of("C:\\Workspace\\Src\\Main.java")); + + // Evaluate with forward slashes + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: session overrides --- + + @Test + void evaluate_autoApprovedBySessionFileApproval() { + // Cache a file-level session approval + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + handler.cacheDecision(action, params, CONV_ID); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFileApprovalNormalizesPath() { + // Cache with backslash + uppercase + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "C:\\Workspace\\Main.java")); + handler.cacheDecision(action, + buildParams("C:\\Workspace\\Main.java", false), CONV_ID); + + // Evaluate with forward slash + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedBySessionFolderApproval() { + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/home/user/external/data.csv", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFolderDoesNotMatchParentPath() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + // File in a different folder (prefix but not under the folder) + InvokeClientToolConfirmationParams params = + buildParams("/home/user/external-other/file.txt", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionApprovalDoesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, "other-conv").isAutoApproved()); + } + + // --- evaluate: outside workspace --- + + @Test + void evaluate_outsideWorkspaceAlwaysRequiresConfirmation() { + InvokeClientToolConfirmationParams params = + buildParams("/tmp/secret.txt", true); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_outsideWorkspaceStillAutoApprovedBySessionFolder() { + // Session folder approval overrides the outside-workspace check + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/tmp")); + handler.cacheDecision(action, + buildParams("/tmp/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/other.txt", true); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: rule matching --- + + @Test + void evaluate_autoApprovedByAllowRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationByDenyRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/**/*", "", false))); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/.github/instructions/rules.md", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + assertNotNull(result.getContent()); + } + + @Test + void evaluate_firstMatchingRuleWins() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", false), + new FileOperationAutoApproveRule("**/*", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + // The first rule (deny .java) should win + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: unmatched fallback --- + + @Test + void evaluate_unmatchedAutoApprovedWhenCheckboxTrue() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_unmatchedNeedsConfirmationWhenCheckboxFalse() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_emptyRulesUsesUnmatchedSetting() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: blank file path --- + + @Test + void evaluate_blankFilePathNeedsConfirmation() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(null, false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: file path extraction --- + + @Test + void evaluate_extractsFilePathFromSensitiveFileData() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // Path set via sensitiveFileData (toolMetadata), not input map + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsFilePathFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // No toolMetadata, only input map with "filePath" + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new HashMap<>(); + input.put("filePath", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsPathKeyFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new HashMap<>(); + input.put("path", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- cacheDecision: global rule --- + + @Test + void cacheDecision_globalAddsRuleToPreferenceStore() { + stubRules(List.of()); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + // The handler should have called setValue on the preference store. + // We verify by loading rules from the same store (which requires + // the mock to return the updated value). Instead, verify the + // store was called with the right key. + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_globalUpdatesExistingRuleCaseInsensitive() { + // Start with a deny rule + stubRules(List.of( + new FileOperationAutoApproveRule( + "C:/workspace/Main.java", "", false))); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "c:/workspace/Main.java")); + + handler.cacheDecision(action, + buildParams("c:/workspace/Main.java", false), CONV_ID); + + // Verify setValue was called (updated existing rule to autoApprove) + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_ignoresUnknownAction() { + Map meta = Map.of( + ConfirmationAction.META_ACTION, "UNKNOWN_ACTION"); + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + @Test + void cacheDecision_ignoresNullActionMetadata() { + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, Map.of(), false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + // --- clearSession --- + + @Test + void clearSession_removesFileAndFolderApprovals() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction fileAction = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(fileAction, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession("other-conv"); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_clearsAttachedFileRegistry() { + stubRules(List.of()); + stubUnmatched(false); + + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- buildContent: in-workspace actions --- + + @Test + void buildContent_inWorkspaceHasAllowOnceAsPrimary() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + List actions = content.getActions(); + ConfirmationAction first = actions.get(0); + assertTrue(first.isPrimary()); + assertTrue(first.isAccept()); + assertEquals(ConfirmationActionScope.ONCE, first.getScope()); + } + + @Test + void buildContent_inWorkspaceHasSkipAsDismiss() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + ConfirmationAction last = actions.get(actions.size() - 1); + assertFalse(last.isAccept()); + } + + @Test + void buildContent_inWorkspaceHasFileSessionAndGlobalActions() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFileSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertTrue(hasFileSession); + assertTrue(hasFileGlobal); + } + + @Test + void buildContent_inWorkspaceNoFolderAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertFalse(hasFolderSession); + } + + // --- buildContent: outside-workspace actions --- + + @Test + void buildContent_outsideWorkspaceHasFolderSessionAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertTrue(hasFolderSession); + } + + @Test + void buildContent_outsideWorkspaceNoFileGlobalAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertFalse(hasFileGlobal); + } + + // --- buildContent: action scopes --- + + @Test + void buildContent_actionScopesAreCorrect() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + + // Session actions have SESSION scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + + // Global actions have GLOBAL scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + // --- evaluate priority order --- + + @Test + void evaluate_priorityOrder_attachedFileBeatsGlobalDenyRule() { + // Attached file auto-approves even when a deny rule would otherwise apply + attachedFileRegistry.addPending( + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionApprovalBeatsGlobalDenyRule() { + // Session-level approval auto-approves even when a deny rule would otherwise apply + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionFolderBeatsOutsideWorkspace() { + // Session folder approval auto-approves even for outside-workspace files + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/external/dir")); + handler.cacheDecision(action, + buildParams("/external/dir/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/external/dir/another.txt", true); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- Helpers --- + + private void stubRules(List rules) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES)) + .thenReturn(GSON.toJson(rules)); + } + + private void stubUnmatched(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)).thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String filePath, boolean isGlobal) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + + if (filePath != null) { + SensitiveFileData sfd = new SensitiveFileData(); + sfd.setFilePath(filePath); + sfd.setGlobal(isGlobal); + + ToolMetadata meta = new ToolMetadata(); + meta.setSensitiveFileData(sfd); + params.setToolMetadata(meta); + } + + Map input = new HashMap<>(); + input.put("toolType", "file_write"); + if (filePath != null) { + input.put("filePath", filePath); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + FileOperationConfirmationHandler.Action actionType, + Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, actionType.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasActionType(ConfirmationAction action, + FileOperationConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java new file mode 100644 index 00000000..4380439b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class McpConfirmationHandlerTests { + + private static final String CONV_ID = "conv-mcp-1"; + private static final String SERVER = "myServer"; + private static final String TOOL = "myTool"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private McpConfirmationHandler handler; + + @BeforeEach + void setUp() { + handler = new McpConfirmationHandler(preferenceStore); + stubGlobalServers(List.of()); + stubGlobalTools(List.of()); + stubTrustAnnotations(false); + } + + // --- evaluate: global server list --- + + @Test + void evaluate_autoApprovedWhenServerInGlobalList() { + stubGlobalServers(List.of(SERVER)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerInGlobalListCaseInsensitive() { + stubGlobalServers(List.of(SERVER.toUpperCase())); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenServerNotInGlobalList() { + stubGlobalServers(List.of("otherServer")); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: global tool list --- + + @Test + void evaluate_autoApprovedWhenToolInGlobalList() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenToolInGlobalListCaseInsensitive() { + String toolKey = SERVER.toUpperCase() + "::" + TOOL.toUpperCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL.toLowerCase()), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenOnlyOtherToolInGlobalList() { + String otherKey = SERVER.toLowerCase() + "::otherTool"; + stubGlobalTools(List.of(otherKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: trust annotations --- + + @Test + void evaluate_autoApprovedWhenReadOnlyAndTrustAnnotationsEnabled() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButOpenWorldHint() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(true); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenAnnotationsTrustedButNotReadOnly() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(false); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButTrustAnnotationsDisabled() { + stubTrustAnnotations(false); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: session approvals --- + + @Test + void evaluate_autoApprovedWhenToolApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenToolApprovedForDifferentSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, "other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + // --- cacheDecision: global persistence --- + + @Test + void cacheDecision_acceptToolGlobal_writesToPreferenceStore() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_MCP_TOOLS), + captor.capture()); + assertTrue(captor.getValue().contains(toolKey)); + } + + @Test + void cacheDecision_acceptServerGlobal_writesToPreferenceStore() { + stubGlobalServers(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_SERVERS), + captor.capture()); + assertTrue(captor.getValue().contains(SERVER)); + } + + @Test + void cacheDecision_acceptToolGlobal_noDuplicateWrite() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + // setValue should NOT be called — key already present + verify(preferenceStore, org.mockito.Mockito.never()) + .setValue(org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_TOOLS), + org.mockito.ArgumentMatchers.anyString()); + } + + // --- clearSession --- + + @Test + void clearSession_removesSessionApprovalsForConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession(CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertFalse(result.isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession("other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + // --- buildContent (actions) --- + + @Test + void buildContent_hasAllowOnceAsFirstAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertTrue(actions.get(0).isPrimary()); + assertTrue(actions.get(0).isAccept()); + assertEquals(ConfirmationActionScope.ONCE, actions.get(0).getScope()); + } + + @Test + void buildContent_hasSkipAsLastAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertFalse(actions.get(actions.size() - 1).isAccept()); + } + + @Test + void buildContent_hasAllFourScopedActions() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)); + } + + @Test + void buildContent_toolAndServerActionsHaveCorrectScopes() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List actions = result.getContent().getActions(); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + @Test + void buildContent_contentHasTitleWithToolAndServer() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + assertNotNull(content.getTitle()); + assertTrue(content.getTitle().contains(TOOL)); + assertTrue(content.getTitle().contains(SERVER)); + } + + @Test + void buildContent_noActionsWhenServerAndToolNull() { + InvokeClientToolConfirmationParams params = + buildParams(null, null); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List actions = result.getContent().getActions(); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + } + + // --- Helpers --- + + private void stubGlobalServers(List servers) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_SERVERS)) + .thenReturn(GSON.toJson(servers)); + } + + private void stubGlobalTools(List tools) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_TOOLS)) + .thenReturn(GSON.toJson(tools)); + } + + private void stubTrustAnnotations(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) + .thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String serverName, String toolName) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map input = new java.util.HashMap<>(); + input.put("toolType", "mcp_tool"); + if (serverName != null) { + input.put("mcpServerName", serverName); + } + if (toolName != null) { + input.put("mcpToolName", toolName); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + McpConfirmationHandler.Action type, Map extra) { + Map meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasAction(List actions, + McpConfirmationHandler.Action type) { + return actions.stream().anyMatch(a -> hasAction(a, type)); + } + + private static boolean hasAction(ConfirmationAction action, + McpConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java new file mode 100644 index 00000000..5cb11e3e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java @@ -0,0 +1,650 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.TerminalCommandData; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TerminalConfirmationHandlerTests { + + private static final String CONV_ID = "conv-1"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private TerminalConfirmationHandler handler; + + @BeforeEach + void setUp() { + handler = new TerminalConfirmationHandler(preferenceStore); + } + + // --- rule matching (tested through evaluate) --- + + @Test + void ruleMatching_simpleRuleMatchesCommandAtStart() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"rm -rf /tmp"}, new String[]{"rm"}, + "rm -rf /tmp"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_simpleRuleDoesNotMatchMiddleOfWord() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"remove something"}, + new String[]{"remove"}, "remove something"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_regexCaseInsensitive() { + stubRules(List.of(new TerminalAutoApproveRule("/^git\\b/i", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"Git status"}, new String[]{"Git"}, + "Git status"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_regexDotallMatchesSubshell() { + stubRules(List.of( + new TerminalAutoApproveRule("/(\\(.+\\))/s", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"(echo hello)"}, + new String[]{"(echo"}, "(echo hello)"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandsNull() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(null, null, "rm -rf"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandsEmpty() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{}, new String[]{}, "rm -rf"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandBlank() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{" "}, new String[]{" "}, " "); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate --- + + @Test + void evaluate_autoApprovedWhenAllSubCommandsMatchAllowRules() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenDenyRuleMatches() { + stubRules(List.of(new TerminalAutoApproveRule("rm", false))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"rm -rf /"}, new String[]{"rm"}, + "rm -rf /"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + assertNotNull(result.getContent()); + } + + @Test + void evaluate_needsConfirmationWhenNoRulesMatchAndUnmatchedFalse() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls -la"}, new String[]{"ls"}, + "ls -la"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenNoRulesMatchAndUnmatchedTrue() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls -la"}, new String[]{"ls"}, + "ls -la"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenSubCommandsNull() { + stubRules(List.of()); + + InvokeClientToolConfirmationParams params = + buildParams(null, null, "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenSubCommandsEmpty() { + stubRules(List.of()); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{}, null, "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_emptyRulesUsesUnmatchedSetting() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls"}, new String[]{"ls"}, "ls"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + // --- Session memory via cacheDecision --- + + @Test + void cacheDecision_acceptAllSession_autoApprovesSubsequent() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void cacheDecision_acceptNamesSession_autoApprovesMatchingNames() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction namesSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION); + handler.cacheDecision(namesSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void cacheDecision_acceptExactSession_autoApprovesMatchingCommand() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction exactSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION); + handler.cacheDecision(exactSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void clearSession_removesApprovalsForConversation() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + handler.clearSession(CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertFalse(result.isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + handler.clearSession("other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + // --- buildContent actions --- + + @Test + void buildContent_alwaysHasAllowOnceAsPrimary() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + List actions = content.getActions(); + ConfirmationAction first = actions.get(0); + assertTrue(first.isPrimary()); + assertTrue(first.isAccept()); + } + + @Test + void buildContent_alwaysHasSkipAsDismiss() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + ConfirmationAction last = actions.get(actions.size() - 1); + assertFalse(last.isAccept()); + } + + @Test + void buildContent_hasAllowAllSessionAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasAllSession = actions.stream().anyMatch(a -> + a.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && a.getMetadata().get(ConfirmationAction.META_ACTION) + .equals( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION + .name())); + assertTrue(hasAllSession); + } + + @Test + void buildContent_hasCommandNameActionsWhenNamesPresent() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasNamesSession = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)); + boolean hasNamesGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_GLOBAL)); + assertTrue(hasNamesSession); + assertTrue(hasNamesGlobal); + } + + @Test + void buildContent_hasExactCommandActionsWhenDifferentFromName() { + stubRules(List.of()); + stubUnmatched(false); + + // commandLine "echo hello" differs from single commandName "echo" + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasExactSession = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION)); + boolean hasExactGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)); + assertTrue(hasExactSession); + assertTrue(hasExactGlobal); + } + + @Test + void buildContent_noExactActionsWhenSingleSubCommandEqualsName() { + stubRules(List.of()); + stubUnmatched(false); + + // commandLine equals the single commandName + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + boolean hasExact = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)); + assertFalse(hasExact); + } + + @Test + void buildContent_actionScopesAreCorrect() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List actions = result.getContent().getActions(); + + // Allow Once → ONCE scope + assertEquals(ConfirmationActionScope.ONCE, actions.get(0).getScope()); + + // Session actions have SESSION scope + actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION)) + .forEach(a -> assertEquals(ConfirmationActionScope.SESSION, + a.getScope())); + + // Global actions have GLOBAL scope + actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_GLOBAL) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)) + .forEach(a -> assertEquals(ConfirmationActionScope.GLOBAL, + a.getScope())); + } + + // --- Helpers --- + + private void stubRules(List rules) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES)) + .thenReturn(GSON.toJson(rules)); + } + + private void stubUnmatched(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)).thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String[] subCommands, String[] commandNames, String commandLine) { + TerminalCommandData tcd = new TerminalCommandData(); + tcd.setSubCommands(subCommands); + tcd.setCommandNames(commandNames); + + ToolMetadata meta = new ToolMetadata(); + meta.setTerminalCommandData(tcd); + + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + params.setToolMetadata(meta); + params.setInput(Map.of("toolType", "terminal", "command", + commandLine != null ? commandLine : "")); + return params; + } + + private static ConfirmationAction buildSessionAction( + TerminalConfirmationHandler.Action actionType) { + Map meta = Map.of( + ConfirmationAction.META_ACTION, actionType.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasActionType(ConfirmationAction action, + TerminalConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } + + // --- Unapproved filtering tests --- + + @Test + void buildContent_filtersSessionApprovedNamesFromActions() { + stubRules(List.of()); + stubUnmatched(false); + + // "echo && curl" — approve echo in session first + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, CONV_ID); + + // Now evaluate "echo hello && curl example.com" + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "curl example.com"}, + new String[]{"echo", "curl"}, + "echo hello && curl example.com"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List actions = result.getContent().getActions(); + + // Command-name actions should only mention "curl", not "echo" + ConfirmationAction namesAction = actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)) + .findFirst().orElse(null); + assertNotNull(namesAction); + assertTrue(namesAction.getLabel().contains("curl")); + assertFalse(namesAction.getLabel().contains("echo")); + } + + @Test + void buildContent_filtersGlobalApprovedNamesFromActions() { + // Global allow rule for "echo" + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + // Evaluate "echo hello && hostname" + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "hostname"}, + new String[]{"echo", "hostname"}, + "echo hello && hostname"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List actions = result.getContent().getActions(); + + // "echo" is globally allowed — only "hostname" in actions + ConfirmationAction namesAction = actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)) + .findFirst().orElse(null); + assertNotNull(namesAction); + assertTrue(namesAction.getLabel().contains("hostname")); + assertFalse(namesAction.getLabel().contains("echo")); + } + + @Test + void buildContent_allNamesApproved_autoApproves() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + // Session-approve "curl" + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"curl x"}, new String[]{"curl"}, + "curl x"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, CONV_ID); + + // Evaluate "echo hello && curl example.com" + // echo = global allow, curl = session allow → all approved + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "curl example.com"}, + new String[]{"echo", "curl"}, + "echo hello && curl example.com"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void sessionRules_surviveConversationSwitch() { + // Approve "echo" in conversation A + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, "conv-A"); + + // Switch to conversation B, then back to A — rule should survive + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo world"}, new String[]{"echo"}, + "echo world"); + ConfirmationResult result = evaluate(params, "conv-A"); + assertTrue(result.isAutoApproved()); + } + + @Test + void sessionRules_evictOldestWhenCapExceeded() { + // Fill up to MAX_SESSION_CONVERSATIONS with unique conversation IDs + for (int i = 0; i < TerminalConfirmationHandler.MAX_SESSION_CONVERSATIONS; + i++) { + InvokeClientToolConfirmationParams p = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + p, "conv-" + i); + } + + // Add one more — should evict the oldest (conv-0) + InvokeClientToolConfirmationParams p = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + p, "conv-new"); + + // conv-0 should have been evicted + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo test"}, new String[]{"echo"}, + "echo test"); + ConfirmationResult evicted = evaluate(params, "conv-0"); + assertFalse(evicted.isAutoApproved()); + + // conv-new should still work + ConfirmationResult kept = evaluate(params, "conv-new"); + assertTrue(kept.isAutoApproved()); + + // conv-1 (second oldest, not evicted) should still work + ConfirmationResult second = evaluate(params, "conv-1"); + assertTrue(second.isAutoApproved()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java index 79f9c3df..3c80a6be 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java @@ -20,6 +20,7 @@ import org.eclipse.ui.PlatformUI; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -44,6 +45,7 @@ class ChatCompletionServiceTest { private static ChatCompletionService chatCompletionService; private static MockedStatic copilotUiMock; private static MockedStatic platformUiMock; + private static IPreferenceStore mockPreferenceStore; @BeforeAll static void setUp() { @@ -53,7 +55,7 @@ static void setUp() { // Mock CopilotUi.getPlugin() so the constructor can register its preference listener CopilotUi mockPlugin = mock(CopilotUi.class); - IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + mockPreferenceStore = mock(IPreferenceStore.class); LanguageServerSettingManager mockSettingManager = mock(LanguageServerSettingManager.class); when(mockPlugin.getLanguageServerSettingManager()).thenReturn(mockSettingManager); when(mockPlugin.getPreferenceStore()).thenReturn(mockPreferenceStore); @@ -82,6 +84,11 @@ static void setUp() { } } + @BeforeEach + void resetConsoleContextPreference() { + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(false); + } + @AfterAll static void tearDown() { if (chatCompletionService != null) { @@ -122,4 +129,34 @@ void testIsCommand() { void testGetFilteredTemplates() { assertNotNull(chatCompletionService.getFilteredTemplates(ChatMode.Ask)); } -} \ No newline at end of file + + @Test + void testConsoleContextCommandDisabledByDefault() { + assertFalse(chatCompletionService.isCommand("@console", "Ask")); + } + + @Test + void testConsoleContextCommandEnabledForBuiltInModes() { + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(true); + + assertTrue(chatCompletionService.isCommand("@console", "Ask")); + assertTrue(chatCompletionService.isCommand("@console", "Agent")); + assertTrue(chatCompletionService.isCommand("@console", "Plan")); + } + + @Test + void testConsoleContextCommandUnavailableForCustomAgents() { + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(true); + + assertFalse(chatCompletionService.isCommand("@console", "file:///workspace/.github/agents/custom.agent.md")); + } + + @Test + void testConsoleContextCommandUnavailableWhenModeIsNull() { + // When the active mode is null (unknown), @console should not be available even if pref is enabled, + // since we cannot verify that the mode supports it. + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(true); + + assertFalse(chatCompletionService.isCommand("@console", null)); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessorTest.java new file mode 100644 index 00000000..4530bfbb --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessorTest.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextPromptProcessor.ProcessedMessage; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService.ConsoleSnapshot; + +class ConsoleContextPromptProcessorTest { + + @Test + void processAddsConsoleContextWhenLeadingCommandIsEnabledAndSupported() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain the failure", true, true, + () -> ConsoleSnapshot.available("Maven", "BUILD FAILURE", false)); + + assertTrue(result.consoleContextRequested()); + assertTrue(result.serverMessage().startsWith("explain the failure")); + assertTrue(result.serverMessage().contains("[Console Context]")); + assertTrue(result.serverMessage().contains("Console: Maven")); + assertTrue(result.serverMessage().contains("Truncated: no")); + assertTrue(result.serverMessage().contains("BUILD FAILURE")); + assertTrue(result.serverMessage().contains("[Maven Build Summary]")); + assertFalse(result.serverMessage().contains("@console")); + } + + @Test + void processUsesMavenParserWhenConsoleNameContainsMaven() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console check build", true, true, + () -> ConsoleSnapshot.available("Maven Build", "[INFO] BUILD SUCCESS", false)); + + assertTrue(result.serverMessage().contains("[Maven Build Summary]")); + assertTrue(result.serverMessage().contains("Result: BUILD SUCCESS")); + assertFalse(result.serverMessage().contains("[Mule Error Summary]")); + } + + @Test + void processUsesMuleParserForNonMavenConsoleName() { + String muleExceptionOutput = "org.mule.runtime.core.internal.exception.MessagingException\n" + + "error type: EXPRESSION:INVALID_EXPRESSION\nFlow name: myFlow"; + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console check error", true, true, + () -> ConsoleSnapshot.available("Mule Application", muleExceptionOutput, false)); + + assertTrue(result.serverMessage().contains("[Mule Error Summary]")); + assertFalse(result.serverMessage().contains("[Maven Build Summary]")); + } + + @Test + void processOnlyConsumesLeadingConsoleCommand() { + AtomicBoolean supplierCalled = new AtomicBoolean(false); + + ProcessedMessage result = ConsoleContextPromptProcessor.process("please inspect @console output", true, true, () -> { + supplierCalled.set(true); + return ConsoleSnapshot.available("Console", "output", false); + }); + + assertFalse(result.consoleContextRequested()); + assertEquals("please inspect @console output", result.serverMessage()); + assertFalse(supplierCalled.get()); + } + + @Test + void processDoesNotMatchWhenCommandFollowedByNonWhitespace() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console-output explain", true, true, + () -> ConsoleSnapshot.available("Console", "output", false)); + + assertFalse(result.consoleContextRequested()); + assertEquals("@console-output explain", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenFeatureDisabled() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", false, true, + () -> ConsoleSnapshot.available("Console", "output", false)); + + assertFalse(result.consoleContextRequested()); + assertEquals("@console explain", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenModeUnsupported() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", true, false, + () -> ConsoleSnapshot.available("Console", "output", false)); + + assertFalse(result.consoleContextRequested()); + assertEquals("@console explain", result.serverMessage()); + } + + @Test + void processAddsUnavailableNoteInsteadOfFailing() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", true, true, + () -> ConsoleSnapshot.unavailable("No active console is selected.")); + + assertTrue(result.consoleContextRequested()); + assertTrue(result.serverMessage().contains("Console context unavailable: No active console is selected.")); + } + + @Test + void processAddsEmptyOutputNote() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", true, true, + () -> ConsoleSnapshot.available("Console", "", false)); + + assertTrue(result.consoleContextRequested()); + assertTrue(result.serverMessage().contains("Output: Console output is empty.")); + } + + @Test + void processHandlesConsoleCommandAloneWithNoPrompt() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console", true, true, + () -> ConsoleSnapshot.available("Build", "BUILD FAILURE", false)); + + assertTrue(result.consoleContextRequested()); + // After stripping @console, the prompt is empty, so the server message is just the context block + assertTrue(result.serverMessage().contains("[Console Context]")); + assertTrue(result.serverMessage().contains("Console: Build")); + assertTrue(result.serverMessage().contains("BUILD FAILURE")); + // No leading user prompt, just the context block + assertTrue(result.serverMessage().startsWith("[Console Context]")); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextServiceTest.java new file mode 100644 index 00000000..40bf7a87 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextServiceTest.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import org.eclipse.ui.console.IConsole; +import org.eclipse.ui.console.MessageConsole; +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService.ConsoleSnapshot; + +class ConsoleContextServiceTest { + + @Test + void captureConsoleReturnsTextConsoleOutput() { + MessageConsole console = new MessageConsole("Build", null); + console.getDocument().set("line 1\nline 2"); + + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(console, + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertTrue(snapshot.isAvailable()); + assertEquals("Build", snapshot.consoleName()); + assertEquals("line 1\nline 2", snapshot.output()); + assertFalse(snapshot.truncated()); + } + + @Test + void captureConsoleReturnsEmptySnapshotForEmptyTextConsole() { + MessageConsole console = new MessageConsole("Empty", null); + console.getDocument().set(""); + + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(console, + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertTrue(snapshot.isAvailable()); + assertTrue(snapshot.isEmpty()); + assertFalse(snapshot.truncated()); + } + + @Test + void captureConsoleTruncatesAtLineBoundary() { + MessageConsole console = new MessageConsole("Long", null); + console.getDocument().set("line 1\nline 2\nline 3"); + + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(console, 10); + + assertTrue(snapshot.isAvailable()); + assertEquals("line 3", snapshot.output()); + assertTrue(snapshot.truncated()); + } + + @Test + void captureConsoleReturnsUnavailableForMissingConsole() { + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(null, + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertFalse(snapshot.isAvailable()); + assertTrue(snapshot.unavailableReason().contains("No active console")); + } + + @Test + void captureConsoleReturnsUnavailableForNonTextConsole() { + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(mock(IConsole.class), + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertFalse(snapshot.isAvailable()); + assertTrue(snapshot.unavailableReason().contains("not text-backed")); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParserTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParserTest.java new file mode 100644 index 00000000..1e92920e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParserTest.java @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class MavenConsoleParserTest { + + @Test + void enrich_returnsOriginalWhenNoMavenOutput() { + String input = "some generic console output\nno maven markers here"; + assertEquals(input, MavenConsoleParser.enrich(input)); + } + + @Test + void enrich_returnsNullForNullInput() { + assertNull(MavenConsoleParser.enrich(null)); + } + + @Test + void enrich_returnsBlankForBlankInput() { + assertEquals(" ", MavenConsoleParser.enrich(" ")); + } + + @Test + void enrich_prependsSummaryForBuildSuccess() { + String input = "[INFO] Scanning for projects...\n[INFO] BUILD SUCCESS\n[INFO] Total time: 2.5 s"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.startsWith("[Maven Build Summary]")); + assertTrue(result.contains("Result: BUILD SUCCESS")); + assertTrue(result.contains("Errors: 0")); + assertTrue(result.contains(input)); + } + + @Test + void enrich_prependsSummaryForBuildFailure() { + String input = "[INFO] Scanning for projects...\n[ERROR] Compilation failure\n[INFO] BUILD FAILURE"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.startsWith("[Maven Build Summary]")); + assertTrue(result.contains("Result: BUILD FAILURE")); + assertTrue(result.contains("Errors: 1")); + assertTrue(result.contains(input)); + } + + @Test + void enrich_countsMultipleErrorsAndWarnings() { + String input = "[ERROR] src/Foo.java:10: error\n" + + "[ERROR] src/Bar.java:20: error\n" + + "[WARNING] deprecated API\n" + + "[WARNING] unchecked cast\n" + + "[WARNING] unused import\n" + + "[INFO] BUILD FAILURE"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.contains("Errors: 2")); + assertTrue(result.contains("Warnings: 3")); + } + + @Test + void enrich_isCaseInsensitiveForMarkers() { + String input = "[info] Scanning...\n[error] Compilation failure\nbuild failure"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.startsWith("[Maven Build Summary]")); + assertTrue(result.contains("Errors: 1")); + } + + @Test + void enrich_doesNotDoubleWrapAlreadyEnrichedOutput() { + String input = "[INFO] BUILD SUCCESS"; + String enrichedOnce = MavenConsoleParser.enrich(input); + + assertTrue(enrichedOnce.startsWith("[Maven Build Summary]")); + assertFalse(enrichedOnce.substring(1).contains("[Maven Build Summary]")); + } + + @Test + void enrich_includesRawOutputAfterSummary() { + String input = "[INFO] BUILD SUCCESS\n[INFO] Total time: 1 s"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.contains(input)); + int summaryEnd = result.indexOf(input); + assertTrue(result.substring(0, summaryEnd).contains("[Maven Build Summary]")); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessorTest.java new file mode 100644 index 00000000..77b90f4f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessorTest.java @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextPromptProcessor.ProcessedMessage; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.ScriptEntry; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.TransformEntry; + +class TransformEditorContextPromptProcessorTest { + + private static TransformEditorSnapshot singleTransformSnapshot() { + ScriptEntry payloadScript = new ScriptEntry("payload", "application/json", + "%dw 2.0\noutput application/json\n---\n{ id: payload.customerId }"); + TransformEntry entry = new TransformEntry("MyTransform [id=abc-123]", "MyTransform", "abc-123", + List.of(payloadScript)); + return TransformEditorSnapshot.available("/project/src/main/mule/main.xml", 1, List.of(entry), false); + } + + @Test + void processAddsTransformContextWhenLeadingCommandIsEnabledAndSupported() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform help me improve this mapping", true, true, () -> singleTransformSnapshot()); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().startsWith("help me improve this mapping")); + assertTrue(result.serverMessage().contains("[Transform Context]")); + assertTrue(result.serverMessage().contains("file: /project/src/main/mule/main.xml")); + assertTrue(result.serverMessage().contains("transforms: 1")); + assertTrue(result.serverMessage().contains("--- Transform: MyTransform [id=abc-123] ---")); + assertTrue(result.serverMessage().contains("target: payload")); + assertTrue(result.serverMessage().contains("outputType: application/json")); + assertTrue(result.serverMessage().contains("{ id: payload.customerId }")); + assertFalse(result.serverMessage().contains("@transform")); + } + + @Test + void processOnlyConsumesLeadingTransformCommand() { + AtomicBoolean supplierCalled = new AtomicBoolean(false); + + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "please look at @transform output", true, true, () -> { + supplierCalled.set(true); + return singleTransformSnapshot(); + }); + + assertFalse(result.transformContextRequested()); + assertEquals("please look at @transform output", result.serverMessage()); + assertFalse(supplierCalled.get()); + } + + @Test + void processDoesNotMatchWhenCommandFollowedByNonWhitespace() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform-mapper improve", true, true, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("@transform-mapper improve", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenFeatureDisabled() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", false, true, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("@transform explain", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenModeUnsupported() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", true, false, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("@transform explain", result.serverMessage()); + } + + @Test + void processAddsUnavailableNoteInsteadOfFailing() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", true, true, + () -> TransformEditorSnapshot.unavailable("No active Mule XML editor is open.")); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage() + .contains("Transform context unavailable: No active Mule XML editor is open.")); + } + + @Test + void processAddsEmptyTransformNote() { + TransformEditorSnapshot emptySnapshot = TransformEditorSnapshot.available( + "/project/src/main/mule/main.xml", 0, List.of(), false); + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", true, true, () -> emptySnapshot); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("No ee:transform elements found in this file.")); + } + + @Test + void processHandlesTransformCommandAloneWithNoPrompt() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform", true, true, () -> singleTransformSnapshot()); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("[Transform Context]")); + assertTrue(result.serverMessage().contains("MyTransform")); + // No leading user prompt; context block starts the server message + assertTrue(result.serverMessage().startsWith("[Transform Context]")); + } + + @Test + void processTruncatedFlagAppearsInOutput() { + ScriptEntry payloadScript = new ScriptEntry("payload", "application/json", "...large script..."); + TransformEntry entry = new TransformEntry("T", "T", "t1", List.of(payloadScript)); + TransformEditorSnapshot truncatedSnapshot = TransformEditorSnapshot.available( + "/project/src/main/mule/main.xml", 1, List.of(entry), true); + + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform check this", true, true, () -> truncatedSnapshot); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("truncated due to length")); + } + + @Test + void processAutoInject_appendsContextWhenEnabledAndSnapshotAvailable() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, true, () -> singleTransformSnapshot()); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("explain the error")); + assertTrue(result.serverMessage().contains("[Transform Context]")); + assertTrue(result.serverMessage().contains("target: payload")); + assertTrue(result.serverMessage().contains("@transform") == false); + } + + @Test + void processAutoInject_doesNotInjectWhenDisabled() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", false, true, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + } + + @Test + void processAutoInject_doesNotInjectWhenModeUnsupported() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, false, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + } + + @Test + void processAutoInject_doesNotInjectWhenSnapshotUnavailable() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, true, + () -> TransformEditorSnapshot.unavailable("No active Mule XML editor is open.")); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + assertFalse(result.serverMessage().contains("unavailable")); + } + + @Test + void processAutoInject_doesNotInjectWhenSnapshotEmpty() { + TransformEditorSnapshot emptySnapshot = TransformEditorSnapshot.available( + "/project/src/main/mule/main.xml", 0, List.of(), false); + + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, true, () -> emptySnapshot); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleAgentToolsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleAgentToolsTest.java index 6288638c..fecca4db 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleAgentToolsTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleAgentToolsTest.java @@ -4,6 +4,7 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Files; @@ -123,6 +124,241 @@ void munitImprovementSuggestionsReturnsCadenceGuidance() throws Exception { assertTrue(output.contains("For each external connector, mock success and failure")); } + @Test + void transformReadMatchesByNameCaseInsensitiveAndSubstring() throws Exception { + Path xml = createMuleProjectWithTransform(false); + + // Case-insensitive match + LanguageModelToolResult[] caseResult = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "map accounts"), null).get(); + assertEquals(ToolInvocationStatus.success, caseResult[0].getStatus()); + assertTrue(caseResult[0].getContent().get(0).getValue().contains("target=payload")); + + // Substring match (partial name) + LanguageModelToolResult[] subResult = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Accounts"), null).get(); + assertEquals(ToolInvocationStatus.success, subResult[0].getStatus()); + assertTrue(subResult[0].getContent().get(0).getValue().contains("target=payload")); + + // Non-matching name still fails + LanguageModelToolResult[] noMatch = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Non Existent Transform"), null).get(); + assertEquals(ToolInvocationStatus.error, noMatch[0].getStatus()); + assertTrue(noMatch[0].getContent().get(0).getValue().contains("No ee:transform element matched")); + } + + @Test + void dwlReadToolReturnsFileContentAndLineCount() throws Exception { + Path dwl = tempDir.resolve("normalize.dwl"); + Files.writeString(dwl, "%dw 2.0\noutput application/json\n---\npayload map (item -> item)"); + + LanguageModelToolResult[] results = new MuleDwlReadTool() + .invoke(Map.of("dwlFilePath", dwl.toString()), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("lines=4")); + assertTrue(output.contains("script:")); + assertTrue(output.contains("%dw 2.0")); + assertTrue(output.contains("payload map")); + } + + @Test + void dwlReadToolRejectsNonDwlFile() throws Exception { + Path xml = tempDir.resolve("test.xml"); + Files.writeString(xml, ""); + + LanguageModelToolResult[] results = new MuleDwlReadTool() + .invoke(Map.of("dwlFilePath", xml.toString()), null).get(); + + assertEquals(ToolInvocationStatus.error, results[0].getStatus()); + assertTrue(results[0].getContent().get(0).getValue().contains(".dwl")); + } + + @Test + void dwlWriteToolWritesScriptToFile() throws Exception { + Path dwl = tempDir.resolve("transform.dwl"); + Files.writeString(dwl, "%dw 2.0\noutput application/json\n---\npayload"); + String newScript = "%dw 2.0\noutput application/json\n---\npayload map (item -> { id: item.id })"; + + LanguageModelToolResult[] results = new MuleDwlWriteTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "dwlScript", newScript), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + assertEquals(newScript, Files.readString(dwl)); + } + + @Test + void dwlOptimizeToolDetectsMissingOutputDirective() throws Exception { + Path dwl = tempDir.resolve("bad.dwl"); + Files.writeString(dwl, "%dw 2.0\n---\npayload"); + + LanguageModelToolResult[] results = new MuleDwlOptimizeTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "includeComments", false, "applyFixes", false), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("missing-output-directive")); + assertTrue(output.contains("output application/json")); + } + + @Test + void dwlOptimizeToolDetectsNestedMapFilter() throws Exception { + Path dwl = tempDir.resolve("nested.dwl"); + Files.writeString(dwl, "%dw 2.0\noutput application/json\n---\npayload.a map (x -> payload.b filter (y -> y.id == x.id))"); + + LanguageModelToolResult[] results = new MuleDwlOptimizeTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "includeComments", false, "applyFixes", false), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("nested-map-filter")); + assertTrue(output.contains("groupBy")); + } + + @Test + void dwlOptimizeToolAppliesFixesWhenRequested() throws Exception { + Path dwl = tempDir.resolve("nocomment.dwl"); + String original = "%dw 2.0\noutput application/json\n---\nfun greet(name) = \"Hello \" ++ name\ngreet(payload.name)"; + Files.writeString(dwl, original); + + LanguageModelToolResult[] results = new MuleDwlOptimizeTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "includeComments", true, "applyFixes", true), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String written = Files.readString(dwl); + assertTrue(written.contains("// greet")); + } + + @Test + void dwlToolsExposeExpectedToolMetadata() { + assertEquals("mule_read_dwl_file", new MuleDwlReadTool().getToolInformation().getName()); + assertEquals("mule_write_dwl_file", new MuleDwlWriteTool().getToolInformation().getName()); + assertEquals("mule_optimize_dwl", new MuleDwlOptimizeTool().getToolInformation().getName()); + assertTrue(new MuleDwlReadTool().getToolInformation().getDescription().contains("read-only")); + assertTrue(new MuleDwlWriteTool().getToolInformation().getDescription().contains("%dw 2.0")); + assertTrue(new MuleDwlOptimizeTool().getToolInformation().getDescription().contains("groupBy")); + } + + @Test + void transformToolsExposeExpectedToolMetadata() { + assertEquals("mule_read_transform", new MuleTransformReadTool().getToolInformation().getName()); + assertEquals("mule_write_transform", new MuleTransformWriteTool().getToolInformation().getName()); + assertTrue(new MuleTransformReadTool().getToolInformation().getDescription().contains("set-attributes")); + assertTrue(new MuleTransformWriteTool().getToolInformation().getDescription().contains("variable:name")); + } + + @Test + void transformReadReturnsPayloadAttributesVariablesAndExternalDwl() throws Exception { + Path xml = createMuleProjectWithTransform(false); + LanguageModelToolResult[] results = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("target=payload")); + assertTrue(output.contains("target=attributes")); + assertTrue(output.contains("target=variable:customerId")); + assertTrue(output.contains("target=variable:externalVar")); + assertTrue(output.contains("resource=dw/external.dwl")); + assertTrue(output.contains("resourceStatus=resolved")); + assertTrue(output.contains("externalValue")); + } + + @Test + void transformWriteUpdatesPayloadAttributesAndVariables() throws Exception { + Path xml = createMuleProjectWithTransform(false); + String payloadScript = """ + %dw 2.0 + output application/json + --- + { updatedPayload: true } + """; + String attributesScript = """ + %dw 2.0 + output application/java + --- + attributes ++ { source: "test" } + """; + String variableScript = """ + %dw 2.0 + output application/java + --- + "updated-variable" + """; + + assertEquals(ToolInvocationStatus.success, new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "payload", "dwlScript", payloadScript), null).get()[0].getStatus()); + assertEquals(ToolInvocationStatus.success, new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "attributes", "dwlScript", attributesScript), null).get()[0].getStatus()); + assertEquals(ToolInvocationStatus.success, new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "variable:customerId", "dwlScript", variableScript), null).get()[0].getStatus()); + + String updated = Files.readString(xml); + assertTrue(updated.contains("updatedPayload")); + assertTrue(updated.contains("attributes ++")); + assertTrue(updated.contains("updated-variable")); + } + + @Test + void transformWriteErrorsDoNotModifyXml() throws Exception { + Path xml = createMuleProjectWithTransform(true); + String original = Files.readString(xml); + String script = """ + %dw 2.0 + output application/json + --- + payload + """; + + LanguageModelToolResult[] ambiguous = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "dwlScript", script), null).get(); + assertEquals(ToolInvocationStatus.error, ambiguous[0].getStatus()); + assertTrue(ambiguous[0].getContent().get(0).getValue().contains("Multiple ee:transform")); + assertEquals(original, Files.readString(xml)); + + LanguageModelToolResult[] missingTransform = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Missing", + "target", "payload", "dwlScript", script), null).get(); + assertEquals(ToolInvocationStatus.error, missingTransform[0].getStatus()); + assertEquals(original, Files.readString(xml)); + + LanguageModelToolResult[] missingTarget = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "missingVar", "dwlScript", script), null).get(); + assertEquals(ToolInvocationStatus.error, missingTarget[0].getStatus()); + assertEquals(original, Files.readString(xml)); + + LanguageModelToolResult[] blankScript = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "payload", "dwlScript", " "), null).get(); + assertEquals(ToolInvocationStatus.error, blankScript[0].getStatus()); + assertEquals(original, Files.readString(xml)); + } + + @Test + void mulesoftAgentAssetsExposeLocalTransformAndMcpTools() throws Exception { + Path repo = findRepoRoot(); + String anypointTemplate = Files.readString( + repo.resolve("com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md")); + String bundledAgent = Files.readString(repo.resolve( + "com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md")); + String munitPrompt = Files.readString(repo.resolve( + "com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md")); + + assertTrue(anypointTemplate.contains("- mule_read_transform")); + assertTrue(anypointTemplate.contains("- mule_write_transform")); + assertTrue(anypointTemplate.contains("- mulesoft/generate_or_modify_munit_test")); + assertFalse(anypointTemplate.contains("generate_or_modify_munit\n")); + assertTrue(bundledAgent.contains("- mule_read_transform")); + assertTrue(bundledAgent.contains("- mule_write_transform")); + assertTrue(bundledAgent.contains("- mulesoft/generate_or_modify_munit_test")); + assertTrue(munitPrompt.contains("- mulesoft/generate_or_modify_munit_test")); + } + private Path createMuleProject() throws Exception { Path project = tempDir.resolve("mule-app"); Files.createDirectories(project.resolve("src/main/mule")); @@ -209,4 +445,79 @@ private Path createMuleProjectWithWeakMunit() throws Exception { """); return project; } + + private Path createMuleProjectWithTransform(boolean includeSecondTransform) throws Exception { + Path project = tempDir.resolve(includeSecondTransform ? "mule-transforms-two" : "mule-transforms-one"); + Files.createDirectories(project.resolve("src/main/mule")); + Files.createDirectories(project.resolve("src/main/resources/dw")); + Files.writeString(project.resolve("pom.xml"), """ + + 4.0.0 + example + mule-transform-app + 1.0.0 + + """); + Files.writeString(project.resolve("src/main/resources/dw/external.dwl"), """ + %dw 2.0 + output application/java + --- + "externalValue" + """); + String secondTransform = includeSecondTransform ? """ + + + + + + """ : ""; + Path xml = project.resolve("src/main/mule/api.xml"); + Files.writeString(xml, """ + + + + + + + + + + + + + ${SECOND_TRANSFORM} + + """.replace("${SECOND_TRANSFORM}", secondTransform)); + return xml; + } + + private Path findRepoRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null) { + if (Files.isDirectory(current.resolve("com.microsoft.copilot.eclipse.anypoint/templates")) + && Files.isDirectory(current.resolve("com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("Unable to locate repository root from test runtime."); + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java index cf9c2db4..ddcc7da4 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java @@ -39,4 +39,13 @@ void testInitializeDefaultPreferences_WhenAutoShowWhatsNewSetToFalse_ShouldRemai boolean autoShowWhatsNew = configPrefs.getBoolean(Constants.AUTO_SHOW_WHAT_IS_NEW, true); assertFalse(autoShowWhatsNew); } -} \ No newline at end of file + + @Test + void testInitializeDefaultPreferences_ShouldDisableConsoleContextByDefault() { + initializer.initializeDefaultPreferences(); + + boolean consoleContextEnabled = CopilotUi.getPlugin().getPreferenceStore() + .getDefaultBoolean(Constants.CONSOLE_CONTEXT_ENABLED); + assertFalse(consoleContextEnabled); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index 5843ccfe..2892f521 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java @@ -14,6 +14,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.LinkedHashMap; + import org.eclipse.core.net.proxy.IProxyData; import org.eclipse.core.net.proxy.IProxyService; import org.eclipse.jface.preference.IPreferenceStore; @@ -23,6 +25,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; @@ -33,6 +37,7 @@ import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class LanguageServerSettingManagerTests { @Mock private IPreferenceStore mockPreferenceStore; @@ -53,6 +58,10 @@ void testNoProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -83,6 +92,10 @@ void testBasicProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -121,6 +134,10 @@ void testUpdateConfigShouldBeCalledWhenWorkspaceInstructionsEnabledWithContent() settings.getGithubSettings().setCopilotSettings(copilotSettings); settings.getGithubSettings().getCopilotSettings().getAgent() .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -152,6 +169,10 @@ void testUpdateConfigShouldBeCalledWithoutInstructionWhenWorkspaceInstructionsDi expectedSettings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); expectedParams.setSettings(expectedSettings); // act diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index 95000bb5..39975d00 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -7,6 +7,7 @@ Bundle-Vendor: GitHub Copilot Bundle-Localization: plugin Export-Package: com.microsoft.copilot.eclipse.ui, com.microsoft.copilot.eclipse.ui.chat, + com.microsoft.copilot.eclipse.ui.chat.confirmation;x-friends:="com.microsoft.copilot.eclipse.ui.test", com.microsoft.copilot.eclipse.ui.chat.services, com.microsoft.copilot.eclipse.ui.chat.tools, com.microsoft.copilot.eclipse.ui.completion, diff --git a/com.microsoft.copilot.eclipse.ui/css/dark.css b/com.microsoft.copilot.eclipse.ui/css/dark.css index a20c0faa..86541db2 100644 --- a/com.microsoft.copilot.eclipse.ui/css/dark.css +++ b/com.microsoft.copilot.eclipse.ui/css/dark.css @@ -80,6 +80,16 @@ color: #A4A4A4; } +StyledText.chat-input-text { + color: #DEE1E5; + background-color: #1E1F22; +} + +Text.copilot-preference-text-input { + color: #FFFFFF; + background-color: #1E1F22; +} + #chat-container > HandoffContainer, #chat-container > HandoffContainer * { background-color: #1E1F22; diff --git a/com.microsoft.copilot.eclipse.ui/css/light.css b/com.microsoft.copilot.eclipse.ui/css/light.css index d5979617..0e1f225d 100644 --- a/com.microsoft.copilot.eclipse.ui/css/light.css +++ b/com.microsoft.copilot.eclipse.ui/css/light.css @@ -93,7 +93,7 @@ background-color: #3584F1; } -#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > .bg-command-panel { +#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > ScrolledComposite > .bg-command-panel { background-color: #FFFFFF; } diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md index d25e4e55..e0db9491 100644 --- a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md @@ -5,24 +5,104 @@ tools: - api_schema_analyze - mule_code_review - mule_security_review + - mule_read_transform + - mule_write_transform + - mule_read_dwl_file + - mule_write_dwl_file + - mule_optimize_dwl - munit_validate_flow_tests - munit_full_review - munit_improvement_suggestions - summarize_mule_project - get_mule_project_errors - run_mule_maven_tests + - mulesoft/create_mule_project + - mulesoft/generate_mule_flow + - mulesoft/run_local_mule_application + - mulesoft/create_api_spec_project + - mulesoft/generate_api_spec + - mulesoft/implement_api_spec + - mulesoft/mock_api_spec + - mulesoft/search_asset + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_create_sample_data + - mulesoft/dataweave_get_project_metadata + - mulesoft/dataweave_get_module_metadata + - mulesoft/dataweave_create_documentation + - mulesoft/generate_or_modify_munit_test + - mulesoft/deploy_mule_application + - mulesoft/update_mule_application + - mulesoft/list_applications + - mulesoft/create_and_manage_api_instances + - mulesoft/list_api_instances + - mulesoft/manage_api_instance_policy + - mulesoft/create_and_manage_assets + - mulesoft/get_reuse_metrics + - mulesoft/get_flex_gateway_policy_example + - mulesoft/manage_flex_gateway_policy_project + - mulesoft/create_install_runtime_fabric + - mulesoft/upgrade_runtime_fabric + - mulesoft/delete_runtime_fabric + - mulesoft/create_and_run_task + - mulesoft/get_platform_insights --- # MuleSoft Engineer -You are assisting with a Mule 4 application in Anypoint Studio. Treat suggestions as production integration code. +You are assisting with a Mule 4 application in Anypoint Studio. Treat every suggestion as production integration code subject to integration contract, security, and performance requirements. -Use `mule_project_scan` before making claims about project structure. Use `api_schema_analyze` for RAML, OpenAPI, WSDL, XSD, JSON Schema, Avro, CSV, GraphQL, OData, and AsyncAPI contracts. Use `mule_code_review` and `mule_security_review` before recommending implementation changes. +Always run `mule_project_scan` before making claims about project structure. Use `api_schema_analyze` for RAML, OpenAPI, WSDL, XSD, JSON Schema, Avro, CSV, GraphQL, OData, and AsyncAPI contracts. Run `mule_code_review` and `mule_security_review` before recommending implementation changes. Prefer MuleSoft MCP tools for Anypoint Platform actions and local Studio tools for XML/project inspection. -Preserve API-led architecture boundaries. Use XML-aware Mule edits. Do not duplicate flows, sub-flows, global configs, or APIkit route mappings. Never hardcode credentials, tokens, certificates, private keys, or passwords. Prefer secure properties or external secret references. Redact secrets and PII from logs. +## API-Led Architecture +Three-layer model — preserve boundaries strictly: +- **Experience API**: Consumer-facing contract. Routes to Process APIs. Handles protocol, format, and consumer-specific transformation. +- **Process API**: Orchestrates business processes across multiple System APIs. Owns retry logic, aggregation, and error correlation. +- **System API**: One-to-one adapter for a single backend. Exposes backend capabilities in a standard REST/SOAP contract. No business logic. -When reviewing or generating changes, state assumptions, propose minimal diffs, include validation commands, recommend MUnit coverage, and call out security and performance risks. +When generating flows: identify the correct layer, enforce that flow-refs and HTTP calls respect the hierarchy (Experience → Process → System, never upward), and reuse existing sub-flows before creating new ones. -For MUnit review, use `munit_validate_flow_tests` to check suite structure, test purpose, flow execution, assertions, -mock/spy/verify usage, component coverage, branch coverage, and error-path coverage. Use `munit_full_review` for -broad suite reviews and `munit_improvement_suggestions` to improve coverage cadence across happy-path, negative-path, -edge-data, connector-failure, and error-contract scenarios. +## Error Handling Contract +- All HTTP-facing flows must have `` with typed error matchers. Catch-all global handlers are a last resort, not the primary handler. +- Use `` only for truly optional, non-blocking steps (e.g., best-effort enrichment). +- Every error handler must log: `correlationId`, `flow.name`, `error.errorType`, `error.description` — in structured JSON, not string concatenation. +- Error responses must return a consistent JSON shape `{ "code", "message", "correlationId" }` with correct HTTP status codes. Never always return 500. +- Correlation ID: set at HTTP Listener from `X-Correlation-ID` header (fallback `uuid()`), stored as a flow variable, propagated in all outbound HTTP Request headers and all Logger calls. + +## Standalone DataWeave Module Files +- Use `mule_read_dwl_file` to read `.dwl` module files in `src/main/resources/dwl/` before editing or reviewing them. +- Run `mule_optimize_dwl` before rewriting a DWL module to surface performance issues (nested maps, inline regex, round-trip serialization), null-safety gaps, and missing output declarations. +- Use `mule_write_dwl_file` to update a `.dwl` module after confirming the optimized script with the user. +- Always run `mulesoft/dataweave_run_script_tool` after writing to validate the updated script against representative sample data. + +## DataWeave Standards +- Run `mule_read_transform` before editing any Transform Message. Use `mule_write_transform` only after confirming the target element and validating with diagnostics or Maven tests. +- Every script must declare `output` type. All optional field accesses must use `default`. Prefer `map`/`filter`/`reduce`/`groupBy` over imperative patterns. +- Flag nested maps over large collections (O(n²)). Pre-index with `groupBy` and look up in O(1). +- Streaming: use `output application/json streaming=true` for payloads of unknown or large size. Streaming scripts cannot use `sizeOf()`, `[-1]`, or `reverse()`. +- Extract repeated DataWeave logic to `.dwl` modules in `src/main/resources/dwl/` and import with `import`. + +## Logging and Observability +- Log at INFO: flow entry/exit with `correlationId`, `flowName`, and key input identifiers. No full payloads at INFO. +- Log at ERROR: every `` with `correlationId`, `flowName`, `errorType`, `errorDescription`. No raw payload. +- Log at DEBUG: connector calls, DataWeave diagnostics. Must be disabled in production. +- Never log passwords, tokens, API keys, or PII fields without masking. +- Use structured JSON format in Logger `message` expressions. + +## Connector Governance +- Align connector versions with the Mule runtime compatibility matrix. Do not suggest connectors newer than `minMuleVersion` in `mule-artifact.json`. +- Database global configs: set `minPoolSize`, `maxPoolSize`, `maxWait`. HTTP Request configs: set `responseTimeout`. Flag any missing. +- Outbound HTTP: HTTPS only, TLS context configured, `insecure="true"` never allowed. +- Retry: `reconnect` with finite count/frequency. Flag `reconnect-forever` in production. +- Flag deprecated connectors: HTTP v1, File Connector v1, Scripting Module (Groovy/JS/Python). + +## Security Non-Negotiables +- All sensitive values use `${secure::property.name}`. All environment values use `${property.name}`. No inline values in XML. +- DB connector queries: parameterized only (`:variable` syntax). No string concatenation in query attributes. +- XPath expressions: no user-controlled input without sanitization. +- External XML parsing: secure parser settings required (no XXE). + +## MUnit +- Coverage required per public flow: happy path, invalid input (400), connector failure simulation, and error-response contract. +- Mock all external connectors by `doc:name` using `munit:mock-when`. Do not mock sub-flows. +- Cover every `` branch including otherwise. +- After generating: run `munit_validate_flow_tests`, then `run_mule_maven_tests`. Address all failures before declaring tests complete. +- Use `munit_full_review` for broad suite reviews and `munit_improvement_suggestions` to identify coverage cadence gaps. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-led-architecture-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-led-architecture-review.prompt.md new file mode 100644 index 00000000..62026712 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-led-architecture-review.prompt.md @@ -0,0 +1,47 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - api_schema_analyze +--- +# API-Led Architecture Review + +Run `mule_project_scan` on each project involved. Ask the user which API-led layer this project is intended to implement (Experience, Process, or System) if it is not obvious from the project name or flow names. + +## Layer Definitions +- **Experience API (xAPI)**: Consumer-facing. Exposes tailored endpoints for a specific channel (mobile, web, partner). Orchestrates by calling Process APIs. Must not call System APIs directly or other Experience APIs. +- **Process API (pAPI)**: Orchestration layer. Combines data from multiple System APIs to implement a business process. Must not be called by other Process APIs in a chain — flatten the orchestration instead. +- **System API (sAPI)**: One-to-one backend adapter. Exposes a single backend system (Salesforce, SAP, database) via a standard REST/SOAP interface. Contains no business logic. Must not call Process or Experience APIs. + +## Layer Identification +- **Experience API indicators**: APIkit router present, endpoint URLs contain consumer-oriented resources (e.g., `/orders`, `/profile`), response shapes tailored for a channel, HTTP listener on a public port. +- **Process API indicators**: Multiple outbound HTTP Request connectors calling different System APIs, aggregation/transformation logic, no direct backend connector (DB, Salesforce, JMS) calls. +- **System API indicators**: Exactly one backend connector (DB, Salesforce, MQ, SFTP, SAP), thin transformation layer, endpoint URLs closely mirror the backend resource names. + +## Call Direction Violations (Flag as High) +- Experience API calling a System API directly (skipping the Process layer) — couples consumer contracts to backend implementation details. +- System API calling another System API — creates hidden dependencies between backends. +- System API calling a Process API — inverts the dependency graph. +- Circular references: any flow-ref or HTTP call that eventually calls back into the same application. + +## How to Detect Call Direction +- From `mule_project_scan` output: check `connectors` list. If a project claims to be a System API but has multiple outbound HTTP connectors calling different hosts, it may be doing Process API work. +- Outbound HTTP Request configs pointing to internal API base URIs (e.g., `/api/v1/`) rather than backend systems suggest cross-API calls. Flag these for review. +- `mule_code_review` findings on duplicate global configs or duplicate flow logic often signal that System API responsibilities have leaked into Process/Experience layers. + +## Naming Conventions +- Flow names should reflect the layer: Experience APIs use consumer-action naming (`getProductsByCategory`), Process APIs use business-process naming (`processOrderFulfillment`), System APIs use backend-operation naming (`queryCustomerFromSalesforce`). +- API spec `title` and `version` should include the layer indicator: `Customer Experience API v2` vs `Customer System API v1`. +- Project name should follow the pattern: `--api` (e.g., `order-process-api`, `customer-system-api`). + +## Shared Resources +- Global connector configs (DB, Salesforce, MQ) belong in System APIs only. If a Process or Experience API contains connector configs for backend systems, the System API layer is missing. +- Shared DataWeave modules (`.dwl` files) used across layers should live in a separately versioned Exchange asset, not copied between projects. + +## APIkit Validation +- Run `api_schema_analyze` on the API spec. The spec should reflect the layer's consumer contract, not the backend data model. +- System API specs should closely mirror the backend resource vocabulary. Process/Experience API specs should use business vocabulary regardless of how backends name their data. + +## Output +Return: identified layer (or ambiguous if unclear), layer-specific findings (call direction violations, naming issues, connector placement), and recommended refactoring steps to restore layer boundaries. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md index 053ee3d3..a1ad2ce4 100644 --- a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md @@ -2,9 +2,47 @@ mode: agent tools: - api_schema_analyze + - mule_project_scan + - mulesoft/generate_api_spec + - mulesoft/create_and_manage_assets --- # API Specification Review -Analyze the selected API contract. Validate required metadata, reusable schemas, examples, error responses, security definitions, and APIkit compatibility. +Run `api_schema_analyze` on the target API spec file. If a Mule project is available, run `mule_project_scan` to cross-reference the spec against APIkit router flows. -Return contract summary, diagnostics, governance recommendations, and implementation hints. +## Required Spec Metadata +- Title, version, and base URI (RAML: `title`, `version`, `baseUri`; OpenAPI: `info.title`, `info.version`, `servers`). +- Contact, license, and description fields should be present for published Exchange assets. +- Flag missing or placeholder values (e.g., `version: "1.0"` with no semantic versioning, `baseUri: http://example.com`). + +## Schema Quality +- Request and response bodies must reference named schema types, not inline anonymous objects. Inline schemas prevent reuse and make clients harder to generate. +- All reusable types should be defined in a `types` section (RAML) or `components/schemas` (OpenAPI) — not duplicated across endpoints. +- Required fields must be explicitly declared. Flag schemas with no `required` array (OpenAPI) or all-optional fields (RAML) on POST/PUT request bodies. +- Enums should be used for fields with a fixed value set. Avoid free-string fields where a constrained list is appropriate. + +## Examples +- Every request body and response body must have at least one example. Examples validate the spec is usable and enable mocking. +- Examples must be valid against their schema. Flag examples that do not match the declared types or required fields. + +## Error Responses +- All endpoints must declare at minimum: 400 (bad request), 401 (unauthorized), 404 (not found), 500 (internal error). +- Error response bodies should reference a shared error schema (e.g., `ErrorResponse` type) with fields `code`, `message`, and optionally `details`. +- Flag endpoints with only a 200 response defined — partial spec coverage misleads consumers. + +## Security Definitions +- A security scheme must be defined at the spec level: OAuth 2.0, API Key, or HTTP Basic. Flag specs with no security scheme. +- Security scheme must be applied to all non-public endpoints. Flag endpoints with no `securedBy` (RAML) or no `security` (OpenAPI). +- OAuth 2.0 scopes should be listed with descriptions. Generic `read`/`write` scopes are acceptable minimums, but resource-specific scopes are preferred. + +## APIkit Compatibility +- If a Mule project is available: compare spec endpoint list against APIkit router flow names. Flag spec endpoints missing a router flow and router flows missing a spec endpoint. +- RAML: verify that `baseUri` and `version` are compatible with the APIkit router configuration (`api.raml` path in router config). +- Flag RAML `uses:` references to Exchange libraries that are not pinned to a specific version — unversioned dependencies can break on Exchange republish. + +## Versioning +- API version must be in the URL path (e.g., `/v1/customers`) for REST APIs. Header-based versioning is acceptable but must be documented and consistently applied. +- Breaking changes (removing fields, changing types, renaming endpoints) require a new major version. Flag any spec that removes or narrows a previously defined field without a version bump. + +## Output +Return: contract summary (endpoints, schemas, security), governance findings by severity, APIkit compatibility issues if project was scanned, and specific recommendations for each finding. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/async-flow-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/async-flow-review.prompt.md new file mode 100644 index 00000000..89045f53 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/async-flow-review.prompt.md @@ -0,0 +1,54 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - munit_validate_flow_tests + - get_mule_project_errors +--- +# Async Flow Review + +Run `mule_project_scan` first. Check `schedulerFlows` for scheduler-triggered flows and `connectors` for VM or Anypoint MQ connectors. If neither is present, async patterns may still exist via async scopes — proceed with the review below. + +## Scheduler-Triggered Flows +- Scheduler flows must not block for long periods. All downstream connector calls should have timeouts configured. +- Flag scheduler flows without error handlers — an uncaught exception in a scheduler flow produces a `MULE:UNKNOWN` error with no response to return, making it silent unless logged. +- Scheduler flows with correlation IDs: since there is no inbound HTTP request, the correlation ID should be generated with `uuid()` at the start of the flow. +- Check `schedulerFlows` from the scan. For each scheduler flow: verify it has an error handler, a correlation ID set-variable, and a Logger at INFO on entry. + +## Async Scope (``) +- The `` scope runs its processors in a separate thread without blocking the main flow. Use when a side effect (audit log, notification) should not delay the response. +- Flag `` scopes that contain business-critical logic (DB writes, external API calls that the caller depends on) — if the async thread fails, the main flow is unaware. +- `` scopes cannot propagate errors to the parent flow. Any error handler inside `` must handle the error completely. Flag `` scopes with no internal error handler. +- Do not use `` to call downstream APIs where the caller needs a response — use synchronous flow-ref or HTTP request instead. + +## VM Connector Patterns +- VM queues are in-memory and not clustered by default in CloudHub. For production cross-application messaging, use Anypoint MQ or JMS instead. +- VM `publish` + `consume` within the same application is acceptable for decoupling processing stages. +- Flag VM `publish` without a corresponding VM listener flow — published messages will accumulate with no consumer. +- VM listeners must have error handlers. An unhandled error in a VM listener logs an error and discards the message with no retry. +- For reliable messaging: use `transactional="true"` on the VM publish if the source operation should roll back when the consumer fails. + +## Anypoint MQ Patterns +- Anypoint MQ listener flows must have error handlers. Unacknowledged messages return to the queue and will be redelivered (causing duplicate processing) if error handling does not ack or nack explicitly. +- Use `` on success and `` on failure when `acknowledgementMode="MANUAL"` is set. +- Flag MQ listener flows with `acknowledgementMode` not set (defaults to AUTO) where business logic can fail after ack — message loss risk. +- Dead-letter queues should be configured on the MQ destination for messages that fail after max redeliveries. + +## Thread Pool Impact +- Async scopes and MQ/VM listeners consume threads from the `IO` or `CPU_LITE` thread pool depending on the operation type. +- Heavy DataWeave transformations inside async scopes should be run in a `CPU_INTENSIVE` pool — wrap them in `` (Enterprise Edition) or annotate flows with `processingStrategy`. +- Flag async scopes with nested HTTP calls — these block an IO thread while waiting for the response. + +## Graceful Shutdown +- Scheduler flows stop automatically on application shutdown. For VM/MQ listeners, in-flight messages should complete before shutdown. Configure `shutdownTimeout` on the Mule runtime if processing time per message can exceed the default 5-second shutdown window. +- Flag applications with MQ listeners and no `shutdownTimeout` setting where message processing can take more than 5 seconds. + +## Testing Async Flows +- Scheduler-triggered flows: invoke directly with `munit:run-flow` — do not rely on the scheduler firing in tests. +- VM publish/consume: publish a test message to the VM queue in `munit:execution`, then use `munit:assert-that` on the side effect (DB record, variable) after a brief wait or `munit:run-flow` on the listener directly. +- Anypoint MQ: mock the MQ connector with `munit:mock-when` matching `doc:name`. Assert the downstream processing result. +- Verify correlation ID appears in Logger calls inside the async/listener flow using `munit-tools:verify-call`. + +## Output +Return findings grouped by pattern (scheduler, async scope, VM, MQ): missing error handlers, correlation ID gaps, thread pool risks, acknowledgement mode issues, and recommended test scenarios for each async entry point. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/batch-job-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/batch-job-review.prompt.md new file mode 100644 index 00000000..bd7dc84f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/batch-job-review.prompt.md @@ -0,0 +1,55 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mule_read_transform + - munit_validate_flow_tests + - run_mule_maven_tests +--- +# Batch Job Review + +Run `mule_project_scan` first. Check `hasBatchJob` in the scan output. If `hasBatchJob=false`, this prompt does not apply — inform the user there are no batch jobs in the project. If `hasBatchJob=true`, proceed with the review below. + +## Batch Job Structure +Every Mule 4 batch job must have: +- `` with a `name` attribute. +- `` phase: defines the data source (DB query, file read, MQ consumer). Must return an iterable collection — flag if the input produces a single non-iterable object. +- At least one `` with a meaningful `name`. +- `` phase: logs job summary (total records, success count, failure count). Flag if missing — silent batch completion makes production monitoring impossible. + +## Record Block Sizing (`maxRecordsPerBlock`) +- Default `maxRecordsPerBlock` is 100. For most integrations this is too small (low throughput) or too large (high memory per block). +- Rule of thumb: `maxRecordsPerBlock` × (average record size in bytes) should not exceed 10 MB. + - For small records (< 1 KB): `maxRecordsPerBlock=500–1000` is appropriate. + - For large records (> 10 KB): `maxRecordsPerBlock=10–50` is safer. +- Flag jobs where `maxRecordsPerBlock` is not set (uses default 100 without intentional sizing). + +## Step-Level Error Handling +- Each `` should have an `acceptPolicy` or filter expression to skip records that fail validation before processing. +- Connector failures inside a step mark the record as failed. The job continues processing remaining records by default. Verify this behavior is intentional — flag steps where a connector failure should abort the entire job instead. +- Add `` inside the step scope when per-record failures should be tracked but not abort the job. Add `` when a single failure must stop all processing. +- The `` phase receives `batchJobInstanceId`, `loadedRecords`, `successfulRecords`, `failedRecords`, and `elapsedTimeInMillis`. All should be logged. + +## Batch Aggregator +- `` collects records into a buffer before writing. Use for bulk database inserts, bulk API calls, or file writes. +- Set `size` on the aggregator to match the target system's bulk operation limit (e.g., Salesforce upsert max 200 records, DB bulk insert batch size). +- For large aggregated payloads, use `streaming="true"` on the aggregator. Without streaming, the full buffer is materialized in memory. +- Flag aggregators without explicit `size` — they default to the full block which may exceed the target system's limit. + +## DataWeave Inside Batch +- Use `mule_read_transform` on Transform Message components inside batch steps. +- DataWeave transforms inside batch steps run per record. Flag: nested map over sub-collections (O(n) per record × n records = O(n²) total), regex compiled inline (compile outside the step via a variable), and `write()`/`read()` round-trips that are unnecessary. +- For large record fields, use `output application/json streaming=true` to avoid materializing the record in heap. + +## Testing Batch Jobs +- Unit-test individual batch steps by invoking the step's flow directly via `munit:run-flow` with a single fixture record. +- Integration-test the full batch job with a small fixture dataset containing: + - One valid record (verifies happy path). + - One record that triggers a step failure (verifies failure counting and `on-complete` logging). + - One boundary record (empty field, null, max-length string). +- Verify `On Complete` phase: assert `failedRecords` count and log output. +- Use `munit_validate_flow_tests` after generating tests. + +## Output +Return: batch job structure findings (missing on-complete, unsized blocks, unsized aggregators), step-level error handling gaps, DataWeave performance risks, and recommended MUnit fixture scenarios. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/connector-governance.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/connector-governance.prompt.md new file mode 100644 index 00000000..20fcd11b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/connector-governance.prompt.md @@ -0,0 +1,47 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mulesoft/search_asset +--- +# Connector Governance Review + +Run `mule_project_scan` to collect the Mule runtime version and the full list of connector dependencies from the POM. Then review global connector configurations in Mule XML. + +## Version Compatibility +- Every connector version must be compatible with the project's Mule runtime version. Mulesoft publishes a compatibility matrix on the documentation site. +- Flag connectors pinned to EOL versions: HTTP Connector v1 (use v2+), File Connector v1 (use File Connector v2), FTP Connector v1 (use FTP v2), Database Connector v1 (use DB Connector v8+). +- Minor version mismatches between connectors (e.g., HTTP 1.5.x used with Mule 4.4.x when 1.7.x is available) should be flagged as upgrade opportunities. +- Use `mulesoft/search_asset` to look up the latest patch version of a connector in Exchange when a version upgrade is recommended. + +## Redundant or Duplicate Connectors +- Flag POM dependencies that import two versions of the same connector (e.g., `mule-http-connector` appears twice at different versions). Only one version can be active at runtime. +- Flag Mule XML that defines multiple global HTTP Request configurations pointing to the same base URL/host — consolidate into one reusable config. +- Flag duplicate Database global configurations with identical JDBC URL. Each logical database should have exactly one global config. + +## Connection Pooling +- Database connector global configs must set `minPoolSize`, `maxPoolSize`, and `maxWait`. Missing pool config defaults to unlimited connections, which exhausts the DB under concurrent load. + - Recommended baseline: `minPoolSize=2`, `maxPoolSize=10`, `maxWait=5000` (adjust per load profile). +- JMS/ActiveMQ connector should set consumer thread count and prefetch appropriate to the processing throughput. +- HTTP Request config should set `maxConnections` and `connectionIdleTimeout` to prevent connection starvation. + +## Timeout and Retry Strategy +- Every HTTP Request connector config must have `responseTimeout` set. Flag configs with no timeout (defaults to no timeout, blocking threads indefinitely on upstream hang). +- Database operations that may run long (bulk inserts, complex queries) should use `queryTimeout` on the operation, not just rely on connection pool wait. +- Retry: `reconnection-strategy` with `reconnect` (finite retries) is appropriate for transient connectivity loss. Flag `reconnect-forever` in production — it can consume a thread indefinitely. +- `until-successful` scope for retry logic: `maxRetries` and `millisBetweenRetries` must always be set. Flag `until-successful` without both attributes. + +## Authentication Method Consistency +- HTTP connectors to the same upstream service should use the same authentication type. Flag a project where one flow uses OAuth Bearer to call Service X and another uses Basic Auth to the same service. +- Prefer OAuth 2.0 Client Credentials over Basic Auth for machine-to-machine integrations. Flag Basic Auth usages to external APIs where OAuth is available. +- API key authentication should use headers, not query parameters — query parameters appear in server access logs. Flag `apiKey` passed as a query parameter. +- Salesforce connector: prefer OAuth JWT Bearer (server-to-server) over username/password in production. Flag username/password OAuth flows if the project is production-bound. + +## Deprecated and Risky Connectors +- **Scripting Module (groovy/js/python scripts)**: Scripting components execute arbitrary code and are a security risk. Flag usage and recommend DataWeave or Java Module with a typed interface instead. +- **Java Module with `java:invoke-static`**: Calling static methods on third-party libraries bypasses Mulesoft's connector contract. Flag usage and note that library upgrades can silently break the integration. +- **VM Connector for cross-application communication**: VM queues are in-memory and not clustered by default in CloudHub. Use Anypoint MQ or JMS for reliable cross-application messaging. + +## Output +Return findings grouped by connector: connector name, version in use, recommended version, configuration issues, authentication issues, and specific XML attribute changes needed. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-best-practices.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-best-practices.prompt.md new file mode 100644 index 00000000..421536ae --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-best-practices.prompt.md @@ -0,0 +1,53 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_read_transform + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_get_project_metadata + - mulesoft/dataweave_create_documentation +--- +# DataWeave Best Practices Review + +Run `mule_project_scan` to find Transform Message components. Use `mule_read_transform` on each one before reviewing or rewriting DataWeave scripts. + +## Output Type Declaration +- Every DataWeave script must declare an output directive: `output application/json`, `output application/xml`, `output application/java`, etc. +- Missing output directives cause the runtime to infer type, which can produce unexpected results and suppress compile-time errors. +- Input types should be declared when the upstream content-type is ambiguous: `input payload application/json`. + +## Null Safety +- Use the `default` operator for every field access that may be absent: `payload.customer.name default "Unknown"`. +- For nested access chains, each level must be null-safe: `payload.order.items[0].price default 0`. +- Prefer `if (payload.field != null)` over `try(() -> payload.field) default null` — the latter silences real errors. +- Flag scripts that access payload fields without null guards when the input schema has optional fields. + +## Functional Patterns (prefer over imperative) +- Use `map` to transform each element of an array. Use `filter` to exclude elements. Use `reduce` to aggregate. Avoid `if/else` inside `map` when `filter` pre-passes the array. +- Use `groupBy` to index an array into a map keyed by a field — avoids O(n²) nested `map` with inner `filter` lookups. +- Use `distinctBy` to deduplicate collections before processing. +- Flag `do { var ... }` patterns that re-compute the same expression inside a `map` on every iteration. Pre-compute to a variable outside the `map`. + +## Performance Anti-Patterns +- **Nested maps over large collections**: `arrayA map (a -> arrayB filter (b -> b.id == a.id))` is O(n×m). Replace with `groupBy` on `arrayB` and then lookup inside `map` on `arrayA`. +- **Inline regex**: `payload map (item -> item.name matches /^[A-Z].*/)` compiles the regex on every iteration. Extract: `var namePattern = /^[A-Z].*/` and reference it in the map. +- **Unnecessary serialization**: `write(payload, "application/json")` followed immediately by `read(..., "application/json")` is a no-op round-trip. Remove it. +- **Large `output application/java` objects**: materializing large Java maps/lists loses streaming. Use `output application/json` and let the next connector handle deserialization. + +## Streaming for Large Payloads +- When processing payloads where size is unknown at design time (file processing, DB result sets, API pagination), use DataWeave streaming: `output application/json streaming=true`. +- Streaming transforms cannot use `sizeOf()`, `[-1]` (last element), or `reverse()` since these require the full collection in memory. Flag these operations in streaming transforms. +- Batch jobs processing large files should use `` with `` reading from a streaming source rather than loading the full file into payload. + +## Modularity and Reuse +- Repeated DataWeave logic (date formatting, error response building, field masking) should be extracted to a DataWeave module (`.dwl` file in `src/main/resources/dwl/`) and imported with `import` directive. +- Flag copy-pasted DataWeave snippets that appear in 3 or more Transform Message components — these are candidates for a shared module. +- Use `mulesoft/dataweave_create_documentation` to document complex module functions. + +## Type Safety and Documentation +- Complex scripts should document the expected input type as an inline comment: `// Input: { orderId: String, items: Array<{sku: String, qty: Number}> }`. +- Use named types in DataWeave type system for shared structures: `type OrderItem = { sku: String, qty: Number }`. +- When using `mulesoft/dataweave_run_script_tool` to test a script, always test with: a valid input, a null/empty input, and a malformed input to verify null-safety and error handling. + +## Output +Return findings per Transform Message component: component name/ID, file reference, issues found, corrected DataWeave snippet, and a test command using `mulesoft/dataweave_run_script_tool` to validate the fix. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-optimize.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-optimize.prompt.md new file mode 100644 index 00000000..d3bc2a97 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-optimize.prompt.md @@ -0,0 +1,68 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_read_dwl_file + - mule_write_dwl_file + - mule_optimize_dwl + - mule_read_transform + - mule_write_transform + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_create_documentation +--- +# DataWeave Optimization + +Scan, analyze, and optimize all DataWeave scripts in the project — both inline Transform Message +components and standalone `.dwl` module files. + +## Workflow + +1. Run `mule_project_scan` to inventory the project. Identify: + - All Mule XML files with `ee:transform` components + - All `.dwl` module files in `src/main/resources/dwl/` + +2. For each **standalone `.dwl` module**: + - Read with `mule_read_dwl_file` + - Run `mule_optimize_dwl` (includeComments=true, applyFixes=false) to preview issues + - Present findings to the user with type, line, description, and suggested fix + - If the user approves, apply with `mule_optimize_dwl` (applyFixes=true) or `mule_write_dwl_file` + - Validate with `mulesoft/dataweave_run_script_tool` using valid, null, and malformed sample inputs + +3. For each **inline Transform Message** component: + - Read with `mule_read_transform` (use transformName or transformId to target specific components) + - Apply the same optimization checks + - If issues found and user approves, write with `mule_write_transform` + - Validate with `mulesoft/dataweave_run_script_tool` + +## Optimization Priorities (in order) + +1. **Performance** — most impactful changes first: + - Nested `map`+`filter` → pre-index with `groupBy`, look up in O(1) + - Inline regex literals inside `map`/`filter` → extract to `var` before the map + - Round-trip `write()`/`read()` serialization → remove the no-op pair + +2. **Null safety** — prevents runtime `NullPointerException` equivalents: + - Every optional field access must use `default`: `payload.field default ""` + - Nested chains: each level must be guarded: `payload.order.items[0].price default 0` + +3. **Output directive** — prevents runtime type inference issues: + - Every script must start with `%dw 2.0` and declare `output application/json` (or the correct type) + - Input types should be declared when upstream content-type is ambiguous + +4. **Streaming** — for large or unknown-size payloads: + - Suggest `output application/json streaming=true` when the script maps over a potentially large array + - Warn if `sizeOf()`, `[-1]`, or `reverse()` are used in a streaming context + +5. **Documentation** — for maintainability: + - Add `//` or `/** */` comments before undocumented `fun` declarations + - Describe: purpose, parameters, return type + - Flag copy-pasted logic appearing in 3+ transforms as a module extraction candidate + +## Output Format + +For each file reviewed, report: +- File path and component name/ID +- Number of issues found +- For each issue: type, line number, description, suggested fix +- The optimized script (preview or applied) +- Validation command to run with `mulesoft/dataweave_run_script_tool` diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md index a034d2f6..10fa43c6 100644 --- a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md @@ -5,9 +5,51 @@ tools: - mule_code_review - mule_security_review - run_mule_maven_tests + - mulesoft/list_applications + - mulesoft/deploy_mule_application + - mulesoft/update_mule_application + - mulesoft/get_platform_insights --- # MuleSoft Deployment Readiness -Assess readiness for CloudHub, CloudHub 2.0, Runtime Fabric, or standalone Mule deployment. +Run `mule_project_scan` to establish the baseline. Run `mule_code_review` and `mule_security_review`. Then run `run_mule_maven_tests` to confirm MUnit status. Ask the user for the target platform (CloudHub 1.0, CloudHub 2.0 / Runtime Fabric, or standalone/on-prem) to tailor checklist items. -Check runtime version, Maven plugin configuration, secure properties, environment properties, logging profile, health endpoints, MUnit status, and blocking security or validation issues. +## Universal Prerequisites +- `mule-artifact.json` present with correct `minMuleVersion` and `classLoaderModelLoaderDescriptor`. +- `pom.xml` has the correct Mule Maven Plugin version compatible with the target runtime. Flag `mule-maven-plugin` versions that do not match the runtime major version. +- All MUnit tests pass (`run_mule_maven_tests`). Blocking failures must be resolved before deployment. +- No hardcoded secrets in any Mule XML, property file, or POM (confirmed by `mule_security_review`). +- All environment-specific properties externalized to `config-.yaml` or `.properties` with `${property}` placeholders. Flag any `config-default.yaml` values that look environment-specific (URLs, ports, hostnames). +- Log level set to `INFO` or `WARN` in the production logging profile. Flag `DEBUG` or `TRACE` in the default log4j2 config. + +## Health Endpoints +- Every application must expose a health check endpoint (e.g., `GET /health` or `GET /status`) returning HTTP 200 with at minimum `{"status": "UP", "version": ""}`. +- The health endpoint must respond within 2 seconds under normal load. Flag health implementations that call downstream services synchronously without a timeout guard. +- Flag missing health endpoints — deployment platforms use them for liveness and readiness probes. + +## CloudHub 1.0 Specific +- Worker type and count must be configured in the deployment descriptor or Anypoint Platform. Recommend minimum `Medium` (1 vCore) for production flows. Flag `Micro` for anything other than dev/test. +- Persistent queues should be enabled for flows using VM connector or Anypoint MQ when message loss is unacceptable. +- Static IP should be requested in advance if the app connects to IP-allowlisted upstream services. + +## CloudHub 2.0 / Runtime Fabric Specific +- `resources.cpu.reserved`, `resources.cpu.limit`, `resources.memory.reserved`, `resources.memory.limit` must be set in the deployment descriptor. Flag missing resource specifications. +- Replicas should be 2+ for HA in production. Flag single-replica production deployments. +- Liveness and readiness probe paths should point to the health endpoint. Flag if not configured. +- Ingress TLS must be terminated at the ingress controller. Flag HTTP-only ingress configurations. + +## Standalone / On-Premises Specific +- Mule runtime installed at the correct version matching `minMuleVersion`. Flag version mismatches. +- Cluster configuration required for HA: `` settings in `wrapper.conf` or via Management Center. +- JVM heap sizing: `-Xms` and `-Xmx` configured appropriate to worker memory. Flag default JVM settings (256 MB) for production workloads. +- Application hot-deployment path confirmed writable by the Mule process user. + +## Smoke Test Checklist +After deployment: +1. Health endpoint returns `{"status": "UP"}` — verify manually or via curl. +2. Main API endpoint returns expected response to a known-good test request. +3. Log output shows application startup completion without ERROR lines. +4. Anypoint Monitoring or CloudWatch shows response time < SLA threshold within 5 minutes of startup. + +## Output +Return a deployment readiness score (ready / conditional / blocked), a checklist of passed and failed items grouped by category, and any blocking issues that must be resolved before deployment proceeds. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/error-handling-contract.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/error-handling-contract.prompt.md new file mode 100644 index 00000000..19843a08 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/error-handling-contract.prompt.md @@ -0,0 +1,57 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - get_mule_project_errors +--- +# Error Handling Contract Review + +Run `mule_project_scan` first. The scan now returns `flowErrorHandlerTypes` (typed/catch-all/none per flow) and `flowsWithCorrelationId`. Use these to focus the review on flows that are missing typed handlers or correlation IDs. + +## Required Error Handler Presence +- Every flow exposed via HTTP Listener, Anypoint MQ listener, or Scheduler must have its own `` or ``. A global default error handler is a fallback, not a substitute. +- Flag flows where `flowErrorHandlerTypes` shows `"none"` — these will expose raw Mule stack traces on failure. +- Flag flows where `flowErrorHandlerTypes` shows `"catch-all"` — catch-all handlers mask errors and make debugging difficult. Typed handlers are required. + +## On Error Propagate vs. On Error Continue +- ``: re-throws the error after the handler runs. Use for all HTTP-facing flows — the caller needs a proper error response. +- ``: swallows the error and lets the flow complete "successfully." Use only when failure of a step is truly optional (e.g., best-effort audit logging, non-critical enrichment). Never use as a default catch-all. +- Flag any `` without a `type` attribute — this is a catch-all that silences all errors. + +## Typed Error Matching +- Error handlers must declare typed matchers: `type="HTTP:CONNECTIVITY"`, `type="DB:QUERY_EXECUTION"`, `type="MULE:EXPRESSION"`, etc. +- Multiple types can be combined with a comma: `type="HTTP:CONNECTIVITY, HTTP:RESPONSE_VALIDATION"`. +- Flag `` or `` elements with no `type` attribute on HTTP-facing flows. +- Common Mule 4 error type namespaces: `HTTP`, `DB`, `SALESFORCE`, `JMS`, `VM`, `MULE`, `APIKIT`, `VALIDATION`. + +## Correlation ID in Error Handlers +- Every error handler in an HTTP-facing flow must log the correlation ID. Without it, production incidents cannot be traced across systems. +- Check that `flowsWithCorrelationId` from the scan includes all public flows. If a flow is missing from that set, flag it. +- Required Logger format in error handlers: + ``` + #[output application/json --- { + "event": "flowError", + "flowName": flow.name, + "correlationId": vars.correlationId default "none", + "errorType": error.errorType, + "errorMessage": error.description + }] + ``` + +## Consistent Error Response Shape +- All HTTP-facing `` handlers must set the HTTP status code explicitly via `` or via an `` that sets the appropriate status variable. +- Error responses must follow a consistent JSON shape: + ```json + { "code": "ERROR_TYPE", "message": "Human-readable message", "correlationId": "..." } + ``` +- Flag flows that return raw Mule error descriptions (`error.description`) directly as the response body — these expose internal stack trace fragments to API consumers. +- HTTP status code mapping: validation errors → 400, auth failures → 401/403, not found → 404, connector failures → 503, unexpected → 500. Never always return 500. + +## Global Error Handlers +- Global `` elements are acceptable as a last-resort fallback (catches errors not caught by flow-level handlers). +- The global handler should log with correlation ID and return a 500 response. It should NOT be the primary handler for known error types. +- Flag projects where the only error handler is a global one — this indicates no per-flow error handling exists. + +## Output +Return findings grouped by flow: flow name, current error handler type (from scan data), missing typed matchers, correlation ID gap, and the corrected error handler XML snippet. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md index a078862f..03c505fb 100644 --- a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md @@ -3,18 +3,59 @@ mode: agent tools: - mule_project_scan - mule_code_review + - mule_read_transform - munit_validate_flow_tests - munit_full_review - munit_improvement_suggestions - get_mule_project_errors - run_mule_maven_tests + - mulesoft/generate_or_modify_munit_test --- # Generate MUnit Tests -Inspect the target flow and existing MUnit suites. Recommend or generate focused MUnit coverage for positive, negative, and edge cases. +Run `mule_project_scan` to identify flows and existing MUnit suites. Run `munit_validate_flow_tests` on existing suites first to understand current coverage gaps. Then use `mulesoft/generate_or_modify_munit_test` to create or update tests. -Mock downstream connectors, validate payloads and variables, avoid duplicate tests, and include the Maven command needed to run the suite. +## Required Coverage for Every Flow +- **Happy path**: Valid input, all connectors succeed, expected payload/variable in response. +- **Negative path**: Invalid or missing input returning correct error response (correct HTTP status and error body). +- **Error path**: Simulated connector failure (e.g., `munit:mock-when` with `thenFail`) verifying On Error Propagate behavior and error response shape. +- **Boundary/edge data**: Empty collections, null optional fields, maximum length strings, zero-value numerics. -Before finalizing, validate the suite with `munit_validate_flow_tests` and address missing MUnit namespaces, config, -execution, validation, assertions, processor coverage, branch coverage, and error-path coverage. Use `munit_full_review` -for an end-to-end review and `munit_improvement_suggestions` to tune the testing cadence. +## Mocking Strategy +- Mock every external connector call: HTTP Request, Database, Salesforce, MQ, etc. Use `munit:mock-when` with `munit:with-attributes` to match the specific processor by `doc:name` or flow path. +- Do NOT mock sub-flow calls — test sub-flows through the parent flow invocation. Mock only connectors that reach outside the Mule runtime. +- For scheduler-triggered flows, use `munit:run-flow` to invoke the flow directly; do not rely on scheduler firing in tests. + +## Choice Router Branch Coverage +- Each `` router requires one test per when-condition plus one test for the otherwise branch. +- Flag test suites that cover the default route only or only a subset of branches. + +## Scatter-Gather Testing +- Each route in a scatter-gather must be independently mocked. Verify the aggregated payload contains contributions from all routes. +- Add one test with a failing route to confirm the scatter-gather error handler behavior. + +## Batch Job Testing +- Unit-test individual Batch Step flows in isolation via `munit:run-flow`. +- Integration-test the full batch job with a small fixture dataset (3–5 records) including: one valid record, one record that triggers a step failure, and one boundary record. +- Verify the On Complete phase logging and output variables. + +## Async Flow Testing +- Flows using VM Publish-Consume or async scopes: use `munit:run-flow` and then poll/assert the VM queue or output variable with a reasonable timeout. +- For purely async flows (VM Publish with no response), assert side effects: DB records written, MQ messages published (via mock verify-call), or variables set. + +## Transactional Flows +- Test rollback: mock the second connector in a try scope to fail, verify the first connector's write was rolled back (assert mock was called, DB record not committed). +- Verify the error handler returns the correct HTTP status and body when a transaction rolls back. + +## Correlation ID Propagation +- Every test that simulates an inbound HTTP request should set a `MULE_CORRELATION_ID` attribute on the mock message source. +- Assert that Logger calls within the flow include the correlation ID in structured output. + +## Test Naming Convention +- Use descriptive test names that state intent: `given_validRequest_when_getCustomer_then_returns200`, or shorter `getCustomer_validId_returns200`. +- Avoid names like `test1`, `happyPath`, or the flow name alone. + +## Validation +- After generating, validate the suite with `munit_validate_flow_tests`. Address any missing MUnit namespaces, munit:config, execution, assertion, or mock issues before finalizing. +- Run `run_mule_maven_tests` to confirm all tests pass. +- Include the Maven command to run only this suite: `mvn test -Dmunit.test=.xml`. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/logging-observability.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/logging-observability.prompt.md new file mode 100644 index 00000000..b5be5b73 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/logging-observability.prompt.md @@ -0,0 +1,54 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mulesoft/get_platform_insights +--- +# Logging and Observability Review + +Run `mule_project_scan` to identify flows, connectors, and log4j2 configuration. Run `mule_code_review` with reviewType `logging`. Use `mulesoft/get_platform_insights` to check available monitoring metrics in Anypoint Platform. + +## Correlation ID Strategy +- Correlation IDs enable distributed tracing across systems. Every inbound HTTP Listener must set a correlation ID at the source: use the `X-Correlation-ID` request header if present, otherwise generate one with `uuid()`. + - Example: `` +- The correlation ID must be propagated in all outbound HTTP Request calls as a request header: `X-Correlation-ID: #[vars.correlationId]`. +- Log the correlation ID at flow entry and in all error handlers. Flag Logger components inside main flows that do not include `correlationId`. + +## Log Levels +- **ERROR**: Unexpected exceptions that terminate a flow or cause data loss. Includes connector failures after retries exhausted, unhandled exceptions. +- **WARN**: Recoverable issues that may indicate misconfiguration or degraded behavior: retry attempts, missing optional headers, slow upstream responses. +- **INFO**: Flow lifecycle events: entry and exit of public flows with key metadata (correlation ID, input record count, operation name). Should NOT include full payloads. +- **DEBUG**: Connector call details, DataWeave input/output for diagnostic purposes. Must be disabled in production. +- Flag Logger components using `INFO` inside ``, ``, or Batch Steps — these produce one log entry per record at high volume. Log at entry/exit of the outer flow instead with count metadata. + +## Structured Logging Format +- Log messages should be structured JSON strings rather than free text, to enable log aggregation and search. + - Preferred: `{"event":"flowEntry","flowName":"getCustomerFlow","correlationId":"#[vars.correlationId]","inputId":"#[payload.customerId]"}` + - Avoid: `"Processing customer " ++ payload.customerId ++ " for request " ++ vars.correlationId` +- All Logger `message` expressions should use DataWeave to build a JSON object, not string concatenation. +- Flag Logger messages that include raw `payload` or `attributes` objects — these log the entire request/response body including potentially sensitive fields. + +## PII and Secrets in Logs +- Flag Logger components that log `payload` fields containing: names, email addresses, phone numbers, SSNs, credit card numbers, passwords, tokens, or API keys. +- Use field masking in the log message DataWeave: `output application/json --- { "email": payload.email[0..2] ++ "***" }`. +- The `mule_security_review` tool flags hardcoded secrets; this review focuses on runtime log data. + +## Error Handler Logging +- Every `` and `` should include a Logger at ERROR or WARN level with: correlation ID, flow name, error type, and a summary message. Do NOT log the full error payload. +- Flag error handlers with no Logger component — silent error handling makes production diagnosis impossible. +- Recommended error log format: `{"event":"flowError","flowName":"#[flow.name]","correlationId":"#[vars.correlationId default 'none']","errorType":"#[error.errorType]","errorMessage":"#[error.description]"}` + +## Log4j2 Configuration +- Production `log4j2.xml` should set root logger to `INFO`. Flag `DEBUG` or `TRACE` at root level — these flood logs with Mule internals. +- CloudHub log forwarding to external aggregators (Splunk, ELK) requires the async appender to be configured. Flag missing async appender for high-throughput applications. +- JSON layout appender preferred for machine-parseable logs: `` or similar. + +## Anypoint Monitoring and Metrics +- Use `mulesoft/get_platform_insights` to verify that the application has Anypoint Monitoring enabled in the target environment. +- Flag applications deployed without Anypoint Monitoring — no visibility into response times, error rates, or connector health. +- Custom metrics can be emitted from Mule flows using the Anypoint Monitoring Custom Metrics feature. Recommend adding custom metrics for: processing time per record, error counts by type, integration payload sizes. +- CloudHub 2 and Runtime Fabric deployments should also expose a `/metrics` endpoint compatible with Prometheus scraping if the operations team uses Prometheus/Grafana. + +## Output +Return findings grouped by category: correlation ID gaps, log level violations, PII/secrets exposure risks, missing error handler logging, log4j2 configuration issues, and monitoring gaps. Include the specific Logger or flow element reference, the issue, and the corrected Logger message expression. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md index 957b326c..2227206e 100644 --- a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md @@ -3,12 +3,38 @@ mode: agent tools: - mule_project_scan - mule_code_review + - mule_read_transform - get_mule_project_errors --- # MuleSoft Code Review -Scan the Mule project, then review Mule XML, DataWeave, properties, API specs, MUnit suites, and POM metadata. +Run `mule_project_scan` first to establish the project baseline. Then run `mule_code_review` across Mule XML, DataWeave, properties, API specs, MUnit suites, and POM metadata. -Prioritize findings by severity. Check API-led boundaries, flow naming, duplicate routes, connector configuration, error handling, logging, correlation IDs, DataWeave readability, MUnit coverage, and deployment readiness. +## Flow Structure +- Flows should use camelCase verb-noun naming (e.g., `getCustomerByIdFlow`, `postOrderFlow`). Sub-flows use the same convention with a descriptive qualifier. +- Prefer sub-flows for reusable logic called from multiple flows. Use private flows only for asynchronous branching (async scope, VM). +- Every flow exposed via HTTP or a message source must have an On Error Propagate at the flow level with at least one specific error type. Do not rely solely on global default error handlers. +- On Error Continue is appropriate only when the flow must complete successfully despite the error (e.g., optional enrichment steps). On Error Propagate re-throws and should be the default for public-facing flows. +- Correlation IDs must be set at the HTTP Listener or message source (e.g., `correlationId` attribute), logged at flow entry, and propagated through all flow-refs and async calls. -Return findings with file references, recommended fixes, test gaps, and validation commands. +## Global Configuration +- No duplicate global configs. One ``, one ``, etc. per logical target. Duplicates cause confusion and runtime precedence issues. +- All sensitive values (passwords, tokens, client secrets) must use `${secure::property.name}` — never plain `${property.name}` and never hardcoded values in XML. +- All environment-specific values (hosts, ports, paths) must use `${property.name}` with corresponding `config-.yaml` or `.properties` files. + +## DataWeave +- Read DataWeave scripts with `mule_read_transform` before recommending changes. +- Output type must be declared (`output application/json`, `output application/xml`, etc.). +- Null-safe patterns required: use `default` operator for optional fields (e.g., `payload.name default "Unknown"`). +- Prefer `map`, `filter`, `reduce` over `if/else` imperative patterns. Flag nested `map` calls on large collections as performance risks. + +## MUnit Coverage +- Every public flow (HTTP listener, scheduler, connector source) should have at least one MUnit test. +- Flag flows with zero test coverage. Flag suites that test only happy-path without any error-path or connector-failure scenario. + +## APIkit +- If APIkit router is present, verify every endpoint in the RAML/OpenAPI spec has a corresponding router flow (`get:\resource:api-config` naming pattern). +- Flag router flows that exist in XML but have no corresponding spec endpoint (orphaned routes). + +## Output +Prioritize findings as critical, high, medium, low. For each finding: file reference, line or element, issue, recommended fix, and a validation command (e.g., Maven test or Studio validation). diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md index 0867a2d4..238fe828 100644 --- a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md @@ -3,9 +3,46 @@ mode: agent tools: - mule_project_scan - mule_code_review + - mule_read_transform --- # MuleSoft Performance Review -Review Mule flows and DataWeave for large-payload behavior, unnecessary materialization, logging volume, streaming strategy, connector pools, timeouts, retries, pagination, batch sizing, and maxConcurrency risks. +Run `mule_project_scan` first to identify the runtime version, connectors, and batch jobs. Then run `mule_code_review` with reviewType `performance`. Use `mule_read_transform` to inspect DataWeave scripts in Transform Message components. -Return prioritized performance findings, tuning recommendations, metrics to collect, and a load test plan. +## DataWeave and Payload Handling +- Payloads larger than 1 MB should use streaming rather than materializing the full payload in memory. Flag any `output application/json` or `output application/xml` transforms that do not set `streaming=true` where size is unknown at design time. +- Flag nested `map` calls over large collections. These are O(n²) or worse. Prefer a single-pass `map` with a nested `reduce` or a lookup map pre-built with `groupBy`. +- `write()` and `read()` calls that convert between formats unnecessarily inflate memory. Flag DataWeave that serializes then immediately re-parses. +- Flag inline regex patterns inside `map` loops — compile regex outside the loop via a variable. + +## Batch Processing +- Batch job step size (records per block) should balance memory pressure and throughput. The default (100) is often too small for high-volume jobs and too large for memory-constrained runtimes. Recommend reviewing `maxRecordsPerBlock` against the payload size per record. +- Flag Batch Aggregator steps without explicit `streaming="true"` when processing large result sets. +- Flag Batch jobs that have no `On Complete` phase logging — without it, failures are silent. + +## Concurrency and Threading +- `maxConcurrency` on a flow defaults to the listener thread pool. For CPU-intensive DataWeave, set `maxConcurrency` to number of CPU cores; for IO-bound flows (HTTP, DB), allow higher values. +- Scatter-gather with `maxConcurrency` equal to the number of routes is fine for small sets. Flag scatter-gather where `maxConcurrency` is not set and route count is dynamic or could exceed 20. +- `until-successful` without `maxRetries` and `millisBetweenRetries` defaults can cause threads to block indefinitely. Flag unconfigured `until-successful`. + +## Connector Configuration +- HTTP Request configs without explicit `responseTimeout` and `connectionIdleTimeout` will hold threads open on slow upstreams. Both should be set. +- Database connector configs should have `minPoolSize`, `maxPoolSize`, and `maxWait` configured. Default unlimited pooling exhausts DB connections under load. +- JMS/ActiveMQ connector should have prefetch and consumer count tuned to match processing throughput. +- Flag any connector using `reconnect-forever` without a `blocking="false"` strategy — this can deadlock the flow dispatcher thread. + +## Database Queries +- Flag N+1 query patterns: a `` inside a `` or `` over a collection. Prefer a single bulk query with `IN (...)` or a join. +- Flag missing pagination on `` queries that could return unbounded row counts. Use `LIMIT`/`OFFSET` or cursor-based pagination. +- Flag `fetchSize` not set on large result sets — defaults can cause full result materialization in the JDBC driver. + +## Logging Volume +- Logger components in tight loops (inside ``, ``, Batch steps) at INFO or DEBUG level produce enormous log volume under load. Log entry/exit of the outer flow instead. +- Flag full payload logging at INFO — use DEBUG and structured field extraction instead. + +## Caching +- Flag repeated calls to the same external API or DB within a single request that return static or slowly-changing data. Recommend Mule Cache Scope (``) with an appropriate TTL. +- Distributed cache (e.g., Redis via Object Store v2) should be used for clustered deployments. In-memory cache is invalidated on worker restart and inconsistent across CloudHub workers. + +## Output +Return prioritized findings (critical, high, medium, low), tuning recommendations with specific configuration values, key metrics to monitor (response time, GC pressure, thread pool saturation, connector pool wait), and a suggested load test scenario for the highest-risk flow. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md index aca71839..c0a9c8f1 100644 --- a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md @@ -4,9 +4,41 @@ tools: - mule_project_scan - mule_security_review - api_schema_analyze + - mulesoft/list_api_instances + - mulesoft/manage_api_instance_policy --- # MuleSoft Security Review -Review Mule XML, properties, POM metadata, and API contracts for hardcoded secrets, insecure HTTP, missing validation, unsafe logging, weak authentication, missing authorization, dependency risk, and policy gaps. +Run `mule_project_scan` first, then `mule_security_review`. Analyze Mule XML, property files, POM metadata, and API contracts. -Classify findings as critical, high, medium, or low. Include required policy changes, secure property migration notes, and validation steps. +## Credentials and Secrets +- All sensitive values (passwords, tokens, client secrets, API keys, certificates) must use `${secure::property.name}` with the Mule Secure Configuration Properties module. Never `${plain.property}` for secrets, never inline values. +- Check `mule-artifact.json` and POM for the `mule-secure-configuration-property-module` dependency. Flag if missing. +- Flag any property file (`.yaml`, `.properties`) that contains values matching secret patterns (password, secret, token, apikey, clientsecret, privatekey). These should be encrypted or externalized. + +## Injection Attacks (Mule-Specific) +- **XPath injection**: Any flow using XPATH expressions (e.g., `xpath3()` function, XSLT, XQuery) with user-controlled input must sanitize or parameterize. Hardcoded XPath is safe; concatenated XPath with `attributes.queryParams` or `payload` fields is not. +- **XML External Entity (XXE)**: Flows that parse XML using DataWeave or Java invoke must use secure parser settings. Flag any `java:invoke` or `java:new` calls on XML parsers without explicit `FEATURE_SECURE_PROCESSING` or equivalent. +- **SQL injection**: All Database connector operations must use parameterized queries (`:variable` syntax). Flag any `` or `` where the query attribute concatenates flow variables or payload fields as strings. +- **DataWeave deserialization**: Flag `readUrl()` or `read()` calls on untrusted input without schema validation or content-type enforcement. + +## Transport and Network Security +- All HTTP Listener configs for non-internal flows must use HTTPS (``). Flag plain HTTP on public-facing endpoints. +- All HTTP Request configs calling external services must use HTTPS. Flag any `http://` URLs in request configs. +- Outbound HTTP Request configs must have `tlsContext` set and not disable certificate validation (no `insecure="true"`). +- Check for hardcoded IP addresses or internal hostnames — these should use property placeholders. + +## Authentication and Authorization +- Public flows receiving external requests must include authentication validation: API key policy, OAuth 2.0, JWT validation, or Basic Auth connector with credential store. Flag flows with no authentication mechanism. +- Authorization: presence of authentication does not imply authorization. If role-based access is required, verify it exists in the flow or via Anypoint policy. +- Flag flows that return 200 on auth failure instead of 401/403. + +## API Policies +- Use `mulesoft/list_api_instances` and `mulesoft/manage_api_instance_policy` to verify that deployed API instances have at minimum: Rate Limiting or SLA-based throttling, Client ID Enforcement or OAuth, and IP allowlist where applicable. + +## Logging Safety +- Flag any Logger components that log `payload`, `attributes`, or variables containing passwords, tokens, or PII fields without masking. +- Structured logging with field masking is preferred over full payload logging. + +## Output +Classify each finding as critical, high, medium, or low. Include: file/element reference, attack vector, remediation step, and secure property migration note where applicable. diff --git a/com.microsoft.copilot.eclipse.ui/plugin.properties b/com.microsoft.copilot.eclipse.ui/plugin.properties index 38fd45a7..636fc1fb 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.properties +++ b/com.microsoft.copilot.eclipse.ui/plugin.properties @@ -47,4 +47,5 @@ intro.quickStart.description=Boost your coding efficiency with built-in Copilot theme.category.label=GitHub Copilot theme.category.description=Font and color settings for GitHub Copilot theme.chatFont.label=Chat Font -theme.chatFont.description=The font used for text in the Copilot Chat view \ No newline at end of file +theme.chatFont.description=The font used for text in the Copilot Chat view +page.preferencesPage.autoApprove.name=Tool Auto Approve \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/plugin.xml b/com.microsoft.copilot.eclipse.ui/plugin.xml index be871853..8613d381 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui/plugin.xml @@ -175,6 +175,12 @@ category="com.microsoft.copilot.eclipse.ui.preferences.CopilotPreferencesPage" class="com.microsoft.copilot.eclipse.ui.preferences.CustomModesPreferencePage"> + + @@ -191,6 +197,27 @@ + + + + + + + + + + + + @@ -203,6 +230,16 @@ + + + + requestToolExecutionConfirmation(String title, - String message, Object input) { - // process all the messages before showing the confirmation dialog + public CompletableFuture requestToolExecutionConfirmation( + ConfirmationContent content, Object input) { reset(); - this.confirmDialog = new InvokeToolConfirmationDialog(this, title, message, input); + this.confirmDialog = new InvokeToolConfirmationDialog(this, content, input); CompletableFuture toolConfirmationFuture = this.confirmDialog .getConfirmationFuture(); - this.getParent().layout(); + this.getParent().requestLayout(); return toolConfirmationFuture; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java index 0b961438..945d5ae9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java @@ -170,13 +170,32 @@ private int getMatchPriority(ConversationTemplate template, String lowerPrefix) public ICompletionProposal[] createCopilotCompletionAgentProposals(String prefix) { List proposals = new ArrayList<>(); ChatCompletionService commandService = chatServiceManager.getChatCompletionService(); + String activeModeNameOrId = chatServiceManager.getUserPreferenceService().getActiveModeNameOrId(); + if (StringUtils.isBlank(activeModeNameOrId)) { + ChatMode activeChatMode = chatServiceManager.getUserPreferenceService().getActiveChatMode(); + activeModeNameOrId = activeChatMode != null ? activeChatMode.toString() : null; + } + + if (commandService.isConsoleContextCommandAvailable(activeModeNameOrId) + && (prefix.isEmpty() || ChatCompletionService.CONSOLE_CONTEXT_COMMAND.startsWith(prefix))) { + proposals.add(new ChatCompletionProposal(ChatCompletionService.AGENT_MARK, + ChatCompletionService.CONSOLE_CONTEXT_COMMAND, ChatCompletionService.CONSOLE_CONTEXT_DESCRIPTION)); + } + + if (commandService.isTransformContextCommandAvailable(activeModeNameOrId) + && (prefix.isEmpty() || ChatCompletionService.TRANSFORM_CONTEXT_COMMAND.startsWith(prefix))) { + proposals.add(new ChatCompletionProposal(ChatCompletionService.AGENT_MARK, + ChatCompletionService.TRANSFORM_CONTEXT_COMMAND, ChatCompletionService.TRANSFORM_CONTEXT_DESCRIPTION)); + } + if (!commandService.isAgentsReady()) { - return new ICompletionProposal[0]; + return proposals.toArray(new ICompletionProposal[0]); } - // So far no template supports agent mode. + // So far no language-server agent command supports agent mode. Local context commands above may still apply. if (Objects.equals(chatServiceManager.getUserPreferenceService().getActiveChatMode(), ChatMode.Agent)) { - return new ICompletionProposal[0]; + return proposals.toArray(new ICompletionProposal[0]); } + ConversationAgent[] agents = commandService.getAgents(); for (ConversationAgent agent : agents) { if (prefix.isEmpty() || agent.getSlug().startsWith(prefix)) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java index b4438ec9..f29db684 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java @@ -46,6 +46,7 @@ */ public class ChatInputTextViewer extends UndoableTextViewer implements PaintListener { private static final int MAX_INPUT_ROWS = 5; + private static final String CHAT_INPUT_CSS_CLASS = "chat-input-text"; private Composite parent; private Consumer sendMessageHandler; @@ -58,6 +59,8 @@ public class ChatInputTextViewer extends UndoableTextViewer implements PaintList private int lastCursorLineOffset = 0; private Color placeholderColor; + private Color darkInputBackground; + private Color darkInputForeground; private Runnable layoutRefreshCallback; @@ -112,6 +115,15 @@ public void refresh() { applyDarkInputBackground(); } + /** + * Reapplies the theme background for the chat input. Use this before redraw-only updates such as mode placeholder + * refreshes, where Eclipse may have reapplied native defaults. + */ + public void refreshInputBackground() { + applyDarkInputBackground(); + applyDarkInputBackgroundAsync(); + } + @Override public void paintControl(PaintEvent e) { String content = this.getContent(); @@ -131,6 +143,7 @@ private void init() { tvw.setLayout(new GridLayout(1, false)); tvw.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); tvw.setAlwaysShowScrollBars(false); + appendCssClass(tvw, CHAT_INPUT_CSS_CLASS); applyDarkInputBackground(); SwtUtils.invokeOnDisplayThreadAsync(this::applyDarkInputBackground, tvw); tvw.addListener(SWT.Settings, e -> applyDarkInputBackground()); @@ -184,6 +197,9 @@ public void keyPressed(KeyEvent e) { // Unregister callback on dispose tvw.addDisposeListener(e -> { this.chatServiceManager.getChatFontService().unregisterCallback(fontChangeCallback); + disposeColor(this.placeholderColor); + disposeColor(this.darkInputBackground); + disposeColor(this.darkInputForeground); }); } @@ -194,7 +210,15 @@ private void applyDarkInputBackground(StyledText textWidget) { if (parent == null || parent.isDisposed()) { return; } - textWidget.setBackground(parent.getBackground()); + Color background = getDarkInputBackground(textWidget); + Color foreground = getDarkInputForeground(textWidget); + parent.setBackground(background); + textWidget.setBackground(background); + textWidget.setForeground(foreground); + } + + private void applyDarkInputBackground() { + applyDarkInputBackground(this.getTextWidget()); } private void applyDarkInputBackgroundAsync() { @@ -202,15 +226,43 @@ private void applyDarkInputBackgroundAsync() { SwtUtils.invokeOnDisplayThreadAsync(this::applyDarkInputBackground, textWidget); } - private void applyDarkInputBackground() { - applyDarkInputBackground(this.getTextWidget()); - } - private void applyDarkInputLineBackground(LineBackgroundEvent event) { if (parent == null || parent.isDisposed() || !UiUtils.isDarkTheme()) { return; } - event.lineBackground = parent.getBackground(); + event.lineBackground = getDarkInputBackground((StyledText) event.widget); + } + + private Color getDarkInputBackground(StyledText textWidget) { + if (darkInputBackground == null || darkInputBackground.isDisposed()) { + darkInputBackground = CssConstants.getChatBackgroundColor(textWidget.getDisplay()); + } + return darkInputBackground; + } + + private Color getDarkInputForeground(StyledText textWidget) { + if (darkInputForeground == null || darkInputForeground.isDisposed()) { + darkInputForeground = CssConstants.getChatForegroundColor(textWidget.getDisplay()); + } + return darkInputForeground; + } + + private void appendCssClass(StyledText textWidget, String className) { + Object currentClassNames = textWidget.getData(CssConstants.CSS_CLASS_NAME_KEY); + if (currentClassNames instanceof String names && !names.isBlank()) { + if (!(" " + names + " ").contains(" " + className + " ")) { + textWidget.setData(CssConstants.CSS_CLASS_NAME_KEY, names + " " + className); + } + return; + } + + textWidget.setData(CssConstants.CSS_CLASS_NAME_KEY, className); + } + + private void disposeColor(Color color) { + if (color != null && !color.isDisposed()) { + color.dispose(); + } } private void clearFormat(int start, int end) { @@ -309,8 +361,10 @@ private void onKeyPressed(KeyEvent e) { } clearFormat(0, text.length()); String firstWord = text.substring(begin, end); + String activeModeNameOrId = userPreferenceService.getActiveModeNameOrId(); if (e.keyCode == SWT.BS - && chatCompletionService.isBrokenCommand(firstWord, this.getTextWidget().getCaretOffset() - begin)) { + && chatCompletionService.isBrokenCommand(firstWord, this.getTextWidget().getCaretOffset() - begin, + activeModeNameOrId)) { try { getDocument().replace(begin, end - begin, StringUtils.EMPTY); } catch (BadLocationException ex) { @@ -320,7 +374,7 @@ private void onKeyPressed(KeyEvent e) { } // we may need to highlight the command if user removed leading character before a command // user is typing - if (chatCompletionService.isCommand(firstWord)) { + if (chatCompletionService.isCommand(firstWord, activeModeNameOrId)) { this.getTextWidget().setStyleRange(new StyleRange(begin, end - begin, UiUtils.SLASH_COMMAND_FORGROUND_COLOR, UiUtils.SLASH_COMMAND_BACKGROUND_COLOR, SWT.BOLD)); return; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 65cf0a74..6d758d29 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -80,8 +81,14 @@ import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.services.AgentToolService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextPromptProcessor; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextPromptProcessor.ProcessedMessage; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextPromptProcessor; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService; import com.microsoft.copilot.eclipse.ui.chat.services.DebugEventAutoResponseHandler; import com.microsoft.copilot.eclipse.ui.chat.services.ReferencedFileService; import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService; @@ -93,6 +100,7 @@ import com.microsoft.copilot.eclipse.ui.chat.viewers.NoSubscriptionViewer; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -130,6 +138,8 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL // Auto breakpoint response handler private DebugEventAutoResponseHandler debugEventHandler; + private ConsoleContextService consoleContextService = new ConsoleContextService(); + private TransformEditorContextService transformEditorContextService = new TransformEditorContextService(); // Event handlers for cleanup private EventHandler chatOnSendHandler; @@ -435,6 +445,7 @@ public void partActivated(IWorkbenchPartReference partRef) { if (partRef.getPart(false) == ChatView.this) { activateChatViewContext(); } + updateTransformPropertiesHint(partRef, true); } @Override @@ -442,6 +453,7 @@ public void partDeactivated(IWorkbenchPartReference partRef) { if (partRef.getPart(false) == ChatView.this) { deactivateChatViewContext(); } + updateTransformPropertiesHint(partRef, false); } @Override @@ -451,7 +463,7 @@ public void partBroughtToTop(IWorkbenchPartReference partRef) { @Override public void partClosed(IWorkbenchPartReference partRef) { - // No action needed + updateTransformPropertiesHint(partRef, false); } @Override @@ -505,6 +517,38 @@ private void deactivateChatViewContext() { } } + private void updateTransformPropertiesHint(IWorkbenchPartReference partRef, boolean activated) { + String partId = partRef.getId(); + if (!isTransformPropertiesView(partId)) { + return; + } + if (!activated) { + transformEditorContextService.clearActiveTransformHint(); + return; + } + String title = partRef.getPartName(); + String[] hint = parseTransformHintFromTitle(title); + transformEditorContextService.setActiveTransformHint(hint[0], hint[1]); + } + + private boolean isTransformPropertiesView(String partId) { + return partId != null && (partId.equals(TransformEditorContextService.MULE_TRANSFORM_VIEW_ID) + || partId.startsWith("org.mule.tooling.ui.views.transform") + || partId.startsWith("com.mulesoft.studio.properties")); + } + + private String[] parseTransformHintFromTitle(String title) { + if (title == null || title.isBlank()) { + return new String[] {"", ""}; + } + int colonIdx = title.indexOf(':'); + if (colonIdx >= 0 && colonIdx + 1 < title.length()) { + return new String[] {title.substring(colonIdx + 1).trim(), ""}; + } + String cleaned = title.replace("Transform Message", "").trim(); + return new String[] {cleaned, ""}; + } + /** * Build the view for the given status. * @@ -973,15 +1017,26 @@ public void setFocus() { private void onSendInternal(String workDoneToken, String message, String agentSlug, String agentJobWorkspaceFolder, boolean createNewTurn) { - String processedMessage = replaceWorkspaceCommand(message); - - // Persist the user input to history - chatServiceManager.getUserPreferenceService().addInputToHistory(processedMessage); - final ChatMode activeChatMode = chatServiceManager.getUserPreferenceService().getActiveChatMode(); // Get mode information String activeModeId = chatServiceManager.getUserPreferenceService().getActiveModeNameOrId(); + String consoleContextModeId = StringUtils.defaultIfBlank(activeModeId, + activeChatMode != null ? activeChatMode.toString() : null); + ProcessedMessage consoleProcessedMessage = processConsoleContextCommand(message, consoleContextModeId); + TransformEditorContextPromptProcessor.ProcessedMessage transformProcessedMessage = + processTransformContextCommand(consoleProcessedMessage.serverMessage(), consoleContextModeId); + if (!transformProcessedMessage.transformContextRequested()) { + transformProcessedMessage = autoInjectTransformContext( + transformProcessedMessage.serverMessage(), consoleContextModeId); + } + String processedMessage = replaceWorkspaceCommand(transformProcessedMessage.serverMessage()); + String userMessageToPersist = + (consoleProcessedMessage.consoleContextRequested() || transformProcessedMessage.transformContextRequested()) + ? message : processedMessage; + + // Persist the user input to history + chatServiceManager.getUserPreferenceService().addInputToHistory(userMessageToPersist); // Determine chat mode name and custom mode ID for LSP String chatModeName; @@ -1027,10 +1082,19 @@ private void onSendInternal(String workDoneToken, String message, String agentSl final CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); final CopilotModel activeModel = chatServiceManager.getModelService().getActiveModel(); + // Collect attached file paths for auto-approve of file operations. + // Stage as pending so confirmation requests can match immediately, + // even before the real conversation ID arrives. + List pendingAttachedFiles = + collectAttachedFilePaths(currentFile, references); + stagePendingAttachedFiles(pendingAttachedFiles); + if (conversationState == ConversationState.CONTINUED_CONVERSATION) { + // conversationId is already the real one — flush pending into registry + flushPendingAttachedFiles(this.conversationId); // Continue existing conversation - persist user message and send to existing conversation if (persistenceManager != null) { - this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(conversationId, null, processedMessage, + this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(conversationId, null, userMessageToPersist, activeModel, chatModeName, customChatModeId, currentFile, references); } @@ -1080,7 +1144,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl // Load turns from the history conversation and persist user turn with current conversation ID turns = persistenceManager.loadConversationTurns(this.conversationId); this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(this.conversationId, null, - processedMessage, activeModel, chatModeName, customChatModeId, currentFile, references); + userMessageToPersist, activeModel, chatModeName, customChatModeId, currentFile, references); // Set conversationId and last completed turnId for CLS server-side session restoration. restoredConversationId = this.conversationId; @@ -1103,7 +1167,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl // Generate a temporary ID for brand new conversation and persist user turn this.conversationId = UUID.randomUUID().toString(); this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(this.conversationId, null, - processedMessage, activeModel, chatModeName, customChatModeId, currentFile, references); + userMessageToPersist, activeModel, chatModeName, customChatModeId, currentFile, references); } List workspaceFolders = deriveWorkspaceFolders(currentFile, references); @@ -1131,6 +1195,9 @@ private void onSendInternal(String workDoneToken, String message, String agentSl CopilotCore.LOGGER.error("Error updating conversation ID in persistence manager: ", e); } + // Flush pending attached files into the real conversation ID + flushPendingAttachedFiles(newConversationId); + // Render model information in the Copilot turn widget if (result != null && StringUtils.isNotBlank(result.getModelName()) && !UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG.equals(result.getAgentSlug())) { @@ -1144,6 +1211,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } } }).exceptionally(th -> { + clearPendingAttachedFiles(); if (!ConversationUtils.isConversationCancellationThrowable(th)) { CopilotCore.LOGGER.error("Error creating new conversation with exception: ", th); displayErrorAndResetSendButton(workDoneToken, th.getMessage()); @@ -1210,6 +1278,26 @@ private String replaceWorkspaceCommand(String message) { return message; } + private ProcessedMessage processConsoleContextCommand(String message, String activeModeId) { + return ConsoleContextPromptProcessor.process(message, PreferencesUtils.isConsoleContextEnabled(), + ChatCompletionService.isConsoleContextSupportedMode(activeModeId), consoleContextService::captureActiveConsole); + } + + private TransformEditorContextPromptProcessor.ProcessedMessage processTransformContextCommand(String message, + String activeModeId) { + return TransformEditorContextPromptProcessor.process(message, PreferencesUtils.isTransformContextEnabled(), + ChatCompletionService.isTransformContextSupportedMode(activeModeId), + transformEditorContextService::captureActiveTransformContext); + } + + private TransformEditorContextPromptProcessor.ProcessedMessage autoInjectTransformContext(String message, + String activeModeId) { + return TransformEditorContextPromptProcessor.processAutoInject(message, + PreferencesUtils.isTransformContextEnabled(), + ChatCompletionService.isTransformContextSupportedMode(activeModeId), + transformEditorContextService::captureAutoTransformContext); + } + private void displayErrorAndResetSendButton(String workDoneToken, String message) { if (message == null) { message = Messages.chat_warnWidget_defaultErrorMsg; @@ -1246,8 +1334,75 @@ private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { }, parent); } + /** + * Collects absolute paths of the current file and explicitly attached + * references. The returned list is saved to the + * {@link AttachedFileRegistry} once a stable conversation ID is available. + */ + private List collectAttachedFilePaths(IFile currentFile, + List references) { + List filePaths = new ArrayList<>(); + if (currentFile != null && currentFile.getLocation() != null) { + filePaths.add(currentFile.getLocation().toOSString()); + } + if (references != null) { + for (IResource r : references) { + if (r instanceof IFile && r.getLocation() != null) { + filePaths.add(r.getLocation().toOSString()); + } + } + } + return filePaths; + } + + /** + * Stages file paths as pending in the attached file registry. + * These are immediately visible to confirmation handlers. + */ + private void stagePendingAttachedFiles(List filePaths) { + if (filePaths.isEmpty() || this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry().addPending(filePaths); + } + + /** + * Flushes pending attached files into per-conversation storage + * under the given (stable) conversation ID. + */ + private void flushPendingAttachedFiles(String conversationId) { + if (this.chatServiceManager == null + || StringUtils.isBlank(conversationId)) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry() + .flushPending(conversationId); + } + + private void clearPendingAttachedFiles() { + if (this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService != null) { + agentToolService.getAttachedFileRegistry().clearPending(); + } + } + private void clearCurrentConversation() { this.onCancel(); + clearPendingAttachedFiles(); this.hasHistory = false; this.conversationId = ""; this.conversationState = ConversationState.NEW_CONVERSATION; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java index 745f62a5..ebc307a0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -13,23 +15,34 @@ import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; import org.eclipse.swt.graphics.Font; import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SplitDropdownButton; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * Dialog to confirm tool execution. + * Dialog to confirm tool execution. Renders a title, message, optional command + * block, and action buttons driven by {@link ConfirmationContent}. The primary + * action is shown as a {@link SplitDropdownButton} with secondary actions in + * the dropdown menu. */ public class InvokeToolConfirmationDialog extends Composite { @@ -47,32 +60,77 @@ public class InvokeToolConfirmationDialog extends Composite { * The key for the action in the input map (used by debugger tool). */ private static final String ACTION_KEY = "action"; + private CompletableFuture toolConfirmationFuture; private String cancelMessage; private Label titleLbl; private Font boldFont; private Runnable titleFontChangeCallback; + private ConfirmationContent confirmationContent; + private ConfirmationAction selectedAction; /** - * Create a new confirmation dialog for tool execution. + * Create a new confirmation dialog driven by {@link ConfirmationContent}. * - * @param parent The parent composite - * @param title The title of the confirmation dialog - * @param message The message to display - * @param input The input object to pass to the tool + * @param parent the parent composite + * @param content confirmation content with title, message, and action buttons + * @param input the input object to pass to the tool */ - public InvokeToolConfirmationDialog(Composite parent, String title, String message, Object input) { + public InvokeToolConfirmationDialog(Composite parent, + ConfirmationContent content, Object input) { super(parent, SWT.BORDER | SWT.WRAP); this.setLayout(new GridLayout(1, false)); this.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - createDialogContent(title, message, input); + this.confirmationContent = content; + createDialogContent(content.getTitle(), content.getMessage(), input); this.toolConfirmationFuture = new CompletableFuture<>(); } - private void createDialogContent(String title, String message, Object input) { - // Title of the confirmation dialog + /** + * Returns the action the user selected, or {@code null} if dismissed. + */ + public ConfirmationAction getSelectedAction() { + return selectedAction; + } + + /** + * Get the future that will be completed when the user makes a choice. + * + * @return CompletableFuture containing the result of user's choice + */ + public CompletableFuture getConfirmationFuture() { + return toolConfirmationFuture; + } + + /** + * Cancels the current tool confirmation dialog programmatically. This has + * the same effect as clicking the Cancel / Skip button. + */ + public void cancelConfirmation() { + if (toolConfirmationFuture != null && !toolConfirmationFuture.isDone()) { + toolConfirmationFuture.complete( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + + Composite parent = this.getParent(); + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (parent != null && !parent.isDisposed() + && StringUtils.isNotEmpty(this.cancelMessage)) { + new AgentToolCancelLabel(parent, SWT.NONE, this.cancelMessage); + } + this.dispose(); + if (parent != null && !parent.isDisposed()) { + parent.requestLayout(); + } + }, this); + } + } + + // --------------- content creation --------------- + + private void createDialogContent(String title, String message, + Object input) { titleLbl = new Label(this, SWT.LEFT | SWT.WRAP); titleLbl.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); titleLbl.setText(title); @@ -93,160 +151,177 @@ private void createDialogContent(String title, String message, Object input) { } }); - // Confirmation message of the confirmation dialog Label messageLbl = new Label(this, SWT.LEFT | SWT.WRAP); - GridData messageGridData = new GridData(SWT.FILL, SWT.FILL, true, false); - messageLbl.setLayoutData(messageGridData); - messageLbl.setText(message); + messageLbl.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + messageLbl.setText(message != null ? message : ""); registerControlForFontUpdates(messageLbl); - // More information about the tool invocation - if (input != null) { - Map inputMap = (Map) input; - - // For debugger tool, show all input parameters - if (inputMap.containsKey(ACTION_KEY)) { - String displayText = formatDebuggerInput(inputMap); - - // Create a scrollable container for the input text - ScrolledComposite commandScroll = new ScrolledComposite(this, SWT.H_SCROLL | SWT.V_SCROLL); - commandScroll.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - commandScroll.setExpandHorizontal(true); - commandScroll.setExpandVertical(true); - - Label commandLbl = new Label(commandScroll, SWT.LEFT); - // Escape & characters that are followed by non-space characters, needed for SWT labels where & is used as a - // mnemonic character - String escapedCommand = displayText.replace("&", "&&"); - commandLbl.setText(escapedCommand); - commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); - this.cancelMessage = escapedCommand; - registerControlForFontUpdates(commandLbl); - - commandScroll.setContent(commandLbl); - commandScroll.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } - }); - // Initial size computation - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } else if (inputMap.containsKey(COMMAND_KEY)) { - // For terminal tool, show command - // Create a scrollable container for the command text - ScrolledComposite commandScroll = new ScrolledComposite(this, SWT.H_SCROLL); - commandScroll.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - commandScroll.setExpandHorizontal(true); - commandScroll.setExpandVertical(true); - - Label commandLbl = new Label(commandScroll, SWT.LEFT); - String command = (String) inputMap.get(COMMAND_KEY); - // Escape & characters that are followed by non-space characters, needed for SWT labels where & is used as a - // mnemonic character - String escapedCommand = command.replace("&", "&&"); - commandLbl.setText(escapedCommand); - commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); - this.cancelMessage = escapedCommand; - registerControlForFontUpdates(commandLbl); - - commandScroll.setContent(commandLbl); - commandScroll.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } - }); - // Initial size computation + createInputContent(input); + createActionButtons(); + } + + @SuppressWarnings("unchecked") + private void createInputContent(Object input) { + if (input == null) { + return; + } + Map inputMap = (Map) input; + + if (inputMap.containsKey(ACTION_KEY)) { + createScrollableCommand(formatDebuggerInput(inputMap), + SWT.H_SCROLL | SWT.V_SCROLL); + } else if (inputMap.containsKey(COMMAND_KEY)) { + createScrollableCommand((String) inputMap.get(COMMAND_KEY), + SWT.H_SCROLL); + } + + if (inputMap.containsKey(EXPLANATION_KEY)) { + Label explanationLbl = new Label(this, SWT.LEFT | SWT.WRAP); + explanationLbl.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + explanationLbl.setText((String) inputMap.get(EXPLANATION_KEY)); + registerControlForFontUpdates(explanationLbl); + } + } + + private void createScrollableCommand(String text, int scrollStyle) { + ScrolledComposite commandScroll = + new ScrolledComposite(this, scrollStyle); + commandScroll.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + commandScroll.setExpandHorizontal(true); + commandScroll.setExpandVertical(true); + + Label commandLbl = new Label(commandScroll, SWT.LEFT); + String escapedCommand = text.replace("&", "&&"); + commandLbl.setText(escapedCommand); + commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); + this.cancelMessage = escapedCommand; + registerControlForFontUpdates(commandLbl); + + commandScroll.setContent(commandLbl); + commandScroll.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); commandLbl.setSize(size); commandScroll.setMinSize(size); } + }); + Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); + commandLbl.setSize(size); + commandScroll.setMinSize(size); + } + + // --------------- action buttons with dropdown --------------- - if (inputMap.containsKey(EXPLANATION_KEY)) { - Label explanationLbl = new Label(this, SWT.LEFT | SWT.WRAP); - explanationLbl.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - explanationLbl.setText((String) inputMap.get(EXPLANATION_KEY)); - registerControlForFontUpdates(explanationLbl); + private void createActionButtons() { + List actions = confirmationContent.getActions(); + + ConfirmationAction primaryAction = null; + ConfirmationAction dismissAction = null; + List dropdownActions = new ArrayList<>(); + + for (ConfirmationAction action : actions) { + if (!action.isAccept()) { + dismissAction = action; + } else if (action.isPrimary()) { + primaryAction = action; + } else { + dropdownActions.add(action); } } - createButtons(); - } + if (primaryAction == null) { + return; + } - private void createButtons() { - GridLayout actionLayout = new GridLayout(2, false); - actionLayout.marginLeft = 0; - actionLayout.marginRight = 0; - actionLayout.marginWidth = 0; - actionLayout.horizontalSpacing = 0; - actionLayout.marginHeight = 0; - Composite actionArea = new Composite(this, SWT.NONE); - actionArea.setLayout(actionLayout); - actionArea.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - - Button continueButton = new Button(actionArea, SWT.PUSH); - continueButton.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); - continueButton.setText("Continue"); - continueButton.addListener(SWT.Selection, e -> { - this.toolConfirmationFuture.complete(new LanguageModelToolConfirmationResult(ToolConfirmationResult.ACCEPT)); - - // Store parent reference before disposal - Composite parent = this.getParent(); - this.dispose(); - // Check if parent is still valid before using it - if (parent != null && !parent.isDisposed()) { - parent.layout(); + // Column count: primary dropdown button + dismiss + Composite actionArea = newButtonArea(2); + + // --- primary dropdown button --- + SplitDropdownButton primaryDropdown = + new SplitDropdownButton(actionArea, SWT.PUSH); + primaryDropdown.setText(primaryAction.getLabel()); + primaryDropdown.setShowArrow(!dropdownActions.isEmpty()); + primaryDropdown.setSeparatorColor( + getDisplay().getSystemColor(SWT.COLOR_WHITE)); + + Button primaryBtn = primaryDropdown.getButton(); + primaryBtn.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + primaryBtn.setLayoutData( + new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + registerControlForFontUpdates(primaryBtn); + + final ConfirmationAction primaryRef = primaryAction; + primaryDropdown.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (e.detail == SWT.ARROW && !dropdownActions.isEmpty()) { + Menu menu = new Menu(primaryBtn.getShell(), SWT.POP_UP); + for (ConfirmationAction action : dropdownActions) { + MenuItem item = new MenuItem(menu, SWT.PUSH); + item.setText(action.getLabel()); + item.addListener(SWT.Selection, + ev -> acceptAndDispose(action)); + } + menu.addListener(SWT.Hide, ev -> { + ev.display.asyncExec(menu::dispose); + }); + Rectangle bounds = primaryBtn.getBounds(); + Point loc = primaryBtn.getParent() + .toDisplay(bounds.x, bounds.y + bounds.height); + menu.setLocation(loc); + menu.setVisible(true); + } else { + acceptAndDispose(primaryRef); + } } }); - continueButton.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); - registerControlForFontUpdates(continueButton); - Button cancelButton = new Button(actionArea, SWT.PUSH); - cancelButton.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); - cancelButton.setText("Cancel"); - cancelButton.addListener(SWT.Selection, e -> { + // --- dismiss (skip) button --- + Button dismissBtn = new Button(actionArea, SWT.PUSH); + dismissBtn.setLayoutData( + new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + dismissBtn.setText( + dismissAction != null ? dismissAction.getLabel() + : Messages.confirmation_action_skip); + registerControlForFontUpdates(dismissBtn); + + final ConfirmationAction dismissRef = dismissAction; + dismissBtn.addListener(SWT.Selection, e -> { + this.selectedAction = dismissRef; cancelConfirmation(); }); - registerControlForFontUpdates(cancelButton); } - /** - * Get the future that will be completed when the user makes a choice. - * - * @return CompletableFuture containing the result of user's choice - */ - public CompletableFuture getConfirmationFuture() { - return toolConfirmationFuture; + // --------------- helpers --------------- + + private Composite newButtonArea(int columns) { + GridLayout layout = new GridLayout(columns, false); + layout.marginLeft = 0; + layout.marginRight = 0; + layout.marginWidth = 0; + layout.horizontalSpacing = 0; + layout.marginHeight = 0; + + Composite area = new Composite(this, SWT.NONE); + area.setLayout(layout); + area.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + return area; } - /** - * Cancels the current tool confirmation dialog programmatically. This has the same effect as clicking the Cancel - * button in the confirmation dialog. - */ - public void cancelConfirmation() { - if (toolConfirmationFuture != null && !toolConfirmationFuture.isDone()) { - toolConfirmationFuture.complete(new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + private void acceptAndDispose(ConfirmationAction action) { + this.selectedAction = action; + this.toolConfirmationFuture.complete( + new LanguageModelToolConfirmationResult( + ToolConfirmationResult.ACCEPT)); - // Store parent reference before disposal - Composite parent = this.getParent(); - SwtUtils.invokeOnDisplayThread(() -> { - // Only show the cancel widget for special cases when the tool has a parameter "command" in the input map - if (StringUtils.isNotEmpty(this.cancelMessage)) { - new AgentToolCancelLabel(this.getParent(), SWT.NONE, this.cancelMessage); - } - this.dispose(); - // Check if parent is still valid before using it - if (parent != null && !parent.isDisposed()) { - parent.layout(); - } - }, this); + Composite parent = this.getParent(); + this.dispose(); + if (parent != null && !parent.isDisposed()) { + parent.requestLayout(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index 9c905913..f1a06d18 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -41,6 +41,39 @@ public final class Messages extends NLS { public static String thinking_expandTooltip; public static String thinking_collapseTooltip; + // Confirmation dialog action labels + public static String confirmation_action_allowOnce; + public static String confirmation_action_skip; + public static String confirmation_action_allowAllCommands; + public static String confirmation_action_allowNamesSession; + public static String confirmation_action_alwaysAllowNames; + public static String confirmation_action_allowExactSession; + public static String confirmation_action_alwaysAllowExact; + public static String confirmation_action_alwaysAllow; + public static String confirmation_action_allowFileSession; + public static String confirmation_action_allowFolderSession; + + // MCP confirmation dialog action labels + public static String confirmation_title_mcpTool; + public static String confirmation_title_mcpToolDefault; + public static String confirmation_action_allowServerSession; + public static String confirmation_action_alwaysAllowServer; + + // Confirmation dialog titles + public static String confirmation_title_terminal; + public static String confirmation_title_fallback; + public static String confirmation_title_fileRead; + public static String confirmation_title_fileWrite; + public static String confirmation_title_fileOperation; + + // Confirmation dialog messages + public static String confirmation_message_fileRead; + public static String confirmation_message_fileWrite; + public static String confirmation_message_fileOperation; + + // Misc + public static String confirmation_autoApprovedDescription; + static { // initialize resource bundle NLS.initializeMessages(BUNDLE_NAME, Messages.class); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java new file mode 100644 index 00000000..bb548c77 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; + +/** + * Tracks files explicitly attached by the user in the context panel. + * + *

New files are first stored in a {@code pending} set (not yet bound + * to any conversation ID). The confirmation handler checks pending files + * first, so auto-approve works even before the real conversation ID + * arrives from CLS. Once the real ID is known, call + * {@link #flushPending(String)} to move them into per-conversation + * storage. + */ +public class AttachedFileRegistry { + + private final Map> attachedPaths = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** Files waiting for a stable conversation ID. */ + private final Set pendingFiles = + Collections.synchronizedSet(new HashSet<>()); + + /** + * Stages file paths for auto-approve before the conversation ID is + * known. These are checked by {@link #isAttachedFile} immediately. + */ + public void addPending(Collection filePaths) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .forEach(pendingFiles::add); + } + + /** + * Moves pending files into per-conversation storage under the given + * conversation ID, then clears the pending set. + */ + public void flushPending(String conversationId) { + if (StringUtils.isBlank(conversationId) || pendingFiles.isEmpty()) { + return; + } + Set flushed; + synchronized (pendingFiles) { + flushed = new HashSet<>(pendingFiles); + pendingFiles.clear(); + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, flushed, (a, b) -> { + Set merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Records files for an existing conversation (continued turns). + */ + public void addAttachedFiles(String conversationId, + Collection filePaths) { + if (filePaths == null || filePaths.isEmpty() + || StringUtils.isBlank(conversationId)) { + return; + } + Set keys = filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .collect(Collectors.toSet()); + if (keys.isEmpty()) { + return; + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, keys, (a, b) -> { + Set merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Returns {@code true} when the given file was explicitly attached + * by the user — either in the pending set or for the given conversation. + */ + public boolean isAttachedFile(String conversationId, String filePath) { + if (StringUtils.isBlank(filePath)) { + return false; + } + String key = toComparisonKey(filePath); + // Check pending files first (before conversation ID is known) + if (pendingFiles.contains(key)) { + return true; + } + Set paths = attachedPaths.get(conversationId); + return paths != null && paths.contains(key); + } + + /** Removes all tracked data for a conversation. */ + public void clearConversation(String conversationId) { + attachedPaths.remove(conversationId); + } + + /** Discards any pending (pre-conversation) files. */ + public void clearPending() { + pendingFiles.clear(); + } + + private void evictOldestIfNeeded() { + synchronized (attachedPaths) { + while (attachedPaths.size() >= ConfirmationHandler.MAX_SESSION_CONVERSATIONS) { + var it = attachedPaths.entrySet().iterator(); + if (it.hasNext()) { + it.next(); + it.remove(); + } + } + } + } + + private static String toComparisonKey(String path) { + return ConfirmationHandler.normalizePath(path); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java new file mode 100644 index 00000000..3e1e2e1a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Evaluates whether a tool confirmation request can be auto-approved. + * Each implementation handles a specific category of tool (terminal, file operations, MCP, etc.). + */ +public interface ConfirmationHandler { + + /** Maximum number of conversations tracked in session memory. */ + int MAX_SESSION_CONVERSATIONS = 50; + + /** + * Normalizes a file path for case-insensitive, separator-agnostic comparison. + * Converts backslashes to forward slashes and lowercases the result. + * + * @param path the file path to normalize + * @return the normalized path + */ + static String normalizePath(String path) { + return path.replace('\\', '/').toLowerCase(Locale.ROOT); + } + + /** + * Extracts the {@code toolType} string from the input map of a confirmation request. + * Returns {@code null} if the field is absent or not a string. + * + * @param params the confirmation request parameters from CLS + * @return the toolType value, or {@code null} + */ + static String extractToolType(InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object toolType = inputMap.get("toolType"); + if (toolType instanceof String) { + return (String) toolType; + } + } + return null; + } + + /** + * Evaluates whether the given confirmation request should be auto-approved, + * taking into account whether the auto-approval feature is enabled by token/policy. + * + * @param params the confirmation request parameters from CLS + * @param sessionConversationId the conversation ID to use for session-scoped + * lookups (may differ from params.getConversationId() when called from a + * subagent context) + * @param isAutoApprovalEnabled whether the auto-approval feature is currently enabled + * @return the confirmation result + */ + ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled); + + /** + * Caches a user's decision based on the action scope. + * SESSION actions are stored in-memory per conversation; + * GLOBAL actions are written to preferences. + * + * @param action the user's selected action with metadata + * @param params the original confirmation params (for command data etc.) + * @param sessionConversationId the conversation ID to use for session storage + */ + default void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + // no-op by default + } + + /** Clears session-scoped approvals for the given conversation. */ + default void clearSession(String conversationId) { + // no-op by default + } + + /** + * Evicts the oldest entry from a {@link java.util.LinkedHashMap}-backed map when it reaches + * {@link #MAX_SESSION_CONVERSATIONS}. Thread-safe via {@code synchronized(map)}. + * + * @param value type + * @param map the map to evict from (must be a {@code Collections.synchronizedMap} wrapping a + * {@code LinkedHashMap} for correct eviction order) + */ + static void evictOldestIfNeeded(Map map) { + synchronized (map) { + while (map.size() >= MAX_SESSION_CONVERSATIONS) { + Entry oldest = map.entrySet().iterator().next(); + map.remove(oldest.getKey()); + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java new file mode 100644 index 00000000..50242adc --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.EnumMap; +import java.util.Map; + +import org.eclipse.jface.preference.IPreferenceStore; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Central entry point for auto-approve evaluation. Classifies each tool confirmation request + * into a category using the toolType field provided by CLS, then dispatches to the registered + * handler for that category. All session/global persist logic lives in each handler. + */ +public class ConfirmationService { + + /** Tool functional types matching CLS ToolFunctionalType enum values. */ + public enum ToolCategory { + TERMINAL("terminal"), + FILE_READ("file_read"), + FILE_WRITE("file_write"), + FILE_OPERATION("file_operation"), + MCP_TOOL("mcp_tool"), + SAFE_TOOL("safe_tool"), + WEB("web"), + UNKNOWN("unknown"); + + private final String value; + + ToolCategory(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + /** + * Resolves a CLS toolType string to a ToolCategory. + */ + public static ToolCategory fromValue(String value) { + if (value != null) { + for (ToolCategory category : values()) { + if (category.value.equals(value)) { + return category; + } + } + } + return UNKNOWN; + } + } + + private final Map handlers = + new EnumMap<>(ToolCategory.class); + private final ConfirmationHandler fallbackHandler = + new FallbackConfirmationHandler(); + private final IPreferenceStore preferenceStore; + + /** + * Creates a new ConfirmationService. + * + * @param preferenceStore the preference store for reading auto-approve settings + * @param attachedFileRegistry registry of user-attached context files + */ + public ConfirmationService(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { + this.preferenceStore = preferenceStore; + handlers.put(ToolCategory.TERMINAL, + new TerminalConfirmationHandler(preferenceStore)); + FileOperationConfirmationHandler fileHandler = + new FileOperationConfirmationHandler(preferenceStore, + attachedFileRegistry); + handlers.put(ToolCategory.FILE_READ, fileHandler); + handlers.put(ToolCategory.FILE_WRITE, fileHandler); + handlers.put(ToolCategory.FILE_OPERATION, fileHandler); + handlers.put(ToolCategory.MCP_TOOL, + new McpConfirmationHandler(preferenceStore)); + } + + /** + * Evaluates whether a tool confirmation request should be auto-approved. + * + *

When the auto-approval feature is disabled by token or organization policy, all + * auto-approve rules (YOLO, session, global) are bypassed and the user is always prompted + * with a simple Allow-Once / Skip dialog. + * + * @param params the confirmation request parameters + * @param sessionConversationId the conversation ID for session-scoped lookups + */ + public ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + boolean autoApprovalEnabled = flags == null || flags.isAutoApprovalEnabled(); + + if (autoApprovalEnabled && preferenceStore.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)) { + return ConfirmationResult.AUTO_APPROVED; + } + + ToolCategory category = classify(params); + if (category == ToolCategory.SAFE_TOOL) { + return ConfirmationResult.AUTO_APPROVED; + } + + ConfirmationHandler handler = handlers.getOrDefault(category, fallbackHandler); + return handler.evaluate(params, sessionConversationId, autoApprovalEnabled); + } + + /** + * Caches the user's decision for future auto-approve lookups. + * + * @param action the user's selected action + * @param params the original confirmation params + * @param sessionConversationId the conversation ID for session storage + */ + public void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + if (action == null || action.getScope() == null + || action.getScope() == ConfirmationActionScope.ONCE) { + return; + } + ToolCategory category = classify(params); + ConfirmationHandler handler = handlers.get(category); + if (handler != null) { + handler.cacheDecision(action, params, sessionConversationId); + } + } + + /** Clears session-scoped approvals for a conversation across all handlers. */ + public void clearSession(String conversationId) { + for (ConfirmationHandler handler : handlers.values()) { + handler.clearSession(conversationId); + } + } + + ToolCategory classify(InvokeClientToolConfirmationParams params) { + return ToolCategory.fromValue(ConfirmationHandler.extractToolType(params)); + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java new file mode 100644 index 00000000..d58c77e7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.List; + +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Catch-all handler for tool types that have no dedicated handler registered. + * Always shows a simple confirmation dialog with Allow Once / Skip. + */ +public class FallbackConfirmationHandler implements ConfirmationHandler { + + @Override + public ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + String title = params.getTitle() != null + ? params.getTitle() + : NLS.bind(Messages.confirmation_title_fallback, params.getName()); + return ConfirmationResult.needsConfirmation( + new ConfirmationContent(title, params.getMessage(), + List.of( + ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce), + ConfirmationAction.skip( + Messages.confirmation_action_skip)))); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java new file mode 100644 index 00000000..313c56c2 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java @@ -0,0 +1,457 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates file-operation confirmation requests against user-configured glob pattern rules. + * Files outside the workspace always require confirmation. + */ +public class FileOperationConfirmationHandler implements ConfirmationHandler { + + /** Action types for edit confirmation. */ + public enum Action { + /** Allow this specific file for the rest of the session. */ + ACCEPT_FILE_SESSION, + /** Always allow this specific file (persisted globally). */ + ACCEPT_FILE_GLOBAL, + /** Allow all files under this folder for the session. */ + ACCEPT_FOLDER_SESSION + } + + /** File tool type matching CLS values. */ + enum FileToolType { + FILE_READ("file_read"), + FILE_WRITE("file_write"), + FILE_OPERATION("file_operation"); + + private final String value; + + FileToolType(String value) { + this.value = value; + } + + String getDefaultTitle() { + switch (this) { + case FILE_READ: + return Messages.confirmation_title_fileRead; + case FILE_WRITE: + return Messages.confirmation_title_fileWrite; + default: + return Messages.confirmation_title_fileOperation; + } + } + + String getMessageTemplate() { + switch (this) { + case FILE_READ: + return Messages.confirmation_message_fileRead; + case FILE_WRITE: + return Messages.confirmation_message_fileWrite; + default: + return Messages.confirmation_message_fileOperation; + } + } + + static FileToolType fromValue(String value) { + if (value != null) { + for (FileToolType t : values()) { + if (t.value.equals(value)) { + return t; + } + } + } + return FILE_OPERATION; + } + } + + static final String META_FILE_PATH = "filePath"; + static final String META_FOLDER_PATH = "folderPath"; + + /** + * Local fallback defaults matching CLS + * {@code FileSafetyRulesService.defaultRules}. + * Used when CLS is not yet available. + */ + public static final List FALLBACK_DEFAULT_RULES = List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", + "GitHub instructions files", false, true), + new FileOperationAutoApproveRule("**/github-copilot/**/*", + "GitHub Copilot settings and token files", false, true)); + + private static final Type RULES_TYPE = + new TypeToken>() { + }.getType(); + + private final IPreferenceStore preferenceStore; + private final AttachedFileRegistry attachedFileRegistry; + private final Map> sessionApprovedFiles = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> sessionApprovedFolders = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new FileOperationConfirmationHandler. + * + * @param preferenceStore the preference store for reading file-operation auto-approve rules + * @param attachedFileRegistry registry of user-attached files for auto-approval + */ + public FileOperationConfirmationHandler(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { + this.preferenceStore = preferenceStore; + this.attachedFileRegistry = attachedFileRegistry; + } + + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params, sessionConversationId); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Auto-approve files explicitly attached by the user in the context panel + if (attachedFileRegistry.isAttachedFile( + sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Session override — file level + String convId = sessionConversationId; + Set convFiles = sessionApprovedFiles.get(convId); + if (convFiles != null && convFiles.contains(normalizePath(filePath))) { + return ConfirmationResult.AUTO_APPROVED; + } + // Session override — folder level + Set convFolders = sessionApprovedFolders.get(convId); + if (convFolders != null && filePath != null) { + String normalizedPath = normalizePath(filePath); + for (String folder : convFolders) { + if (normalizedPath.startsWith(folder + "/")) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // Files outside workspace always require confirmation + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + // Check against rules + List rules = loadRules(); + for (FileOperationAutoApproveRule rule : rules) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildContent(params)); + } + } + + return evaluateUnmatched(params); + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params, String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Still honor files explicitly attached by the user (intentional context) + if (attachedFileRegistry.isAttachedFile(sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Outside workspace always requires confirmation (simplified dialog only) + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + + // Only check default rules; ignore user-configured rules + for (FileOperationAutoApproveRule rule : FALLBACK_DEFAULT_RULES) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + } + + // Unmatched workspace file: auto-approve + return ConfirmationResult.AUTO_APPROVED; + } + + private ConfirmationResult evaluateUnmatched( + InvokeClientToolConfirmationParams params) { + if (preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)) { + return ConfirmationResult.AUTO_APPROVED; + } + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String safeFilePath = filePath != null ? filePath : ""; + boolean outsideWorkspace = isOutsideWorkspace(params); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (outsideWorkspace && filePath != null) { + // Outside workspace: offer folder-level approval + Path parent = Path.of(filePath).getParent(); + String folderName = (parent != null && parent.getFileName() != null) + ? parent.getFileName().toString() + : (parent != null ? parent.toString() : filePath); + String folderPath = parent != null ? parent.toString() : ""; + actions.add(action(Action.ACCEPT_FOLDER_SESSION, + NLS.bind(Messages.confirmation_action_allowFolderSession, + folderName), + ConfirmationActionScope.SESSION, + Map.of(META_FOLDER_PATH, folderPath))); + } else { + // In workspace: offer file-level approval + actions.add(action(Action.ACCEPT_FILE_SESSION, + Messages.confirmation_action_allowFileSession, + ConfirmationActionScope.SESSION, + Map.of(META_FILE_PATH, safeFilePath))); + actions.add(action(Action.ACCEPT_FILE_GLOBAL, + Messages.confirmation_action_alwaysAllow, + ConfirmationActionScope.GLOBAL, + Map.of(META_FILE_PATH, safeFilePath))); + } + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title = params.getTitle() != null + ? params.getTitle() : fileType.getDefaultTitle(); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, actions); + } + + /** + * Builds a simplified confirmation dialog with only Allow Once and Skip actions. + * Used when the auto-approval feature is disabled by token/policy. + */ + private ConfirmationContent buildSimplifiedContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String title = params.getTitle() != null ? params.getTitle() : fileType.getDefaultTitle(); + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip))); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractFilePath(InvokeClientToolConfirmationParams params) { + // Try toolMetadata first + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().getFilePath(); + } + // Fallback: extract from input map + Object input = params.getInput(); + if (input instanceof Map) { + Object path = ((Map) input).get("filePath"); + if (path == null) { + path = ((Map) input).get("path"); + } + return path instanceof String ? (String) path : null; + } + return null; + } + + // Uses CLS-provided isGlobal flag from sensitiveFileData metadata + private boolean isOutsideWorkspace(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().isGlobal(); + } + return false; + } + + /** Normalizes a file path for case-insensitive, separator-agnostic comparison. */ + private static String normalizePath(String path) { + return ConfirmationHandler.normalizePath(path); + } + + static boolean matchesGlob(String filePath, String globPattern) { + if (StringUtils.isBlank(filePath) || StringUtils.isBlank(globPattern)) { + return false; + } + try { + // Normalize both path and pattern to forward slashes for consistent matching + String normalizedPath = filePath.replace('\\', '/'); + String normalizedPattern = globPattern.replace('\\', '/'); + + // Fast exact-match for absolute file path rules (e.g., from "Always Allow") + if (normalizedPath.equalsIgnoreCase(normalizedPattern)) { + return true; + } + + PathMatcher matcher = FileSystems.getDefault() + .getPathMatcher("glob:" + normalizedPattern); + return matcher.matches(Path.of(normalizedPath)); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Invalid file-operation auto-approve glob: " + globPattern, e); + return false; + } + } + + List loadRules() { + String json = + preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List rules = + new Gson().fromJson(json, RULES_TYPE); + return rules != null ? rules : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file-operation auto-approve rules", e); + return Collections.emptyList(); + } + } + + @Override + public void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = action.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + String convId = sessionConversationId; + Map meta = action.getMetadata(); + switch (type) { + case ACCEPT_FILE_SESSION: + String fp = meta.getOrDefault(META_FILE_PATH, ""); + if (!fp.isEmpty()) { + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFiles); + sessionApprovedFiles.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(fp)); + } + break; + case ACCEPT_FOLDER_SESSION: + String folder = meta.getOrDefault(META_FOLDER_PATH, ""); + if (!folder.isEmpty()) { + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFolders); + sessionApprovedFolders.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(folder)); + } + break; + case ACCEPT_FILE_GLOBAL: + String globalFp = meta.getOrDefault(META_FILE_PATH, ""); + if (!globalFp.isEmpty()) { + List rules = + new ArrayList<>(loadRules()); + // Update existing rule if path matches (case-insensitive for Windows) + boolean found = false; + for (FileOperationAutoApproveRule r : rules) { + if (r.getPattern().equalsIgnoreCase(globalFp)) { + r.setAutoApprove(true); + found = true; + break; + } + } + if (!found) { + rules.add(new FileOperationAutoApproveRule(globalFp, + Messages.confirmation_autoApprovedDescription, true)); + } + preferenceStore.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(rules)); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + sessionApprovedFiles.remove(conversationId); + sessionApprovedFolders.remove(conversationId); + attachedFileRegistry.clearConversation(conversationId); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java new file mode 100644 index 00000000..c2757223 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates MCP tool confirmation requests against session and global approval lists. + * Supports per-server and per-tool approval at both session and global scopes, + * plus optional trust-annotation-based auto-approve for read-only tools. + */ +public class McpConfirmationHandler implements ConfirmationHandler { + + /** Action types for MCP tool confirmation decisions. */ + public enum Action { + /** Allow a specific tool for the current session/conversation. */ + ACCEPT_TOOL_SESSION, + /** Always allow a specific tool (persisted globally). */ + ACCEPT_TOOL_GLOBAL, + /** Allow all tools from a server for the current session/conversation. */ + ACCEPT_SERVER_SESSION, + /** Always allow all tools from a server (persisted globally). */ + ACCEPT_SERVER_GLOBAL + } + + static final String META_SERVER_NAME = "serverName"; + static final String META_TOOL_KEY = "toolKey"; + + private static final String SEPARATOR = "::"; + private static final Type STRING_LIST_TYPE = + new TypeToken>() {}.getType(); + + private final IPreferenceStore preferenceStore; + + // Session-scoped in-memory storage keyed by conversationId. + private final Map> approvedServers = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> approvedTools = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new McpConfirmationHandler. + * + * @param preferenceStore the preference store for reading MCP auto-approve settings + */ + public McpConfirmationHandler(IPreferenceStore preferenceStore) { + this.preferenceStore = preferenceStore; + } + + /** + * When the auto-approval feature is disabled, MCP tools always prompt with + * Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where MCP ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + /** + * Evaluates an MCP tool confirmation request. Check order: + * 1. Session approved servers (by conversationId) + * 2. Session approved tools (by conversationId, key = "server::tool") + * 3. Global approved servers list + * 4. Global approved tools list + * 5. Trust annotations (readOnlyHint=true AND openWorldHint=false) + * 6. Otherwise: needs confirmation + */ + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + String serverLower = serverName != null + ? serverName.toLowerCase(Locale.ROOT) : null; + String toolKey = buildToolKey(serverLower, toolName); + + // 1. Session: server approved for this conversation + if (serverLower != null) { + Set sessionServers = + approvedServers.get(sessionConversationId); + if (sessionServers != null + && sessionServers.contains(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 2. Session: specific tool approved for this conversation + if (toolKey != null) { + Set sessionTools = + approvedTools.get(sessionConversationId); + if (sessionTools != null && sessionTools.contains(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 3. Global: server in approved servers list + if (serverLower != null) { + List globalServers = loadJsonList( + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : globalServers) { + if (s.toLowerCase(Locale.ROOT).equals(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 4. Global: tool in approved tools list + if (toolKey != null) { + List globalTools = loadJsonList( + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : globalTools) { + if (t.toLowerCase(Locale.ROOT).equals(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 5. Trust annotations: read-only and not open-world + if (preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) { + ToolAnnotations annotations = params.getAnnotations(); + if (annotations != null + && annotations.isReadOnlyHint() + && !annotations.isOpenWorldHint()) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 6. Needs confirmation + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName)); + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName, /* simplifiedOnly= */ true)); + } + + @Override + public void cacheDecision(ConfirmationAction confirmAction, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = confirmAction.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + Map meta = confirmAction.getMetadata(); + String serverName = meta.get(META_SERVER_NAME); + String toolKey = meta.get(META_TOOL_KEY); + + switch (type) { + case ACCEPT_TOOL_SESSION: + if (toolKey != null) { + synchronized (approvedTools) { + ConfirmationHandler.evictOldestIfNeeded(approvedTools); + approvedTools.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(toolKey.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_SERVER_SESSION: + if (serverName != null) { + synchronized (approvedServers) { + ConfirmationHandler.evictOldestIfNeeded(approvedServers); + approvedServers.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(serverName.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_TOOL_GLOBAL: + if (toolKey != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_TOOLS, toolKey); + } + break; + case ACCEPT_SERVER_GLOBAL: + if (serverName != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_SERVERS, serverName); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + approvedServers.remove(conversationId); + approvedTools.remove(conversationId); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName) { + return buildContent(params, serverName, toolName, false); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName, boolean simplifiedOnly) { + String toolKey = buildToolKey( + serverName != null ? serverName.toLowerCase(Locale.ROOT) : null, + toolName); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (!simplifiedOnly) { + if (toolName != null && toolKey != null) { + actions.add(action(Action.ACCEPT_TOOL_SESSION, + NLS.bind(Messages.confirmation_action_allowNamesSession, + "'" + toolName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_TOOL_KEY, toolKey))); + actions.add(action(Action.ACCEPT_TOOL_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowNames, + "'" + toolName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_TOOL_KEY, toolKey))); + } + + if (serverName != null) { + actions.add(action(Action.ACCEPT_SERVER_SESSION, + NLS.bind(Messages.confirmation_action_allowServerSession, + "'" + serverName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_SERVER_NAME, serverName))); + actions.add(action(Action.ACCEPT_SERVER_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowServer, + "'" + serverName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_SERVER_NAME, serverName))); + } + } + + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title; + if (toolName != null && serverName != null) { + title = NLS.bind(Messages.confirmation_title_mcpTool, + toolName, serverName); + } else { + title = params.getTitle() != null + ? params.getTitle() + : Messages.confirmation_title_mcpToolDefault; + } + + return new ConfirmationContent(title, params.getMessage(), actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractServerName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object value = inputMap.get("mcpServerName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private String extractToolName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object value = inputMap.get("mcpToolName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private static String buildToolKey(String serverLower, String toolName) { + if (serverLower == null || toolName == null) { + return null; + } + return serverLower + SEPARATOR + + toolName.toLowerCase(Locale.ROOT); + } + + private List loadJsonList(String preferenceKey) { + String json = preferenceStore.getString(preferenceKey); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + preferenceKey, e); + return Collections.emptyList(); + } + } + + private void addToGlobalList(String preferenceKey, String value) { + List current = new ArrayList<>(loadJsonList(preferenceKey)); + String lowerValue = value.toLowerCase(Locale.ROOT); + for (String existing : current) { + if (existing.toLowerCase(Locale.ROOT).equals(lowerValue)) { + return; // already present + } + } + current.add(value); + preferenceStore.setValue(preferenceKey, new Gson().toJson(current)); + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java new file mode 100644 index 00000000..b4641499 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java @@ -0,0 +1,547 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates terminal command confirmation requests against user-configured allow/deny rules. + * Rules are matched against command names provided by CLS in toolMetadata.terminalCommandData. + */ +public class TerminalConfirmationHandler implements ConfirmationHandler { + + /** Action types matching IntelliJ's TerminalAutoApproveAction enum. */ + public enum Action { + /** Allow specific command names for the current session/conversation. */ + ACCEPT_NAMES_SESSION, + /** Always allow specific command names (persisted as a global rule). */ + ACCEPT_NAMES_GLOBAL, + /** Allow this exact command line for the current session/conversation. */ + ACCEPT_EXACT_SESSION, + /** Always allow this exact command line (persisted as a global rule). */ + ACCEPT_EXACT_GLOBAL, + /** Allow all terminal commands for the current session/conversation. */ + ACCEPT_ALL_SESSION + } + + /** Result of evaluating sub-commands against rules. */ + enum RuleVerdict { ALL_APPROVED, DENY_BLOCKED, UNMATCHED } + + private static final class RuleResult { + final RuleVerdict verdict; + final List unapprovedItems; + + RuleResult(RuleVerdict verdict, List unapprovedItems) { + this.verdict = verdict; + this.unapprovedItems = unapprovedItems; + } + } + + static final String META_COMMAND_NAMES = "commandNames"; + static final String META_COMMAND_LINE = "commandLine"; + + /** Default deny rules for dangerous terminal commands. */ + public static final List DEFAULT_RULES = List.of( + new TerminalAutoApproveRule("rm", false), + new TerminalAutoApproveRule("rmdir", false), + new TerminalAutoApproveRule("del", false), + new TerminalAutoApproveRule("kill", false), + new TerminalAutoApproveRule("curl", false), + new TerminalAutoApproveRule("wget", false), + new TerminalAutoApproveRule("eval", false), + new TerminalAutoApproveRule("chmod", false), + new TerminalAutoApproveRule("chown", false), + new TerminalAutoApproveRule("/^Remove-Item\\b/i", false), + new TerminalAutoApproveRule("/(\\(.+\\))/s", false), + new TerminalAutoApproveRule("/`.+`/s", false), + new TerminalAutoApproveRule("/\\{.+\\}/s", false)); + + private static final Type RULES_TYPE = new TypeToken>() { + }.getType(); + + private final IPreferenceStore preferenceStore; + + // Session-scoped in-memory storage keyed by conversationId. + // Uses insertion-ordered maps so we can evict the oldest entry when the + // map grows beyond MAX_SESSION_CONVERSATIONS. + private final Map> allowedCommandNames = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map> allowedExactCommands = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Set allowAllConversations = + Collections.newSetFromMap( + Collections.synchronizedMap(new LinkedHashMap<>())); + + /** + * Creates a new TerminalConfirmationHandler. + * + * @param preferenceStore the preference store for reading terminal auto-approve rules + */ + public TerminalConfirmationHandler(IPreferenceStore preferenceStore) { + this.preferenceStore = preferenceStore; + } + + /** + * When the auto-approval feature is disabled, terminal commands always prompt + * with Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where terminal ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + /** + * Evaluates a terminal confirmation request. Check order follows IntelliJ: + * 1. Session "allow all" flag + * 2. Session exact commandLine match + * 3. Session command name match (all names must be approved) + * 4. Global exact commandLine match against rules + * 5. Global per-subCommand regex/prefix match against rules + * 6. Unmatched fallback (auto-approve if preference enabled) + */ + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String convId = sessionConversationId; + String commandLine = extractCommandLine(params); + + // 1. Session: all commands allowed for this conversation + if (allowAllConversations.contains(convId)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // 2. Session: exact commandLine previously approved + Set exactSet = allowedExactCommands.get(convId); + if (commandLine != null && exactSet != null + && exactSet.contains(commandLine.trim())) { + return ConfirmationResult.AUTO_APPROVED; + } + + // 3. Session: all command names (e.g. "tree", "echo") approved + String[] cmdNames = getCommandNames(params); + Set namesSet = allowedCommandNames.get(convId); + if (cmdNames != null && namesSet != null && cmdNames.length > 0) { + boolean allApproved = true; + for (String name : cmdNames) { + if (!namesSet.contains(name)) { + allApproved = false; + break; + } + } + if (allApproved) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 4-6. Global rules + String[] subCommands = getSubCommands(params); + if (subCommands == null || subCommands.length == 0) { + return ConfirmationResult.needsConfirmation( + buildContent(params, null)); + } + + List rules = loadRules(); + + // 4. Exact commandLine match + if (commandLine != null) { + for (TerminalAutoApproveRule rule : rules) { + if (commandLine.trim().equals(rule.getCommand().trim()) + && rule.isAutoApprove()) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 5. Per-subCommand evaluation + RuleResult result = evaluateSubCommands( + subCommands, cmdNames, rules, namesSet); + + switch (result.verdict) { + case ALL_APPROVED: + return ConfirmationResult.AUTO_APPROVED; + case DENY_BLOCKED: + return ConfirmationResult.needsConfirmation( + buildContent(params, result.unapprovedItems)); + case UNMATCHED: + default: + // 6. Unmatched fallback + if (preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)) { + return ConfirmationResult.AUTO_APPROVED; + } + List items = result.unapprovedItems.isEmpty() + ? null : result.unapprovedItems; + return ConfirmationResult.needsConfirmation( + buildContent(params, items)); + } + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String title = params.getTitle() != null + ? params.getTitle() : Messages.confirmation_title_terminal; + return ConfirmationResult.needsConfirmation( + new ConfirmationContent(title, params.getMessage(), + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip)))); + } + + /** + * Evaluates each sub-command against global rules and session state. + * Returns a verdict and the list of unapproved command names. + */ + private RuleResult evaluateSubCommands(String[] subCommands, + String[] cmdNames, List rules, + Set sessionApprovedNames) { + boolean allApproved = true; + boolean hasDeny = false; + List unapproved = new ArrayList<>(); + + for (int i = 0; i < subCommands.length; i++) { + String subCommand = subCommands[i]; + String cmdName = cmdNames != null && i < cmdNames.length + ? cmdNames[i] : null; + boolean hasAllow = false; + boolean denied = false; + + // Session command-name approval + if (cmdName != null && sessionApprovedNames != null + && sessionApprovedNames.contains(cmdName)) { + hasAllow = true; + } + + // Global rule matching + for (TerminalAutoApproveRule rule : rules) { + if (matchesRule(subCommand, rule.getCommand())) { + if (rule.isAutoApprove()) { + hasAllow = true; + } else { + denied = true; + } + break; + } + } + + if (denied && !hasAllow) { + hasDeny = true; + if (cmdName != null && !unapproved.contains(cmdName)) { + unapproved.add(cmdName); + } + } else if (!hasAllow) { + allApproved = false; + if (cmdName != null && !unapproved.contains(cmdName)) { + unapproved.add(cmdName); + } + } + } + + RuleVerdict verdict; + if (hasDeny) { + verdict = RuleVerdict.DENY_BLOCKED; + } else if (allApproved) { + verdict = RuleVerdict.ALL_APPROVED; + } else { + verdict = RuleVerdict.UNMATCHED; + } + return new RuleResult(verdict, unapproved); + } + + /** + * Builds the confirmation dialog content. When {@code unapprovedNames} + * is non-null, only those names appear in the command-name actions + * (already-approved names are filtered out). + */ + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + List unapprovedNames) { + final String[] commandNames = getCommandNames(params); + String commandLine = extractCommandLine(params); + + // Use unapproved names if available, otherwise all unique names + List uniqueNames = unapprovedNames != null + ? unapprovedNames : dedup(commandNames); + String label = !uniqueNames.isEmpty() + ? "'" + String.join(", ", uniqueNames) + "'" : "'command'"; + String namesValue = String.join(",", uniqueNames); + + // Show exact command actions when commandLine differs from + // a single command name (otherwise redundant). + boolean showExact = commandLine != null + && !(uniqueNames.size() == 1 + && commandLine.trim().equals(uniqueNames.get(0))); + + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce)); + if (!uniqueNames.isEmpty()) { + actions.add(action(Action.ACCEPT_NAMES_SESSION, + NLS.bind(Messages.confirmation_action_allowNamesSession, label), + ConfirmationActionScope.SESSION, + Map.of(META_COMMAND_NAMES, namesValue))); + actions.add(action(Action.ACCEPT_NAMES_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowNames, label), + ConfirmationActionScope.GLOBAL, + Map.of(META_COMMAND_NAMES, namesValue))); + } + if (showExact) { + actions.add(action(Action.ACCEPT_EXACT_SESSION, + Messages.confirmation_action_allowExactSession, + ConfirmationActionScope.SESSION, + Map.of(META_COMMAND_LINE, commandLine))); + actions.add(action(Action.ACCEPT_EXACT_GLOBAL, + Messages.confirmation_action_alwaysAllowExact, + ConfirmationActionScope.GLOBAL, + Map.of(META_COMMAND_LINE, commandLine))); + } + actions.add(action(Action.ACCEPT_ALL_SESSION, + Messages.confirmation_action_allowAllCommands, + ConfirmationActionScope.SESSION, Map.of())); + actions.add(ConfirmationAction.skip(Messages.confirmation_action_skip)); + + String title = params.getTitle() != null + ? params.getTitle() : Messages.confirmation_title_terminal; + return new ConfirmationContent(title, params.getMessage(), actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map extra) { + Map meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractCommandLine( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + Object cmd = inputMap.get("command"); + if (cmd instanceof String) { + return (String) cmd; + } + } + return null; + } + + private static List dedup(String[] items) { + if (items == null || items.length == 0) { + return Collections.emptyList(); + } + LinkedHashSet set = new LinkedHashSet<>(); + for (String item : items) { + if (item != null && !item.isBlank()) { + set.add(item); + } + } + return new ArrayList<>(set); + } + + private String[] getSubCommands(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getTerminalCommandData() != null) { + return metadata.getTerminalCommandData().getSubCommands(); + } + return null; + } + + private String[] getCommandNames(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getTerminalCommandData() != null) { + return metadata.getTerminalCommandData().getCommandNames(); + } + return null; + } + + /** + * Matches a sub-command against a rule. Exact string match is checked + * first (for exact-command rules). Then regex rules (/pattern/flags) are + * used directly, and simple rules (e.g., "rm") are converted to ^rm\b. + */ + static boolean matchesRule(String subCommand, String rulePattern) { + if (StringUtils.isBlank(subCommand) + || StringUtils.isBlank(rulePattern)) { + return false; + } + + // Exact match first + if (subCommand.trim().equals(rulePattern.trim())) { + return true; + } + + String regex; + int regexFlags = 0; + + if (rulePattern.startsWith("/") + && rulePattern.lastIndexOf('/') > 0) { + // Explicit regex: "/^git\b/i" + int lastSlash = rulePattern.lastIndexOf('/'); + regex = rulePattern.substring(1, lastSlash); + String flags = rulePattern.substring(lastSlash + 1); + if (flags.contains("i")) { + regexFlags |= Pattern.CASE_INSENSITIVE; + } + if (flags.contains("s")) { + regexFlags |= Pattern.DOTALL; + } + } else { + // Simple rule: "rm" → "^rm\b" + regex = "^" + Pattern.quote(rulePattern) + "\\b"; + } + + try { + return Pattern.compile(regex, regexFlags) + .matcher(subCommand).find(); + } catch (PatternSyntaxException e) { + CopilotCore.LOGGER.error( + "Invalid terminal auto-approve regex: " + rulePattern, e); + return false; + } + } + + List loadRules() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List rules = new Gson().fromJson(json, RULES_TYPE); + return rules != null ? rules : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse terminal auto-approve rules", e); + return Collections.emptyList(); + } + } + + @Override + public void cacheDecision(ConfirmationAction confirmAction, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = confirmAction.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + String convId = sessionConversationId; + + // Prefer command data from action metadata (set by buildContent) + // so we persist exactly what the user chose, not the full params. + Map meta = confirmAction.getMetadata(); + String metaNames = meta.get(META_COMMAND_NAMES); + String metaLine = meta.get(META_COMMAND_LINE); + + String[] cmdNames = metaNames != null && !metaNames.isBlank() + ? metaNames.split(",") : getCommandNames(params); + String commandLine = metaLine != null && !metaLine.isBlank() + ? metaLine : extractCommandLine(params); + + switch (type) { + case ACCEPT_NAMES_SESSION: + if (cmdNames != null) { + ConfirmationHandler.evictOldestIfNeeded(allowedCommandNames); + Set nameSet = allowedCommandNames.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()); + Collections.addAll(nameSet, cmdNames); + } + break; + case ACCEPT_EXACT_SESSION: + if (commandLine != null && !commandLine.isBlank()) { + ConfirmationHandler.evictOldestIfNeeded(allowedExactCommands); + allowedExactCommands.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(commandLine.trim()); + } + break; + case ACCEPT_ALL_SESSION: + allowAllConversations.add(convId); + // Cap allowAllConversations the same way + while (allowAllConversations.size() > MAX_SESSION_CONVERSATIONS) { + allowAllConversations.iterator().remove(); + } + break; + case ACCEPT_NAMES_GLOBAL: + if (cmdNames != null) { + addGlobalRules(List.of(cmdNames)); + } + break; + case ACCEPT_EXACT_GLOBAL: + if (commandLine != null && !commandLine.isBlank()) { + addGlobalRules(List.of(commandLine.trim())); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + allowedCommandNames.remove(conversationId); + allowedExactCommands.remove(conversationId); + allowAllConversations.remove(conversationId); + } + + private void addGlobalRules(List commands) { + List original = loadRules(); + Set existing = original.stream() + .map(TerminalAutoApproveRule::getCommand) + .collect(Collectors.toSet()); + + // Override existing deny rules → allow + List updated = original.stream() + .map(r -> commands.contains(r.getCommand()) && !r.isAutoApprove() + ? new TerminalAutoApproveRule(r.getCommand(), true) : r) + .collect(Collectors.toCollection(ArrayList::new)); + + // Append new rules for commands not yet present + commands.stream() + .filter(cmd -> !existing.contains(cmd)) + .map(cmd -> new TerminalAutoApproveRule(cmd, true)) + .forEach(updated::add); + + if (!updated.equals(original)) { + preferenceStore.setValue(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(updated)); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index 05a27d94..d46ae613 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -35,3 +35,36 @@ todoList_collapseTooltip=Click to collapse the todo list thinking_title=Thinking thinking_expandTooltip=Click to show the thinking details thinking_collapseTooltip=Click to hide the thinking details + +# Confirmation dialog action labels +confirmation_action_allowOnce=Allow Once +confirmation_action_skip=Skip +confirmation_action_allowAllCommands=Allow all commands in this Session +confirmation_action_allowNamesSession=Allow {0} in this Session +confirmation_action_alwaysAllowNames=Always Allow {0} +confirmation_action_allowExactSession=Allow this exact command in this Session +confirmation_action_alwaysAllowExact=Always Allow this exact command +confirmation_action_alwaysAllow=Always Allow +confirmation_action_allowFileSession=Allow this file in this Session +confirmation_action_allowFolderSession=Allow folder ''{0}'' in this Session + +# MCP confirmation dialog +confirmation_title_mcpTool=Run ''{0}'' tool from ''{1}'' MCP server +confirmation_title_mcpToolDefault=Allow MCP tool? +confirmation_action_allowServerSession=Allow tools from {0} in this Session +confirmation_action_alwaysAllowServer=Always Allow tools from {0} + +# Confirmation dialog titles +confirmation_title_terminal=Run command in terminal +confirmation_title_fallback=Allow {0}? +confirmation_title_fileRead=Allow reading sensitive file? +confirmation_title_fileWrite=Allow edits to sensitive file? +confirmation_title_fileOperation=Allow operation on sensitive file? + +# Confirmation dialog messages +confirmation_message_fileRead=The model wants to read sensitive file ({0}). +confirmation_message_fileWrite=The model wants to edit sensitive file ({0}). +confirmation_message_fileOperation=The model wants to access sensitive file ({0}). + +# Misc +confirmation_autoApprovedDescription=Auto-approved from dialog diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index 2e6c93f8..0daab267 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -18,6 +18,9 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.ChatEventsManager; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.chat.ToolInvocationListener; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; @@ -32,9 +35,13 @@ import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.BaseTurnWidget; import com.microsoft.copilot.eclipse.ui.chat.ChatContentViewer; import com.microsoft.copilot.eclipse.ui.chat.ChatView; +import com.microsoft.copilot.eclipse.ui.chat.InvokeToolConfirmationDialog; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.AttachedFileRegistry; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.ConfirmationService; import com.microsoft.copilot.eclipse.ui.chat.tools.ApiSchemaAnalyzeTool; import com.microsoft.copilot.eclipse.ui.chat.tools.BaseTool; import com.microsoft.copilot.eclipse.ui.chat.tools.CreateFileTool; @@ -42,10 +49,15 @@ import com.microsoft.copilot.eclipse.ui.chat.tools.GetErrorsTool; import com.microsoft.copilot.eclipse.ui.chat.tools.JavaDebuggerToolAdapter; import com.microsoft.copilot.eclipse.ui.chat.tools.MuleCodeReviewTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleDwlOptimizeTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleDwlReadTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleDwlWriteTool; import com.microsoft.copilot.eclipse.ui.chat.tools.MuleProjectErrorsTool; import com.microsoft.copilot.eclipse.ui.chat.tools.MuleProjectScanTool; import com.microsoft.copilot.eclipse.ui.chat.tools.MuleProjectSummaryTool; import com.microsoft.copilot.eclipse.ui.chat.tools.MuleSecurityReviewTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformReadTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformWriteTool; import com.microsoft.copilot.eclipse.ui.chat.tools.MunitFullReviewTool; import com.microsoft.copilot.eclipse.ui.chat.tools.MunitImprovementSuggestionsTool; import com.microsoft.copilot.eclipse.ui.chat.tools.MunitValidateFlowTestsTool; @@ -65,6 +77,8 @@ public class AgentToolService implements ToolInvocationListener, TerminalService protected CopilotLanguageServerConnection lsConnection; private volatile boolean terminalToolsRegistered = false; private List cachedBuiltInTools; + private final ConfirmationService confirmationService; + private final AttachedFileRegistry attachedFileRegistry; /** * Constructor for AgentToolService. @@ -72,6 +86,10 @@ public class AgentToolService implements ToolInvocationListener, TerminalService public AgentToolService(CopilotLanguageServerConnection lsConnection) { this.tools = new ConcurrentHashMap<>(); this.lsConnection = lsConnection; + this.attachedFileRegistry = new AttachedFileRegistry(); + this.confirmationService = new ConfirmationService( + CopilotUi.getPlugin().getPreferenceStore(), + attachedFileRegistry); TerminalServiceManager terminalManager = TerminalServiceManager.getInstance(); if (terminalManager != null) { terminalManager.addListener(this); @@ -112,6 +130,11 @@ private void registerDefaultTools() { registerTool(new ApiSchemaAnalyzeTool()); registerTool(new MuleCodeReviewTool()); registerTool(new MuleSecurityReviewTool()); + registerTool(new MuleTransformReadTool()); + registerTool(new MuleTransformWriteTool()); + registerTool(new MuleDwlReadTool()); + registerTool(new MuleDwlWriteTool()); + registerTool(new MuleDwlOptimizeTool()); registerTool(new MunitValidateFlowTestsTool()); registerTool(new MunitFullReviewTool()); registerTool(new MunitImprovementSuggestionsTool()); @@ -263,6 +286,29 @@ public CompletableFuture onToolConfirmation return CompletableFuture.completedFuture(result); } + // Resolve the session conversation ID: map subagent conversations to the + // parent so that session-scoped approvals apply to the whole chat. + String sessionConversationId = params.getConversationId(); + if (boundChatView != null + && !Objects.equals(sessionConversationId, + boundChatView.getConversationId()) + && Objects.equals(sessionConversationId, + boundChatView.getSubagentConversationId())) { + sessionConversationId = boundChatView.getConversationId(); + } + + // Auto-approve evaluation + ConfirmationResult autoApproveResult = + confirmationService.evaluate(params, sessionConversationId); + if (autoApproveResult.isAutoApproved()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.ACCEPT)); + } + if (autoApproveResult.isDismissed()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + BaseTurnWidget turnWidget = boundChatView.getChatContentViewer().getTurnWidget(params.getTurnId()); if (turnWidget == null) { LanguageModelToolConfirmationResult result = new LanguageModelToolConfirmationResult( @@ -274,13 +320,30 @@ public CompletableFuture onToolConfirmation BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget(); AtomicReference> ref = new AtomicReference<>(); + ConfirmationContent content = autoApproveResult.getContent(); SwtUtils.invokeOnDisplayThread(() -> { - ref.set( - activeTurnWidget.requestToolExecutionConfirmation(params.getTitle(), params.getMessage(), params.getInput())); + ref.set(activeTurnWidget.requestToolExecutionConfirmation( + content, params.getInput())); boundChatView.getChatContentViewer().refreshScrollerLayout(); }); - return ref.get(); + CompletableFuture future = ref.get(); + if (future != null && content != null) { + // Capture dialog reference before it can be reset by a new request + final InvokeToolConfirmationDialog dialog = + activeTurnWidget.getConfirmDialog(); + final String sessConvId = sessionConversationId; + future = future.thenApply(result -> { + ConfirmationAction selected = dialog != null + ? dialog.getSelectedAction() : null; + if (selected != null && selected.isAccept()) { + confirmationService.cacheDecision(selected, params, + sessConvId); + } + return result; + }); + } + return future; } private boolean validToolConfirmInvokeParams(String conversationId, String turnId) { @@ -303,6 +366,20 @@ private boolean validToolConfirmInvokeParams(String conversationId, String turnI return true; } + /** + * Get the confirmation service for auto-approve evaluation. + * + * @return the confirmation service + */ + public ConfirmationService getConfirmationService() { + return confirmationService; + } + + /** Returns the registry of user-attached context files. */ + public AttachedFileRegistry getAttachedFileRegistry() { + return attachedFileRegistry; + } + /** * Dispose the service. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java index cff99834..daaa6137 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java @@ -30,6 +30,7 @@ import com.microsoft.copilot.eclipse.core.CopilotAuthStatusListener; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.core.chat.BuiltInChatMode; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; @@ -46,6 +47,10 @@ public class ChatCompletionService implements CopilotAuthStatusListener { public static final String AGENT_MARK = "@"; public static final String TEMPLATE_MARK = "/"; + public static final String CONSOLE_CONTEXT_COMMAND = "console"; + public static final String CONSOLE_CONTEXT_DESCRIPTION = "Attach active console output"; + public static final String TRANSFORM_CONTEXT_COMMAND = "transform"; + public static final String TRANSFORM_CONTEXT_DESCRIPTION = "Attach active Transform Message editor context"; private volatile List templates = List.of(); private volatile List agents = List.of(); @@ -188,12 +193,25 @@ public ConversationTemplate[] getFilteredTemplates(ChatMode chatMode) { * @return the start and end index of the broken slash command */ public boolean isBrokenCommand(String text, int cursorPosition) { - if (allCommands == null) { + return isBrokenCommand(text, cursorPosition, null); + } + + /** + * Find a broken slash or agent command in the given text for the active mode. + * + * @param text the text + * @param cursorPosition the cursor position inside the first word + * @param activeModeNameOrId the active mode name or custom mode ID + * @return true when the command can be repaired by deleting one character + */ + public boolean isBrokenCommand(String text, int cursorPosition, String activeModeNameOrId) { + Set commands = getAllCommandsSnapshot(activeModeNameOrId); + if (commands == null) { return false; } // Try to recover the text by adding a dot at the cursor position String recoveredText = text.substring(0, cursorPosition) + "." + text.substring(cursorPosition); - for (String command : allCommands) { + for (String command : commands) { if (matchesRecoveredCommand(recoveredText, command)) { return true; } @@ -224,10 +242,22 @@ private boolean matchesRecoveredCommand(String recovered, String command) { * @return the start and end index of the slash command */ public boolean isCommand(String text) { - if (allCommands == null) { + return isCommand(text, null); + } + + /** + * Find a command in the given text for the active mode. + * + * @param text the text + * @param activeModeNameOrId the active mode name or custom mode ID + * @return true if the text is a known command + */ + public boolean isCommand(String text, String activeModeNameOrId) { + Set commands = getAllCommandsSnapshot(activeModeNameOrId); + if (commands == null) { return false; } - return allCommands.contains(text); + return commands.contains(text); } public boolean isTempaltesReady() { @@ -242,6 +272,63 @@ public ConversationAgent[] getAgents() { return agents.toArray(new ConversationAgent[0]); } + /** + * Returns whether @console should be available for the given active mode. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if @console is enabled and supported + */ + public boolean isConsoleContextCommandAvailable(String activeModeNameOrId) { + return PreferencesUtils.isConsoleContextEnabled() && isConsoleContextSupportedMode(activeModeNameOrId); + } + + /** + * Console context is intentionally limited to built-in Ask, Agent, and Plan modes. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if the mode supports console context + */ + public static boolean isConsoleContextSupportedMode(String activeModeNameOrId) { + return BuiltInChatMode.ASK_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.AGENT_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.PLAN_MODE_NAME.equalsIgnoreCase(activeModeNameOrId); + } + + /** + * Returns true when the @transform command is both enabled in preferences and supported by the active mode. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if @transform is enabled and supported + */ + public boolean isTransformContextCommandAvailable(String activeModeNameOrId) { + return PreferencesUtils.isTransformContextEnabled() && isTransformContextSupportedMode(activeModeNameOrId); + } + + /** + * Transform context is limited to built-in Ask, Agent, and Plan modes. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if the mode supports transform context + */ + public static boolean isTransformContextSupportedMode(String activeModeNameOrId) { + return BuiltInChatMode.ASK_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.AGENT_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.PLAN_MODE_NAME.equalsIgnoreCase(activeModeNameOrId); + } + + private Set getAllCommandsSnapshot(String activeModeNameOrId) { + Set commands = new HashSet<>(allCommands); + // Only add @console/@transform if enabled AND the current mode (if known) supports it. + // When activeModeNameOrId is null, we conservatively skip since we can't verify mode support. + if (activeModeNameOrId != null && isConsoleContextCommandAvailable(activeModeNameOrId)) { + commands.add(AGENT_MARK + CONSOLE_CONTEXT_COMMAND); + } + if (activeModeNameOrId != null && isTransformContextCommandAvailable(activeModeNameOrId)) { + commands.add(AGENT_MARK + TRANSFORM_CONTEXT_COMMAND); + } + return commands; + } + @Override public void onDidCopilotStatusChange(CopilotStatusResult copilotStatusResult) { String status = copilotStatusResult.getStatus(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessor.java new file mode 100644 index 00000000..50e6d5fc --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessor.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.function.Supplier; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService.ConsoleSnapshot; + +/** + * Applies explicit @console chat context to the message sent to the language server. + */ +public final class ConsoleContextPromptProcessor { + private static final String CONSOLE_CONTEXT_BLOCK_TITLE = "[Console Context]"; + + private ConsoleContextPromptProcessor() { + // Utility class. + } + + /** + * Adds console context to the server payload when the prompt starts with @console and the feature is available. + * + * @param message original user message + * @param enabled whether console context is enabled + * @param supportedMode whether the active chat mode supports console context + * @param snapshotSupplier supplier for the current console snapshot + * @return processed message details + */ + public static ProcessedMessage process(String message, boolean enabled, boolean supportedMode, + Supplier snapshotSupplier) { + if (!enabled || !supportedMode || !startsWithConsoleCommand(message)) { + return new ProcessedMessage(message, false); + } + + String promptWithoutCommand = stripConsoleCommand(message); + ConsoleSnapshot snapshot = snapshotSupplier.get(); + String serverMessage = appendConsoleContext(promptWithoutCommand, snapshot); + return new ProcessedMessage(serverMessage, true); + } + + /** + * Checks whether a message starts with @console as a full first token. + * + * @param message user message + * @return true when @console is the leading command + */ + public static boolean startsWithConsoleCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + if (!trimmed.startsWith(ChatCompletionService.AGENT_MARK + ChatCompletionService.CONSOLE_CONTEXT_COMMAND)) { + return false; + } + + int commandLength = (ChatCompletionService.AGENT_MARK + ChatCompletionService.CONSOLE_CONTEXT_COMMAND).length(); + return trimmed.length() == commandLength || Character.isWhitespace(trimmed.charAt(commandLength)); + } + + private static String stripConsoleCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + int commandLength = (ChatCompletionService.AGENT_MARK + ChatCompletionService.CONSOLE_CONTEXT_COMMAND).length(); + return StringUtils.stripStart(trimmed.substring(commandLength), null); + } + + private static String appendConsoleContext(String prompt, ConsoleSnapshot snapshot) { + StringBuilder builder = new StringBuilder(StringUtils.defaultString(prompt).stripTrailing()); + if (!builder.isEmpty()) { + builder.append("\n\n"); + } + builder.append(CONSOLE_CONTEXT_BLOCK_TITLE).append('\n'); + + if (snapshot == null || !snapshot.isAvailable()) { + String reason = snapshot != null ? snapshot.unavailableReason() : "Console context is unavailable."; + builder.append("Console context unavailable: ").append(reason); + return builder.toString(); + } + + builder.append("Console: ").append(snapshot.consoleName()).append('\n'); + builder.append("Truncated: ").append(snapshot.truncated() ? "yes" : "no").append('\n'); + + if (snapshot.isEmpty()) { + builder.append("Output: Console output is empty."); + return builder.toString(); + } + + builder.append("Output:\n\n"); + builder.append(enrichConsoleOutput(snapshot.consoleName(), snapshot.output()).stripTrailing()); + builder.append("\n"); + return builder.toString(); + } + + private static String enrichConsoleOutput(String consoleName, String rawOutput) { + if (consoleName != null) { + String lowerName = consoleName.toLowerCase(); + if (lowerName.contains("maven") || lowerName.contains("mvn")) { + return MavenConsoleParser.enrich(rawOutput); + } + } + return MuleConsoleParser.enrich(rawOutput); + } + + /** + * Result of processing a chat message for console context. + */ + public record ProcessedMessage(String serverMessage, boolean consoleContextRequested) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextService.java new file mode 100644 index 00000000..4d1062ae --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextService.java @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.text.IDocument; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.console.ConsolePlugin; +import org.eclipse.ui.console.IConsole; +import org.eclipse.ui.console.IConsoleConstants; +import org.eclipse.ui.console.IConsoleManager; +import org.eclipse.ui.console.IConsoleView; +import org.eclipse.ui.console.TextConsole; + +import com.microsoft.copilot.eclipse.core.CopilotCore; + +/** + * Captures bounded output from the active Eclipse console for chat context. + */ +public class ConsoleContextService { + public static final int DEFAULT_MAX_CHARS = 12_000; + + /** + * Captures output from the console currently selected in the Eclipse Console view. + * + * @return a console snapshot, or an unavailable snapshot when no active text console can be read + */ + public ConsoleSnapshot captureActiveConsole() { + IConsole activeConsole = getActiveConsole(); + return captureConsole(activeConsole, DEFAULT_MAX_CHARS); + } + + /** + * Captures a bounded tail from the given console. + * + * @param console the console to read + * @param maxChars maximum number of characters to include + * @return a console snapshot + */ + public ConsoleSnapshot captureConsole(IConsole console, int maxChars) { + if (console == null) { + return ConsoleSnapshot.unavailable("No active console is selected."); + } + if (!(console instanceof TextConsole textConsole)) { + return ConsoleSnapshot.unavailable("The active console is not text-backed."); + } + + IDocument document = textConsole.getDocument(); + if (document == null) { + return ConsoleSnapshot.unavailable("The active console has no readable document."); + } + + String output = document.get(); + if (output == null) { + output = StringUtils.EMPTY; + } + + return ConsoleSnapshot.available(console.getName(), tailAtLineBoundary(output, maxChars), + output.length() > maxChars); + } + + private IConsole getActiveConsole() { + try { + // Note: PlatformUI.getWorkbench() and workbench page methods must be called on the SWT UI thread. + // This method is safe when called from onSendInternal() in ChatView, which runs on the UI dispatch thread. + // If called from a background thread (e.g., job), it will throw SWTException: invalid thread access. + IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); + if (window == null) { + return null; + } + IWorkbenchPage page = window.getActivePage(); + if (page == null) { + return null; + } + if (page.findView(IConsoleConstants.ID_CONSOLE_VIEW) instanceof IConsoleView consoleView) { + IConsole active = consoleView.getConsole(); + if (active != null) { + return active; + } + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to capture active console context", e); + } + return findPreferredMuleConsole(); + } + + /** + * Searches all registered Eclipse consoles and returns the best match for Mule-related output. + * Priority order: Mule runtime console > MUnit console > Maven/mvn console. + * + * @return the preferred console, or null if none found + */ + IConsole findPreferredMuleConsole() { + try { + IConsoleManager manager = ConsolePlugin.getDefault().getConsoleManager(); + if (manager == null) { + return null; + } + IConsole muleConsole = null; + IConsole munitConsole = null; + IConsole mavenConsole = null; + for (IConsole console : manager.getConsoles()) { + String name = console.getName().toLowerCase(); + if (name.contains("munit")) { + if (munitConsole == null) { + munitConsole = console; + } + } else if (name.contains("mule")) { + if (muleConsole == null) { + muleConsole = console; + } + } else if (name.contains("maven") || name.contains("mvn")) { + if (mavenConsole == null) { + mavenConsole = console; + } + } + } + if (muleConsole != null) { + return muleConsole; + } + if (munitConsole != null) { + return munitConsole; + } + return mavenConsole; + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to find preferred Mule console", e); + return null; + } + } + + private String tailAtLineBoundary(String output, int maxChars) { + // When maxChars <= 0, return full output (guards against nonsensical limits gracefully). + // Otherwise, trim from the end to stay within maxChars and align to line boundaries. + if (maxChars <= 0 || output.length() <= maxChars) { + return output; + } + + int start = output.length() - maxChars; + int nextLineBreak = output.indexOf('\n', start); + if (nextLineBreak >= 0 && nextLineBreak + 1 < output.length()) { + return output.substring(nextLineBreak + 1); + } + + return output.substring(start); + } + + /** + * Snapshot of console context for a chat turn. + */ + public record ConsoleSnapshot(String consoleName, String output, boolean truncated, String unavailableReason) { + public static ConsoleSnapshot available(String consoleName, String output, boolean truncated) { + return new ConsoleSnapshot(StringUtils.defaultIfBlank(consoleName, "Console"), + StringUtils.defaultString(output), truncated, null); + } + + public static ConsoleSnapshot unavailable(String reason) { + return new ConsoleSnapshot(null, StringUtils.EMPTY, false, reason); + } + + public boolean isAvailable() { + return unavailableReason == null; + } + + public boolean isEmpty() { + return StringUtils.isBlank(output); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParser.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParser.java new file mode 100644 index 00000000..551262f7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParser.java @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses Maven build output from the Eclipse console and prepends a structured summary block. + * This surfaces the build result, error count, and warning count before the full log dump, + * reducing noise in the context window. + */ +final class MavenConsoleParser { + + private static final Pattern MAVEN_MARKER = + Pattern.compile("\\[(INFO|WARNING|ERROR|WARN)\\]|BUILD (SUCCESS|FAILURE)", Pattern.CASE_INSENSITIVE); + private static final Pattern BUILD_RESULT_PATTERN = + Pattern.compile("BUILD (SUCCESS|FAILURE)", Pattern.CASE_INSENSITIVE); + private static final Pattern ERROR_LINE_PATTERN = + Pattern.compile("^\\[ERROR\\]", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); + private static final Pattern WARNING_LINE_PATTERN = + Pattern.compile("^\\[WARNING\\]", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); + + private MavenConsoleParser() { + } + + /** + * Inspects raw console output. If Maven build output is detected, prepends a structured + * [Maven Build Summary] block so the model can orient to the build result before reading the + * full log. + * + * @param rawOutput the raw console snapshot text + * @return enriched text with summary prepended, or the original text if no Maven output found + */ + static String enrich(String rawOutput) { + if (rawOutput == null || rawOutput.isBlank() || !isMavenOutput(rawOutput)) { + return rawOutput; + } + + StringBuilder summary = new StringBuilder("[Maven Build Summary]\n"); + + Matcher resultMatcher = BUILD_RESULT_PATTERN.matcher(rawOutput); + if (resultMatcher.find()) { + summary.append("Result: ").append(resultMatcher.group(0)).append('\n'); + } + + summary.append("Errors: ").append(countMatches(ERROR_LINE_PATTERN, rawOutput)).append('\n'); + summary.append("Warnings: ").append(countMatches(WARNING_LINE_PATTERN, rawOutput)).append('\n'); + summary.append('\n'); + + return summary + rawOutput; + } + + private static boolean isMavenOutput(String text) { + return MAVEN_MARKER.matcher(text).find(); + } + + private static int countMatches(Pattern pattern, String text) { + int count = 0; + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + count++; + } + return count; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java index 60ccc202..078faa1e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java @@ -29,6 +29,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.dialogs.DynamicOauthDialog; import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.preferences.McpAutoApproveSection; import com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage; /** @@ -50,6 +51,7 @@ public class McpConfigService extends ChatBaseService implements IMcpConfigServi private ISideEffect mcpToolButtonEnableSideEffect; private ISideEffect mcpToolsButtonRedNoticeSideEffect; private ISideEffect mcpPrefencePageExtMcpTitleRedNoticeSideEffect; + private ISideEffect mcpAutoApproveSideEffect; private IEventBroker eventBroker; @@ -108,6 +110,28 @@ private void initializeMcpFeatureFlagUpdateEvent() { eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_FEATURE_FLAGS, featureFlagNotifiedEventHandler); } + /** + * Bind the observable with UI in McpAutoApproveSection. + */ + public void bindWithAutoApproveSection(McpAutoApproveSection section) { + ensureRealm(() -> { + unbindWithAutoApproveSection(); + mcpAutoApproveSideEffect = ISideEffect.create( + mcpToolsObservableValue::getValue, + section::updateServerCollections); + }); + } + + /** + * Unbind the McpAutoApproveSection side-effect. + */ + public void unbindWithAutoApproveSection() { + if (mcpAutoApproveSideEffect != null) { + mcpAutoApproveSideEffect.dispose(); + mcpAutoApproveSideEffect = null; + } + } + /** * Bind the observable with UI in McpPreferencePage. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java index 447c2935..9923bd47 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java @@ -137,8 +137,7 @@ private void updateApprovedMcpServerString(Map extM if (servers != null && !servers.isEmpty()) { // Merge all servers into the result map servers.forEach((serverName, serverValue) -> { - String displayServerName = regInfo.getPluginDisplayName() + ": " + serverName; - allServers.merge(displayServerName, serverValue, + allServers.merge(serverName, serverValue, (existingValue, newValue) -> newValue != null ? newValue : existingValue); }); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MuleConsoleParser.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MuleConsoleParser.java new file mode 100644 index 00000000..df6130f8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MuleConsoleParser.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses Mule 4 runtime exception output from the Anypoint Studio console and prepends a structured + * summary block. This reduces noise in the context window by surfacing the most actionable fields — + * error type, flow name, root cause, and component location — before the raw console dump. + */ +final class MuleConsoleParser { + + private static final Pattern ERROR_TYPE_PATTERN = + Pattern.compile("error type:\\s*([A-Z][A-Z0-9_]*:[A-Z][A-Z0-9_]*)", Pattern.CASE_INSENSITIVE); + private static final Pattern ROOT_CAUSE_PATTERN = + Pattern.compile("(?:Caused by|root cause):\\s*(.{1,200})", Pattern.CASE_INSENSITIVE); + private static final Pattern COMPONENT_PATTERN = + Pattern.compile("at ([\\w\\-]+)\\s+@\\s+([\\w\\-./]+/processors/\\d+(?:/\\d+)?)", + Pattern.CASE_INSENSITIVE); + private static final Pattern FLOW_PATTERN = + Pattern.compile("(?:Flow name:\\s*|at flow:\\s*|\\bflow=)([\\w\\-.:]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern MULE_EXCEPTION_MARKER = + Pattern.compile("org\\.mule\\.runtime|MuleRuntimeException|MuleException"); + + private MuleConsoleParser() { + } + + /** + * Inspects raw console output. If a Mule runtime exception is detected, prepends a structured + * [Mule Error Summary] block so the model can orient to the error before reading the full dump. + * + * @param rawOutput the raw console snapshot text + * @return enriched text with summary prepended, or the original text if no Mule exception found + */ + static String enrich(String rawOutput) { + if (rawOutput == null || rawOutput.isBlank() || !isMuleException(rawOutput)) { + return rawOutput; + } + + StringBuilder summary = new StringBuilder("[Mule Error Summary]\n"); + + String errorType = extractFirst(ERROR_TYPE_PATTERN, rawOutput, 1); + if (!errorType.isBlank()) { + summary.append("Error type: ").append(errorType).append('\n'); + } + + String flow = extractFirst(FLOW_PATTERN, rawOutput, 1); + if (!flow.isBlank()) { + summary.append("Flow: ").append(flow).append('\n'); + } + + String rootCause = extractFirst(ROOT_CAUSE_PATTERN, rawOutput, 1); + if (!rootCause.isBlank()) { + summary.append("Root cause: ").append(rootCause.trim()).append('\n'); + } + + String component = extractComponent(rawOutput); + if (!component.isBlank()) { + summary.append("Component: ").append(component).append('\n'); + } + + summary.append('\n'); + return summary + rawOutput; + } + + private static boolean isMuleException(String text) { + return MULE_EXCEPTION_MARKER.matcher(text).find(); + } + + private static String extractFirst(Pattern pattern, String text, int group) { + Matcher matcher = pattern.matcher(text); + return matcher.find() ? matcher.group(group).trim() : ""; + } + + private static String extractComponent(String text) { + Matcher matcher = COMPONENT_PATTERN.matcher(text); + return matcher.find() ? matcher.group(1).trim() + " @ " + matcher.group(2).trim() : ""; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessor.java new file mode 100644 index 00000000..2789c8e9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessor.java @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.function.Supplier; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.ScriptEntry; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.TransformEntry; + +/** + * Applies explicit @transform chat context to the message sent to the language server. + */ +public final class TransformEditorContextPromptProcessor { + private static final String TRANSFORM_CONTEXT_BLOCK_TITLE = "[Transform Context]"; + + private TransformEditorContextPromptProcessor() { + // Utility class. + } + + /** + * Adds transform context to the server payload when the prompt starts with @transform and the feature is available. + * + * @param message original user message + * @param enabled whether transform context is enabled + * @param supportedMode whether the active chat mode supports transform context + * @param snapshotSupplier supplier for the current transform editor snapshot + * @return processed message details + */ + public static ProcessedMessage process(String message, boolean enabled, boolean supportedMode, + Supplier snapshotSupplier) { + if (!enabled || !supportedMode || !startsWithTransformCommand(message)) { + return new ProcessedMessage(message, false); + } + + String promptWithoutCommand = stripTransformCommand(message); + TransformEditorSnapshot snapshot = snapshotSupplier.get(); + String serverMessage = appendTransformContext(promptWithoutCommand, snapshot); + return new ProcessedMessage(serverMessage, true); + } + + /** + * Checks whether a message starts with @transform as a full first token. + * + * @param message user message + * @return true when @transform is the leading command + */ + public static boolean startsWithTransformCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + String command = ChatCompletionService.AGENT_MARK + ChatCompletionService.TRANSFORM_CONTEXT_COMMAND; + if (!trimmed.startsWith(command)) { + return false; + } + return trimmed.length() == command.length() || Character.isWhitespace(trimmed.charAt(command.length())); + } + + private static String stripTransformCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + int commandLength = (ChatCompletionService.AGENT_MARK + ChatCompletionService.TRANSFORM_CONTEXT_COMMAND).length(); + return StringUtils.stripStart(trimmed.substring(commandLength), null); + } + + private static String appendTransformContext(String prompt, TransformEditorSnapshot snapshot) { + StringBuilder builder = new StringBuilder(StringUtils.defaultString(prompt).stripTrailing()); + if (!builder.isEmpty()) { + builder.append("\n\n"); + } + builder.append(TRANSFORM_CONTEXT_BLOCK_TITLE).append('\n'); + + if (snapshot == null || !snapshot.isAvailable()) { + String reason = snapshot != null ? snapshot.unavailableReason() : "Transform context is unavailable."; + builder.append("Transform context unavailable: ").append(reason); + return builder.toString(); + } + + if (snapshot.isFromPropertiesView()) { + builder.append("source: properties-view\n"); + } + builder.append("file: ").append(snapshot.xmlFilePath()).append('\n'); + builder.append("transforms: ").append(snapshot.transformCount()).append('\n'); + + if (snapshot.isEmpty()) { + builder.append("Transform context: No ee:transform elements found in this file."); + return builder.toString(); + } + + for (TransformEntry entry : snapshot.transforms()) { + builder.append('\n'); + builder.append("--- Transform: ").append(entry.label()).append(" ---\n"); + for (ScriptEntry script : entry.scripts()) { + builder.append("target: ").append(script.target()).append('\n'); + if (!script.outputType().isBlank()) { + builder.append("outputType: ").append(script.outputType()).append('\n'); + } + builder.append("script:\n"); + builder.append(script.script().stripTrailing()).append('\n'); + } + } + + if (snapshot.truncated()) { + builder.append("\n(Note: one or more DataWeave scripts were truncated due to length.)"); + } + + return builder.toString(); + } + + /** + * Silently appends transform context to the message without requiring an explicit {@code @transform} + * command. Returns the original message unchanged when context is unavailable or empty — no error + * block is emitted, since the user did not ask for it. + * + * @param message original user message (no @transform prefix expected) + * @param enabled whether transform context is enabled + * @param supportedMode whether the active chat mode supports transform context + * @param snapshotSupplier supplier for the current transform editor snapshot + * @return processed message details + */ + public static ProcessedMessage processAutoInject(String message, boolean enabled, boolean supportedMode, + Supplier snapshotSupplier) { + if (!enabled || !supportedMode) { + return new ProcessedMessage(message, false); + } + TransformEditorSnapshot snapshot = snapshotSupplier.get(); + if (snapshot == null || !snapshot.isAvailable() || snapshot.isEmpty()) { + return new ProcessedMessage(message, false); + } + String serverMessage = appendTransformContext(message, snapshot); + return new ProcessedMessage(serverMessage, true); + } + + /** + * Result of processing a chat message for transform context. + */ + public record ProcessedMessage(String serverMessage, boolean transformContextRequested) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextService.java new file mode 100644 index 00000000..b7cd2592 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextService.java @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformSupport; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformSupport.ScriptContent; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Captures the active Mule XML editor's Transform Message elements for chat context. + */ +public class TransformEditorContextService { + public static final int DEFAULT_MAX_CHARS_PER_SCRIPT = 8_000; + + /** View ID for the Anypoint Studio Transform Message properties panel. */ + public static final String MULE_TRANSFORM_VIEW_ID = "org.mule.tooling.ui.views.transformView"; + + /** Source tag emitted when context comes from the properties-view hint. */ + public static final String SOURCE_PROPERTIES_VIEW = "properties-view"; + + private volatile String activeTransformName = null; + private volatile String activeTransformId = null; + + /** + * Sets a hint identifying the specific Transform Message component currently open in the + * Anypoint Studio properties panel. Called from the workbench part listener when the + * Transform properties view is activated. + * + * @param transformName the {@code doc:name} of the focused transform (may be blank) + * @param transformId the {@code doc:id} of the focused transform (may be blank) + */ + public void setActiveTransformHint(String transformName, String transformId) { + this.activeTransformName = transformName; + this.activeTransformId = transformId; + } + + /** + * Clears the properties-view hint so context falls back to the full file's transforms. + * Called when the Transform properties view is deactivated or closed. + */ + public void clearActiveTransformHint() { + this.activeTransformName = null; + this.activeTransformId = null; + } + + /** + * Captures all Transform Message elements from the currently active Mule XML editor. + * + *

When the Anypoint Studio Transform Message properties view is active, narrows the result + * to the specific transform identified by the properties-view hint. Falls back to all transforms + * in the file when no hint is set, and to searching open editors when no main editor is active. + * Must be called on the SWT UI thread. + * + * @return a transform editor snapshot, or an unavailable snapshot when no Mule XML editor is active + */ + public TransformEditorSnapshot captureActiveTransformContext() { + IFile xmlFile = UiUtils.getCurrentFile(); + if (xmlFile == null) { + xmlFile = findFirstMuleXmlFile(UiUtils.getOpenedFiles()); + } + String nameHint = this.activeTransformName; + String idHint = this.activeTransformId; + return captureTransformContext(xmlFile, nameHint, idHint, DEFAULT_MAX_CHARS_PER_SCRIPT); + } + + /** + * Captures all Transform Message elements by scanning all currently open editors for a Mule XML + * file. Used for silent auto-inject when the user has not typed an explicit {@code @transform} + * command. + * + *

Must be called on the SWT UI thread. + * + * @return a transform editor snapshot, or an unavailable snapshot when no open Mule XML file is found + */ + public TransformEditorSnapshot captureAutoTransformContext() { + IFile xmlFile = findFirstMuleXmlFile(UiUtils.getOpenedFiles()); + return captureTransformContext(xmlFile, null, null, DEFAULT_MAX_CHARS_PER_SCRIPT); + } + + /** + * Captures Transform Message elements from the given Mule XML file, returning all transforms. + * + * @param file the IFile to inspect (may be null) + * @param maxCharsPerScript maximum characters per individual DataWeave script + * @return a transform editor snapshot + */ + public TransformEditorSnapshot captureTransformContext(IFile file, int maxCharsPerScript) { + return captureTransformContext(file, null, null, maxCharsPerScript); + } + + /** + * Captures Transform Message elements from the given Mule XML file, optionally filtered to a + * specific transform by name or ID. + * + * @param file the IFile to inspect (may be null) + * @param transformName optional {@code doc:name} filter (null or blank = match all) + * @param transformId optional {@code doc:id} filter (null or blank = match all) + * @param maxCharsPerScript maximum characters per individual DataWeave script + * @return a transform editor snapshot + */ + public TransformEditorSnapshot captureTransformContext(IFile file, String transformName, String transformId, + int maxCharsPerScript) { + if (file == null) { + return TransformEditorSnapshot.unavailable("No active editor is open."); + } + if (!isMuleXmlFile(file)) { + return TransformEditorSnapshot.unavailable("The active file is not a Mule XML flow file."); + } + + String name = transformName != null ? transformName : ""; + String id = transformId != null ? transformId : ""; + boolean hasHint = !name.isBlank() || !id.isBlank(); + + Path xmlPath = file.getLocation().toFile().toPath(); + try { + Document document = MuleTransformSupport.parseXml(xmlPath); + List transforms = MuleTransformSupport.findTransforms(document, name, id); + + String source = hasHint ? SOURCE_PROPERTIES_VIEW : null; + + if (transforms.isEmpty()) { + return TransformEditorSnapshot.available(xmlPath.toString(), 0, List.of(), false, source); + } + + List entries = new ArrayList<>(); + boolean anyTruncated = false; + + for (Element transform : transforms) { + String label = MuleTransformSupport.transformLabel(transform); + String docName = getDocAttribute(transform, "name"); + String docId = getDocAttribute(transform, "id"); + + List scripts = new ArrayList<>(); + + // payload and attributes (inside ee:message) + for (Element messageEl : MuleTransformSupport.directChildren(transform, "message")) { + for (Element setPayload : MuleTransformSupport.directChildren(messageEl, "set-payload")) { + ReadResult entry = readScript(setPayload, xmlPath, MuleTransformSupport.TARGET_PAYLOAD, maxCharsPerScript); + scripts.add(entry.scriptEntry()); + if (entry.truncated()) { + anyTruncated = true; + } + } + for (Element setAttribs : MuleTransformSupport.directChildren(messageEl, "set-attributes")) { + ReadResult entry = readScript(setAttribs, xmlPath, MuleTransformSupport.TARGET_ATTRIBUTES, + maxCharsPerScript); + scripts.add(entry.scriptEntry()); + if (entry.truncated()) { + anyTruncated = true; + } + } + } + + // variables (inside ee:variables) + for (Element setVar : MuleTransformSupport.directChildren(transform, "variables")) { + for (Element variable : MuleTransformSupport.directChildren(setVar, "set-variable")) { + String varName = variable.getAttribute("variableName"); + String target = varName.isBlank() ? MuleTransformSupport.TARGET_VARIABLE_PREFIX + "unknown" + : MuleTransformSupport.TARGET_VARIABLE_PREFIX + varName; + ReadResult entry = readScript(variable, xmlPath, target, maxCharsPerScript); + scripts.add(entry.scriptEntry()); + if (entry.truncated()) { + anyTruncated = true; + } + } + } + + entries.add(new TransformEditorSnapshot.TransformEntry(label, docName, docId, scripts)); + } + + return TransformEditorSnapshot.available(xmlPath.toString(), transforms.size(), entries, anyTruncated, source); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to capture transform editor context", e); + return TransformEditorSnapshot.unavailable("Failed to read Mule XML: " + e.getMessage()); + } + } + + private IFile findFirstMuleXmlFile(java.util.List files) { + for (IFile file : files) { + if (isMuleXmlFile(file)) { + return file; + } + } + return null; + } + + private boolean isMuleXmlFile(IFile file) { + if (!"xml".equalsIgnoreCase(file.getFileExtension())) { + return false; + } + // Accept files under src/main/mule or any XML in a project that has pom.xml + String fullPath = file.getFullPath().toString(); + return fullPath.contains("/src/main/mule/") || fullPath.contains("\\src\\main\\mule\\"); + } + + private String getDocAttribute(Element element, String attrLocalName) { + String value = element.getAttributeNS(MuleTransformSupport.DOC_NS, attrLocalName); + if (value.isBlank()) { + value = element.getAttribute("doc:" + attrLocalName); + } + return value; + } + + private ReadResult readScript(Element element, Path xmlPath, String target, int maxCharsPerScript) { + ScriptContent content = MuleTransformSupport.readScriptContent(element, xmlPath); + String script = content.script(); + boolean truncated = maxCharsPerScript > 0 && script.length() > maxCharsPerScript; + if (truncated) { + script = script.substring(0, maxCharsPerScript); + } + String outputType = extractOutputType(content.script()); + return new ReadResult( + new TransformEditorSnapshot.ScriptEntry(target, outputType, script), + truncated); + } + + private String extractOutputType(String script) { + if (script == null || script.isBlank()) { + return ""; + } + for (String line : script.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.startsWith("output ")) { + return trimmed.substring("output ".length()).trim(); + } + } + return ""; + } + + private record ReadResult(TransformEditorSnapshot.ScriptEntry scriptEntry, boolean truncated) { + } + + /** + * Snapshot of the active Mule XML editor's Transform Message elements for a chat turn. + */ + public record TransformEditorSnapshot( + String xmlFilePath, + int transformCount, + List transforms, + boolean truncated, + String unavailableReason, + String source) { + + public record TransformEntry(String label, String docName, String docId, List scripts) { + } + + public record ScriptEntry(String target, String outputType, String script) { + } + + public static TransformEditorSnapshot available(String xmlFilePath, int transformCount, + List transforms, boolean truncated, String source) { + return new TransformEditorSnapshot(xmlFilePath, transformCount, transforms, truncated, null, source); + } + + public static TransformEditorSnapshot available(String xmlFilePath, int transformCount, + List transforms, boolean truncated) { + return available(xmlFilePath, transformCount, transforms, truncated, null); + } + + public static TransformEditorSnapshot unavailable(String reason) { + return new TransformEditorSnapshot(null, 0, List.of(), false, reason, null); + } + + public boolean isAvailable() { + return unavailableReason == null; + } + + public boolean isEmpty() { + return transforms.isEmpty(); + } + + public boolean isFromPropertiesView() { + return SOURCE_PROPERTIES_VIEW.equals(source); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java index 0ca4b969..277cb437 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java @@ -206,6 +206,16 @@ public void onDidCopilotStatusChange(CopilotStatusResult copilotStatusResult) { String status = copilotStatusResult.getStatus(); switch (status) { case CopilotStatusResult.OK, CopilotStatusResult.NOT_AUTHORIZED: + // Asynchronously reload built-in modes in case the LSP was not ready at enum-init time + // (startup race condition). Update the observable on completion so the mode picker refreshes. + BuiltInChatModeManager.INSTANCE.reloadModesAsync().thenRun(() -> ensureRealm(() -> { + if (!Arrays.deepEquals(getAvailableChatModes(), chatModeObservable.getValue())) { + chatModeObservable.setValue(getAvailableChatModes()); + } + })).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to reload built-in modes on status change", ex); + return null; + }); init(); break; default: diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ApiSchemaAnalyzeTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ApiSchemaAnalyzeTool.java index b861c7a6..139126ff 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ApiSchemaAnalyzeTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ApiSchemaAnalyzeTool.java @@ -32,8 +32,14 @@ public LanguageModelToolInformation getToolInformation() { toolInfo.setDisplayDescription("Analyze Mule API schema files"); toolInfo.setDescription(""" Analyze RAML, OpenAPI, OData, AsyncAPI, GraphQL, WSDL, XSD, JSON Schema, Avro, CSV, or flat-file metadata. - Reports syntax and governance-oriented diagnostics such as missing examples, error responses, - security definitions, and APIkit compatibility hints. This tool is read-only. + Reports syntax errors and governance diagnostics. Governance issues include: missing required metadata + (title, version, baseUri/servers), missing examples on request or response bodies, missing error response + definitions (400, 401, 404, 500), no security scheme defined, inline anonymous schemas that should be + named reusable types, inconsistent naming conventions across endpoints, and APIkit compatibility issues + (RAML baseUri and version must match the APIkit router config, all resources must have at least one method). + Common findings: RAML with no securitySchemes, OpenAPI with 200-only responses on POST endpoints, + RAML types section missing (all schemas inline), examples that do not validate against their schema. + This tool is read-only. """); toolInfo.setInputSchema(MuleToolInputs.schemaAnalyzeSchema()); return toolInfo; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/DwlAnalyzer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/DwlAnalyzer.java new file mode 100644 index 00000000..3dee42da --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/DwlAnalyzer.java @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Static analysis helper for DataWeave 2.0 scripts. Detects common performance anti-patterns, + * null-safety gaps, and documentation gaps. + */ +final class DwlAnalyzer { + + private static final Pattern NESTED_MAP_FILTER = + Pattern.compile("map\\s*\\([^)]*->\\s*[^)]*filter\\b|filter\\s*\\([^)]*->\\s*[^)]*map\\b", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern INLINE_REGEX_IN_MAP = + Pattern.compile("(?:map|filter)\\s*[({][^)}]*\\/[^/\\n]+\\/[^)}]*[)}]", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern ROUND_TRIP_WRITE_READ = + Pattern.compile("write\\s*\\([^)]*read\\s*\\(|read\\s*\\([^)]*write\\s*\\(", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern FIELD_ACCESS_WITHOUT_DEFAULT = + Pattern.compile("payload\\.\\w+(?!\\s+default\\b)", Pattern.CASE_INSENSITIVE); + private static final Pattern FUN_DECL = + Pattern.compile("^(\\s*)fun\\s+(\\w+)\\s*\\(", Pattern.MULTILINE); + private static final Pattern COMMENT_BEFORE_LINE = + Pattern.compile("//[^\\n]*\\n\\s*fun\\s+|/\\*\\*[\\s\\S]*?\\*/\\s*fun\\s+", + Pattern.CASE_INSENSITIVE); + + record Issue(String type, int line, String description, String suggestion) { + } + + private DwlAnalyzer() { + } + + /** + * Analyzes a DataWeave script for common issues. + * + * @param script the full script text + * @return list of issues found (may be empty) + */ + static List analyze(String script) { + List issues = new ArrayList<>(); + if (script == null || script.isBlank()) { + return issues; + } + + String[] lines = script.split("\\r?\\n", -1); + + // Check 1: Missing %dw 2.0 header + if (!script.stripLeading().startsWith("%dw 2.0")) { + issues.add(new Issue("missing-dw-header", 1, + "Script is missing the '%dw 2.0' header directive.", + "%dw 2.0\noutput application/json\n---\n" + script.stripLeading())); + } + + // Check 2: Missing output directive + boolean hasOutput = false; + for (String line : lines) { + if (line.trim().startsWith("output ")) { + hasOutput = true; + break; + } + } + if (!hasOutput) { + issues.add(new Issue("missing-output-directive", 1, + "No 'output' directive found. Add 'output application/json' (or the appropriate type) after the %dw 2.0 header.", + "output application/json")); + } + + // Check 3: Nested map+filter (O(n×m) pattern) + if (NESTED_MAP_FILTER.matcher(script).find()) { + int lineNum = findPatternLine(NESTED_MAP_FILTER, script); + issues.add(new Issue("nested-map-filter", lineNum, + "Nested map+filter detected — this is O(n×m). Pre-index the inner array with 'groupBy' and look up in O(1).", + "var indexedB = arrayB groupBy $.id\narrayA map (a -> indexedB[a.id][0] default {})")); + } + + // Check 4: Inline regex inside map/filter + if (INLINE_REGEX_IN_MAP.matcher(script).find()) { + int lineNum = findPatternLine(INLINE_REGEX_IN_MAP, script); + issues.add(new Issue("inline-regex-in-map", lineNum, + "Regex literal inside map/filter is compiled on every iteration. Extract to a 'var' before the map.", + "var namePattern = /^[A-Z].*/\npayload map (item -> item.name matches namePattern)")); + } + + // Check 5: Round-trip serialization (write then read, or read then write) + if (ROUND_TRIP_WRITE_READ.matcher(script).find()) { + int lineNum = findPatternLine(ROUND_TRIP_WRITE_READ, script); + issues.add(new Issue("round-trip-serialization", lineNum, + "write() immediately followed by read() (or vice versa) is a no-op round-trip. Remove both calls.", + "// Remove the write(...) and read(...) pair — pass the value directly")); + } + + // Check 6: Field access without null guard (heuristic — flags bare payload.field patterns) + Matcher nullMatcher = FIELD_ACCESS_WITHOUT_DEFAULT.matcher(script); + int nullIssues = 0; + while (nullMatcher.find() && nullIssues < 3) { + int lineNum = lineNumberAt(script, nullMatcher.start()); + String access = nullMatcher.group().trim(); + issues.add(new Issue("missing-null-guard", lineNum, + "'" + access + "' accessed without a 'default' guard. If this field is optional, add 'default'.", + access + " default \"\"")); + nullIssues++; + } + + // Check 7: Undocumented fun declarations + Matcher funMatcher = FUN_DECL.matcher(script); + while (funMatcher.find()) { + int lineNum = lineNumberAt(script, funMatcher.start()); + String funName = funMatcher.group(2); + boolean hasComment = hasCommentBefore(script, funMatcher.start()); + if (!hasComment) { + issues.add(new Issue("undocumented-function", lineNum, + "Function '" + funName + "' has no documentation comment.", + "// " + funName + ": describe what this function does, its parameters and return type")); + } + } + + return issues; + } + + /** + * Adds documentation comments before undocumented {@code fun} declarations in the script. + * + * @param script the original DataWeave script + * @return the script with comment stubs inserted + */ + static String addComments(String script) { + if (script == null || script.isBlank()) { + return script; + } + StringBuilder result = new StringBuilder(); + String[] lines = script.split("\\r?\\n", -1); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + String trimmed = line.trim(); + if (trimmed.startsWith("fun ")) { + boolean alreadyCommented = (i > 0 && lines[i - 1].trim().startsWith("//")) + || (i > 0 && lines[i - 1].trim().startsWith("*")) + || (i > 0 && lines[i - 1].trim().startsWith("/**")); + if (!alreadyCommented) { + String indent = line.substring(0, line.length() - line.stripLeading().length()); + String funName = trimmed.replaceFirst("fun\\s+(\\w+).*", "$1"); + result.append(indent).append("// ").append(funName) + .append(": describe what this function does, its parameters and return type") + .append(System.lineSeparator()); + } + } + result.append(line); + if (i < lines.length - 1) { + result.append(System.lineSeparator()); + } + } + return result.toString(); + } + + private static int findPatternLine(Pattern pattern, String script) { + Matcher m = pattern.matcher(script); + if (m.find()) { + return lineNumberAt(script, m.start()); + } + return 0; + } + + private static int lineNumberAt(String script, int charIndex) { + int line = 1; + for (int i = 0; i < charIndex && i < script.length(); i++) { + if (script.charAt(i) == '\n') { + line++; + } + } + return line; + } + + private static boolean hasCommentBefore(String script, int funStart) { + int lineStart = script.lastIndexOf('\n', funStart - 1); + if (lineStart < 0) { + return false; + } + String prevLine = script.substring(script.lastIndexOf('\n', lineStart - 1) + 1, lineStart).trim(); + return prevLine.startsWith("//") || prevLine.startsWith("*") || prevLine.startsWith("/**"); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleCodeReviewTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleCodeReviewTool.java index 698e1855..e160f4d2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleCodeReviewTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleCodeReviewTool.java @@ -32,8 +32,13 @@ public LanguageModelToolInformation getToolInformation() { toolInfo.setDisplayDescription("Review Mule XML, DataWeave, specs, and tests"); toolInfo.setDescription(""" Perform a MuleSoft code review across Mule XML, DataWeave, properties, API specs, MUnit, and POM metadata. - Checks structure, naming, duplicate flows, error handling, logging, test coverage, and deployment readiness. - This tool is read-only and returns findings by severity with remediation guidance. + Checks: flow naming conventions (camelCase verb-noun), duplicate or unused global configs, missing + On Error Propagate on HTTP-facing flows, correlation ID propagation in error handlers, property placeholder + externalization (secure:: for secrets, plain placeholder for env values), APIkit route coverage vs API spec, + DataWeave output type declarations and null-safety, and MUnit test coverage gaps. + Common findings: flows with no error handler, hardcoded URLs in global configs, on-error-continue misused + as a catch-all, flows with zero MUnit coverage, DataWeave scripts missing output directive. + This tool is read-only and returns findings by severity (critical, high, medium, low) with remediation guidance. """); toolInfo.setInputSchema(MuleToolInputs.codeReviewSchema()); return toolInfo; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlOptimizeTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlOptimizeTool.java new file mode 100644 index 00000000..68ee59a8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlOptimizeTool.java @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Analyzes a standalone DataWeave (.dwl) file for performance anti-patterns, null-safety gaps, + * and documentation issues. By default operates as read-only (preview). When applyFixes=true, + * writes the optimized script back to the file (requires user confirmation). + */ +public class MuleDwlOptimizeTool extends BaseTool { + static final String TOOL_NAME = "mule_optimize_dwl"; + + /** + * Creates a DataWeave optimize tool. + */ + public MuleDwlOptimizeTool() { + this.name = TOOL_NAME; + } + + @Override + public boolean needConfirmation() { + return true; + } + + @Override + public ConfirmationMessages getConfirmationMessages() { + ConfirmationMessages messages = new ConfirmationMessages(); + messages.setTitle("Apply DataWeave Optimizations"); + messages.setMessage( + "This will analyze and optionally rewrite the DataWeave .dwl file with performance " + + "improvements and documentation comments. Continue?"); + return messages; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Analyze and optimize a DataWeave module file"); + toolInfo.setDescription(""" + Analyze a standalone DataWeave 2.0 module (.dwl) file for common issues: + - Missing %dw 2.0 header or output directive + - Nested map+filter patterns (O(n×m)) — suggest groupBy pre-indexing + - Inline regex literals inside map/filter — suggest extracting to var + - Round-trip write()/read() serialization — flag as no-op + - Field accesses without null guards (missing 'default' operator) + - Undocumented function declarations — suggest comment stubs + Returns a structured findings report and the suggested optimized script. + When applyFixes=true, writes the improved script back to the file (requires confirmation). + When includeComments=true (default), adds documentation comments to undocumented functions. + Always run mulesoft/dataweave_run_script_tool after applying to validate the result. + """); + toolInfo.setInputSchema(MuleToolInputs.dwlOptimizeSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path dwlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.DWL_FILE_PATH)); + if (dwlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlFilePath must be an absolute path to an existing .dwl file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + if (!dwlPath.getFileName().toString().endsWith(".dwl")) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("The file must be a DataWeave module (.dwl). Got: " + dwlPath.getFileName()); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + boolean includeComments = !Boolean.FALSE.equals(input.get(MuleToolInputs.INCLUDE_COMMENTS)); + boolean applyFixes = Boolean.TRUE.equals(input.get(MuleToolInputs.APPLY_FIXES)); + + String script = Files.readString(dwlPath, StandardCharsets.UTF_8); + List issues = DwlAnalyzer.analyze(script); + String optimizedScript = includeComments ? DwlAnalyzer.addComments(script) : script; + + if (applyFixes) { + Files.writeString(dwlPath, optimizedScript, StandardCharsets.UTF_8); + refreshWorkspaceFile(dwlPath); + } + + result.setStatus(ToolInvocationStatus.success); + result.addContent(formatReport(dwlPath, issues, optimizedScript, applyFixes)); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to optimize DataWeave file: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static String formatReport(Path dwlPath, List issues, + String optimizedScript, boolean applied) { + StringBuilder sb = new StringBuilder(); + sb.append("file=").append(dwlPath.toAbsolutePath()).append(System.lineSeparator()); + sb.append("issues=").append(issues.size()).append(System.lineSeparator()); + sb.append("optimized=").append(applied ? "yes (written to file)" : "no (preview only)") + .append(System.lineSeparator()); + + if (issues.isEmpty()) { + sb.append(System.lineSeparator()).append("No issues found. Script looks good."); + } else { + int num = 1; + for (DwlAnalyzer.Issue issue : issues) { + sb.append(System.lineSeparator()); + sb.append("[Issue ").append(num++).append("]").append(System.lineSeparator()); + sb.append("type: ").append(issue.type()).append(System.lineSeparator()); + sb.append("line: ").append(issue.line()).append(System.lineSeparator()); + sb.append("description: ").append(issue.description()).append(System.lineSeparator()); + if (!issue.suggestion().isBlank()) { + sb.append("suggestion:").append(System.lineSeparator()); + for (String line : issue.suggestion().split("\\r?\\n", -1)) { + sb.append(" ").append(line).append(System.lineSeparator()); + } + } + } + } + + sb.append(System.lineSeparator()); + sb.append("[Suggested script").append(applied ? " (applied)" : " (preview)").append(":]") + .append(System.lineSeparator()); + sb.append(optimizedScript); + return sb.toString(); + } + + private static void refreshWorkspaceFile(Path dwlPath) { + try { + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile file = root.getFileForLocation( + new org.eclipse.core.runtime.Path(dwlPath.toAbsolutePath().toString())); + if (file != null && file.exists()) { + file.refreshLocal(IFile.DEPTH_ZERO, null); + } + } catch (Exception e) { + // Non-fatal: the file was written successfully; workspace refresh will happen on next build + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlReadTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlReadTool.java new file mode 100644 index 00000000..a3e8abfa --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlReadTool.java @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Reads the content of a standalone DataWeave module (.dwl) file. Use this before editing or + * reviewing a DataWeave module to understand the current script and its output type declaration. + * This tool is read-only. + */ +public class MuleDwlReadTool extends BaseTool { + static final String TOOL_NAME = "mule_read_dwl_file"; + + /** + * Creates a DataWeave file read tool. + */ + public MuleDwlReadTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Read a standalone DataWeave module file"); + toolInfo.setDescription(""" + Read the content of a standalone DataWeave 2.0 module (.dwl) file. + Returns the file path, line count, and full script content. + Use this before editing, reviewing, or optimizing a DataWeave module to understand + the current script, its output type declaration, function definitions, and imports. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.dwlReadSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path dwlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.DWL_FILE_PATH)); + if (dwlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlFilePath must be an absolute path to an existing .dwl file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + if (!dwlPath.getFileName().toString().endsWith(".dwl")) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("The file must be a DataWeave module (.dwl). Got: " + dwlPath.getFileName()); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String content = Files.readString(dwlPath, StandardCharsets.UTF_8); + long lineCount = content.lines().count(); + + StringBuilder sb = new StringBuilder(); + sb.append("file=").append(dwlPath.toAbsolutePath()).append(System.lineSeparator()); + sb.append("lines=").append(lineCount).append(System.lineSeparator()); + sb.append("script:").append(System.lineSeparator()); + sb.append(content); + + result.setStatus(ToolInvocationStatus.success); + result.addContent(sb.toString()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to read DataWeave file: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlWriteTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlWriteTool.java new file mode 100644 index 00000000..4293d64f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlWriteTool.java @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Writes a complete DataWeave 2.0 script to a standalone .dwl module file. Replaces the entire + * file content. Requires user confirmation before writing. + */ +public class MuleDwlWriteTool extends BaseTool { + static final String TOOL_NAME = "mule_write_dwl_file"; + + /** + * Creates a DataWeave file write tool. + */ + public MuleDwlWriteTool() { + this.name = TOOL_NAME; + } + + @Override + public boolean needConfirmation() { + return true; + } + + @Override + public ConfirmationMessages getConfirmationMessages() { + ConfirmationMessages messages = new ConfirmationMessages(); + messages.setTitle("Update DataWeave Module File"); + messages.setMessage( + "This will replace the entire content of the DataWeave .dwl file. Continue?"); + return messages; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Write a DataWeave script to a standalone .dwl module file"); + toolInfo.setDescription(""" + Replace the content of a standalone DataWeave 2.0 module (.dwl) file. + The dwlScript must be a complete script (not a fragment) and should start with '%dw 2.0' + followed by an output directive. + Always run mule_read_dwl_file first to confirm the current state before writing. + Always run mulesoft/dataweave_run_script_tool after writing to validate the updated script. + Requires user confirmation before modifying the file. + """); + toolInfo.setInputSchema(MuleToolInputs.dwlWriteSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path dwlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.DWL_FILE_PATH)); + if (dwlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlFilePath must be an absolute path to an existing .dwl file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + if (!dwlPath.getFileName().toString().endsWith(".dwl")) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("The file must be a DataWeave module (.dwl). Got: " + dwlPath.getFileName()); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String dwlScript = MuleToolInputs.optionalString(input.get(MuleToolInputs.DWL_SCRIPT)); + if (dwlScript.isBlank()) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlScript is required and must not be blank."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + Files.writeString(dwlPath, dwlScript, StandardCharsets.UTF_8); + refreshWorkspaceFile(dwlPath); + + result.setStatus(ToolInvocationStatus.success); + result.addContent("Updated DataWeave module: " + dwlPath.toAbsolutePath()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to write DataWeave file: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static void refreshWorkspaceFile(Path dwlPath) { + try { + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile file = root.getFileForLocation( + new org.eclipse.core.runtime.Path(dwlPath.toAbsolutePath().toString())); + if (file != null && file.exists()) { + file.refreshLocal(IFile.DEPTH_ZERO, null); + } + } catch (Exception e) { + // Non-fatal: the file was written successfully; workspace refresh will happen on next build + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalysis.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalysis.java index 1a08125d..6037b47c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalysis.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalysis.java @@ -29,11 +29,20 @@ final class MuleProjectAnalysis { final Set deploymentPlugins = new LinkedHashSet<>(); final Map processorCounts = new LinkedHashMap<>(); final List diagnostics = new ArrayList<>(); + final Map flowErrorHandlerTypes = new LinkedHashMap<>(); + final Set flowsWithCorrelationId = new LinkedHashSet<>(); + final Set schedulerFlows = new LinkedHashSet<>(); + final List untilSuccessfulWithoutMaxRetries = new ArrayList<>(); String muleRuntimeVersion = ""; + String log4j2RootLevel = ""; boolean hasPom; boolean hasMuleArtifact; boolean hasApikit; boolean hasSecureProperties; + boolean hasDbPoolConfig; + boolean hasHttpRequestTimeout; + boolean hasReconnectForever; + boolean hasBatchJob; MuleProjectAnalysis(Path projectPath) { this.projectPath = projectPath; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalyzer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalyzer.java index 94a3886d..ca5ea164 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalyzer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalyzer.java @@ -64,6 +64,10 @@ static MuleProjectAnalysis scan(Path projectPath) throws Exception { listFiles(projectPath.resolve("src/main/mule"), ".xml") .forEach(file -> parseMuleXml(projectPath, file, analysis)); + Path log4j2Path = projectPath.resolve("src/main/resources/log4j2.xml"); + if (Files.isRegularFile(log4j2Path)) { + parseLog4j2(log4j2Path, analysis); + } listFiles(projectPath.resolve("src/main/resources"), null).forEach(file -> { String relative = relativize(projectPath, file); analysis.resourceFiles.add(relative); @@ -91,6 +95,22 @@ static MuleToolResponse projectScanResponse(Path projectPath) throws Exception { response.addArtifact("subFlows=" + String.join(", ", analysis.subFlows)); response.addArtifact("globalConfigs=" + String.join(", ", analysis.globalConfigs)); response.addArtifact("propertyPlaceholders=" + String.join(", ", analysis.placeholders)); + response.addArtifact("hasApikit=" + analysis.hasApikit); + response.addArtifact("hasSecureProperties=" + analysis.hasSecureProperties); + response.addArtifact("hasBatchJob=" + analysis.hasBatchJob); + response.addArtifact("schedulerFlows=" + String.join(", ", analysis.schedulerFlows)); + response.addArtifact("hasReconnectForever=" + analysis.hasReconnectForever); + response.addArtifact("log4j2RootLevel=" + blankToUnknown(analysis.log4j2RootLevel)); + response.addArtifact("hasDbPoolConfig=" + analysis.hasDbPoolConfig); + response.addArtifact("hasHttpRequestTimeout=" + analysis.hasHttpRequestTimeout); + response.addArtifact("flowsWithCorrelationId=" + String.join(", ", analysis.flowsWithCorrelationId)); + String errorHandlerSummary = analysis.flowErrorHandlerTypes.entrySet().stream() + .map(e -> e.getKey() + ":" + e.getValue()).collect(Collectors.joining(", ")); + response.addArtifact("flowErrorHandlerTypes=" + (errorHandlerSummary.isBlank() ? "none" : errorHandlerSummary)); + if (!analysis.untilSuccessfulWithoutMaxRetries.isEmpty()) { + response.addArtifact("untilSuccessfulWithoutMaxRetries=" + String.join(", ", + analysis.untilSuccessfulWithoutMaxRetries)); + } response.addDiagnostics(analysis.diagnostics); response.addNextAction("Run mule_code_review for maintainability findings."); response.addNextAction("Run mule_security_review before committing Mule configuration or property changes."); @@ -282,6 +302,21 @@ static String renderSummary(MuleProjectAnalysis analysis) { analysis.processorCounts.entrySet().stream().sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())).limit(25) .forEach(entry -> builder.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()) .append(System.lineSeparator())); + builder.append("hasApikit: ").append(analysis.hasApikit).append(System.lineSeparator()); + builder.append("hasSecureProperties: ").append(analysis.hasSecureProperties).append(System.lineSeparator()); + builder.append("hasBatchJob: ").append(analysis.hasBatchJob).append(System.lineSeparator()); + builder.append("hasReconnectForever: ").append(analysis.hasReconnectForever).append(System.lineSeparator()); + builder.append("log4j2RootLevel: ").append(blankToUnknown(analysis.log4j2RootLevel)).append(System.lineSeparator()); + builder.append("hasDbPoolConfig: ").append(analysis.hasDbPoolConfig).append(System.lineSeparator()); + builder.append("hasHttpRequestTimeout: ").append(analysis.hasHttpRequestTimeout).append(System.lineSeparator()); + if (!analysis.schedulerFlows.isEmpty()) { + appendList(builder, "Scheduler-triggered flows", analysis.schedulerFlows); + } + if (!analysis.flowsWithCorrelationId.isEmpty()) { + appendList(builder, "Flows with correlationId set", analysis.flowsWithCorrelationId); + } + builder.append("Diagnostics: ").append(analysis.diagnostics.size()) + .append(" finding(s) — run mule_project_scan for full details.").append(System.lineSeparator()); return builder.toString(); } @@ -327,6 +362,7 @@ private static void parseMuleXml(Path projectPath, Path xmlFile, MuleProjectAnal collectNamespaces(root, analysis); collectPlaceholders(root.getTextContent(), analysis.placeholders); collectElements(root, analysis); + analyzeFlowDetails(document, analysis); } catch (Exception e) { analysis.diagnostics.add(MuleDiagnostic.high(relative, 0, "Failed to parse Mule XML: " + e.getMessage(), "Fix XML syntax before asking Copilot to edit or review this file.")); @@ -368,6 +404,22 @@ private static void collectElements(Element root, MuleProjectAnalysis analysis) } else if (element.getParentNode() == root) { analysis.globalConfigs.add(qualifiedName + optionalName(element)); } + if ("reconnect-forever".equals(localName)) { + analysis.hasReconnectForever = true; + } + if ("job".equals(localName) && qualifiedName.startsWith("batch:")) { + analysis.hasBatchJob = true; + } + if ("until-successful".equals(localName) && !element.hasAttribute("maxRetries")) { + String docName = element.getAttribute("doc:name"); + analysis.untilSuccessfulWithoutMaxRetries.add(docName.isBlank() ? "unnamed" : docName); + } + if (element.hasAttribute("minPoolSize")) { + analysis.hasDbPoolConfig = true; + } + if (element.hasAttribute("responseTimeout")) { + analysis.hasHttpRequestTimeout = true; + } collectPlaceholders(element.getTextContent(), analysis.placeholders); collectAttributePlaceholders(element, analysis.placeholders); } @@ -388,6 +440,37 @@ private static void addProjectLevelDiagnostics(MuleProjectAnalysis analysis) { analysis.diagnostics.add(MuleDiagnostic.low("src/main/resources", 0, "No API specification files were found.", "Add RAML/OpenAPI/AsyncAPI/WSDL/XSD contracts when this Mule app exposes or consumes APIs.")); } + if (analysis.hasReconnectForever) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "reconnect-forever detected in connector configuration.", + "Replace reconnect-forever with reconnect (finite retries and frequency) to prevent indefinite thread blocking in production.")); + } + if (!analysis.untilSuccessfulWithoutMaxRetries.isEmpty()) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "until-successful usage without maxRetries: " + String.join(", ", analysis.untilSuccessfulWithoutMaxRetries), + "Set maxRetries and millisBetweenRetries on all until-successful scopes to prevent runaway retry loops.")); + } + if (!analysis.log4j2RootLevel.isBlank() + && (analysis.log4j2RootLevel.equalsIgnoreCase("debug") + || analysis.log4j2RootLevel.equalsIgnoreCase("trace"))) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/resources/log4j2.xml", 0, + "Root log level is " + analysis.log4j2RootLevel.toUpperCase() + " which is not suitable for production.", + "Set the root logger level to INFO or WARN before deploying to production environments.")); + } + boolean hasDbConnector = analysis.connectorDependencies.stream() + .anyMatch(d -> d.toLowerCase(Locale.ROOT).contains("db") || d.toLowerCase(Locale.ROOT).contains("database")); + if (hasDbConnector && !analysis.hasDbPoolConfig) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "Database connector dependency found but no connection pool configuration (minPoolSize/maxPoolSize) was detected.", + "Add connection pool settings to db:config to prevent connection exhaustion under load.")); + } + boolean hasHttpConnector = analysis.connectorDependencies.stream() + .anyMatch(d -> d.toLowerCase(Locale.ROOT).contains("http")); + if (hasHttpConnector && !analysis.hasHttpRequestTimeout) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "HTTP connector found but no responseTimeout was detected in HTTP Request configurations.", + "Set responseTimeout on http:request-config to prevent threads blocking indefinitely on slow upstreams.")); + } detectDuplicateNames("flow", analysis.flows, analysis.diagnostics); detectDuplicateNames("sub-flow", analysis.subFlows, analysis.diagnostics); } @@ -990,6 +1073,98 @@ private static String componentDisplayName(Element element) { return name.isBlank() ? "" : name; } + private static void analyzeFlowDetails(Document document, MuleProjectAnalysis analysis) { + NodeList flows = document.getElementsByTagNameNS("*", "flow"); + for (int i = 0; i < flows.getLength(); i++) { + if (!(flows.item(i) instanceof Element flow)) { + continue; + } + String flowName = flow.getAttribute("name"); + if (flowName.isBlank()) { + continue; + } + detectSchedulerSource(flow, flowName, analysis); + detectCorrelationIdUsage(flow, flowName, analysis); + detectFlowErrorHandlerType(flow, flowName, analysis); + } + } + + private static void detectSchedulerSource(Element flow, String flowName, MuleProjectAnalysis analysis) { + NodeList children = flow.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + if (!(children.item(i) instanceof Element firstChild)) { + continue; + } + String lname = localName(firstChild); + if ("scheduler".equals(lname) || "poll".equals(lname)) { + analysis.schedulerFlows.add(flowName); + } + return; // Only inspect the first element child (the source) + } + } + + private static void detectCorrelationIdUsage(Element flow, String flowName, MuleProjectAnalysis analysis) { + NodeList setVars = flow.getElementsByTagNameNS("*", "set-variable"); + for (int i = 0; i < setVars.getLength(); i++) { + if (!(setVars.item(i) instanceof Element setVar)) { + continue; + } + String varName = setVar.getAttribute("variableName"); + if ("correlationId".equalsIgnoreCase(varName) || "correlationID".equalsIgnoreCase(varName)) { + analysis.flowsWithCorrelationId.add(flowName); + return; + } + String value = setVar.getAttribute("value"); + if (value.contains("X-Correlation-ID") || value.contains("correlationId")) { + analysis.flowsWithCorrelationId.add(flowName); + return; + } + } + } + + private static void detectFlowErrorHandlerType(Element flow, String flowName, MuleProjectAnalysis analysis) { + NodeList errorHandlers = flow.getElementsByTagNameNS("*", "error-handler"); + if (errorHandlers.getLength() == 0) { + analysis.flowErrorHandlerTypes.put(flowName, "none"); + return; + } + Element errorHandler = (Element) errorHandlers.item(0); + boolean allTyped = true; + int handlerCount = 0; + NodeList onErrors = errorHandler.getChildNodes(); + for (int i = 0; i < onErrors.getLength(); i++) { + if (!(onErrors.item(i) instanceof Element onError)) { + continue; + } + String lname = localName(onError); + if ("on-error-propagate".equals(lname) || "on-error-continue".equals(lname)) { + handlerCount++; + if (onError.getAttribute("type").isBlank()) { + allTyped = false; + } + } + } + analysis.flowErrorHandlerTypes.put(flowName, handlerCount == 0 ? "none" : (allTyped ? "typed" : "catch-all")); + } + + private static void parseLog4j2(Path log4j2Path, MuleProjectAnalysis analysis) { + try { + Document doc = parseXml(log4j2Path); + for (String tagName : List.of("Root", "AsyncRoot")) { + NodeList nodes = doc.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + String level = ((Element) nodes.item(0)).getAttribute("level"); + if (!level.isBlank()) { + analysis.log4j2RootLevel = level; + return; + } + } + } + } catch (Exception ignored) { + // log4j2.xml parse failure is non-critical + } + } + private static Document parseXml(Path file) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectScanTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectScanTool.java index 21abfc39..61ea9fc5 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectScanTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectScanTool.java @@ -31,9 +31,14 @@ public LanguageModelToolInformation getToolInformation() { toolInfo.setName(TOOL_NAME); toolInfo.setDisplayDescription("Scan Mule project structure and metadata"); toolInfo.setDescription(""" - Detect Mule project structure, runtime version, Mule XML files, API specs, MUnit suites, - connector dependencies, APIkit usage, deployment plugins, property placeholders, and immediate diagnostics. - This tool is read-only and should be used before Mule code review, security review, or XML edits. + Detect Mule project structure and metadata. Run this first on any Mule task before code review, security review, + or XML edits. Returns: Mule runtime version, all Mule XML file paths, flow and sub-flow names, API spec paths + (RAML, OpenAPI, WSDL), MUnit suite paths and test counts, connector dependencies with versions, APIkit usage, + deployment plugins (CloudHub, Runtime Fabric), property placeholder patterns, and immediate diagnostics + (missing mule-artifact.json, missing POM, no MUnit coverage, no API spec). + Use the runtime version and connector list to check version compatibility before suggesting upgrades. + Use the MUnit coverage data to identify flows with no test coverage. + This tool is read-only. """); toolInfo.setInputSchema(MuleToolInputs.projectPathSchema()); return toolInfo; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectSummaryTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectSummaryTool.java index 4d3334e3..04d8bd86 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectSummaryTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectSummaryTool.java @@ -34,7 +34,11 @@ public LanguageModelToolInformation getToolInformation() { Summarize a MuleSoft Anypoint Studio project by reading Mule XML files under src/main/mule, project metadata, API specs, MUnit suites, connectors, deployment plugins, namespaces, flows, sub-flows, global configs, processors, and property placeholders. - This tool is read-only. + Also surfaces: hasApikit, hasSecureProperties, hasBatchJob, hasReconnectForever, + log4j2RootLevel, hasDbPoolConfig, hasHttpRequestTimeout, scheduler-triggered flows, + flows with correlationId set, and a diagnostic count. + Use mule_project_scan for a full structured JSON response including all diagnostics. + This tool is read-only and returns a human-readable text summary. """); toolInfo.setInputSchema(MuleToolInputs.projectPathSchema()); return toolInfo; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleSecurityReviewTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleSecurityReviewTool.java index 2aa00afd..7ded12f8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleSecurityReviewTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleSecurityReviewTool.java @@ -32,8 +32,15 @@ public LanguageModelToolInformation getToolInformation() { toolInfo.setDisplayDescription("Run MuleSoft security review"); toolInfo.setDescription(""" Perform a security review for MuleSoft projects by scanning Mule XML, property files, POM metadata, - and API specs for hardcoded secrets, insecure HTTP, missing secure properties, missing API contracts, - unsafe logging signals, and policy review prompts. This tool is read-only. + and API specs. Detects: hardcoded credentials (password, secret, token, apikey, clientsecret patterns in XML + or property files), plain ${property} references for sensitive values that should use ${secure::property}, + missing Secure Configuration Properties module dependency, insecure HTTP Listener endpoints (HTTP not HTTPS), + outbound HTTP Request configs with insecure="true" or missing TLS context, Database connector queries + with string-concatenated SQL (SQL injection risk), unsafe payload logging in Logger components, + flows with no authentication mechanism on HTTP-facing endpoints, and missing API policy coverage. + Common high-severity findings: base64-encoded credentials in XML attributes, passwords in config-default.yaml, + HTTP Listener on port 8081 without TLS in a production-bound project. + This tool is read-only and classifies findings as critical, high, medium, or low. """); toolInfo.setInputSchema(MuleToolInputs.securityReviewSchema()); return toolInfo; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolInputs.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolInputs.java index bfc0411c..4d83a31b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolInputs.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolInputs.java @@ -27,6 +27,18 @@ final class MuleToolInputs { static final String REVIEW_TYPE = "reviewType"; static final String SCOPE = "scope"; static final String API_EXPOSURE = "apiExposure"; + static final String XML_FILE_PATH = "xmlFilePath"; + static final String DWL_FILE_PATH = "dwlFilePath"; + static final String TRANSFORM_NAME = "transformName"; + static final String TRANSFORM_ID = "transformId"; + static final String DWL_SCRIPT = "dwlScript"; + static final String VARIABLE_NAME = "variableName"; + static final String TARGET = "target"; + static final String LAYER = "layer"; + static final String TARGET_ENVIRONMENT = "targetEnvironment"; + static final String MAVEN_PROFILE = "mavenProfile"; + static final String INCLUDE_COMMENTS = "includeComments"; + static final String APPLY_FIXES = "applyFixes"; private MuleToolInputs() { } @@ -62,7 +74,8 @@ static InputSchema codeReviewSchema() { inputSchema.setProperties(Map.of( PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder"), FILES, files, - REVIEW_TYPE, new InputSchemaPropertyValue("string", "architecture, code, pr, or full"))); + REVIEW_TYPE, new InputSchemaPropertyValue("string", "architecture, code, pr, or full"), + LAYER, new InputSchemaPropertyValue("string", "API-led layer: experience, process, system, or unknown"))); inputSchema.setRequired(List.of(PROJECT_PATH)); return inputSchema; } @@ -73,7 +86,9 @@ static InputSchema securityReviewSchema() { inputSchema.setProperties(Map.of( PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder"), SCOPE, new InputSchemaPropertyValue("string", "full, changed-files, or active-file"), - API_EXPOSURE, new InputSchemaPropertyValue("string", "public, partner, or internal"))); + API_EXPOSURE, new InputSchemaPropertyValue("string", "public, partner, or internal"), + TARGET_ENVIRONMENT, + new InputSchemaPropertyValue("string", "cloudhub, cloudhub2, rtf, standalone, or unknown"))); inputSchema.setRequired(List.of(PROJECT_PATH)); return inputSchema; } @@ -84,7 +99,8 @@ static InputSchema munitValidationSchema() { inputSchema.setProperties(Map.of( PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder"), FLOW_NAME, new InputSchemaPropertyValue("string", "Optional Mule flow name to validate test coverage for"), - MUNIT_PATH, new InputSchemaPropertyValue("string", "Optional absolute path to one MUnit suite file"))); + MUNIT_PATH, new InputSchemaPropertyValue("string", "Optional absolute path to one MUnit suite file"), + LAYER, new InputSchemaPropertyValue("string", "API-led layer: experience, process, system, or unknown"))); inputSchema.setRequired(List.of(PROJECT_PATH)); return inputSchema; } @@ -125,4 +141,72 @@ static List optionalStringList(Object value) { } return List.of(); } + + static InputSchema transformReadSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + XML_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to a Mule XML file containing ee:transform elements"), + TRANSFORM_NAME, new InputSchemaPropertyValue("string", + "Optional doc:name of the Transform Message component to read"), + TRANSFORM_ID, new InputSchemaPropertyValue("string", + "Optional doc:id of the Transform Message component to read"))); + inputSchema.setRequired(List.of(XML_FILE_PATH)); + return inputSchema; + } + + static InputSchema transformWriteSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + XML_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to the Mule XML file containing the target ee:transform element"), + TRANSFORM_NAME, new InputSchemaPropertyValue("string", + "doc:name of the Transform Message component to update"), + TRANSFORM_ID, new InputSchemaPropertyValue("string", + "doc:id of the Transform Message component to update"), + TARGET, new InputSchemaPropertyValue("string", + "What to update: 'payload', 'attributes', 'variable:name', or a variable name"), + DWL_SCRIPT, new InputSchemaPropertyValue("string", + "Complete DataWeave 2.0 script starting with %dw 2.0 and output directive"))); + inputSchema.setRequired(List.of(XML_FILE_PATH, DWL_SCRIPT)); + return inputSchema; + } + + static InputSchema dwlReadSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + DWL_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to a standalone DataWeave module (.dwl) file"))); + inputSchema.setRequired(List.of(DWL_FILE_PATH)); + return inputSchema; + } + + static InputSchema dwlWriteSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + DWL_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to the .dwl file to write"), + DWL_SCRIPT, new InputSchemaPropertyValue("string", + "Complete replacement DataWeave 2.0 script (should start with %dw 2.0 and output directive)"))); + inputSchema.setRequired(List.of(DWL_FILE_PATH, DWL_SCRIPT)); + return inputSchema; + } + + static InputSchema dwlOptimizeSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + DWL_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to the .dwl file to analyze and optimize"), + INCLUDE_COMMENTS, new InputSchemaPropertyValue("boolean", + "Whether to add inline comments to undocumented functions (default: true)"), + APPLY_FIXES, new InputSchemaPropertyValue("boolean", + "Whether to write the optimized script back to the file (default: false — preview only)"))); + inputSchema.setRequired(List.of(DWL_FILE_PATH)); + return inputSchema; + } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformReadTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformReadTool.java new file mode 100644 index 00000000..efd119f8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformReadTool.java @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Reads DataWeave scripts from Transform Message (ee:transform) components in Mule XML files. + * Returns the payload, attributes, and variable DWL scripts along with their output type declarations, + * giving Copilot the context needed to generate or improve DataWeave mappings. + */ +public class MuleTransformReadTool extends BaseTool { + static final String TOOL_NAME = "mule_read_transform"; + + /** + * Creates a Mule transform read tool. + */ + public MuleTransformReadTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Read DataWeave scripts from Transform Message components"); + toolInfo.setDescription(""" + Read the DataWeave 2.0 scripts inside Transform Message (ee:transform) components in a Mule XML file. + Returns set-payload, set-attributes, and set-variable scripts with output types. + External DWL resources are reported and read from src/main/resources when the project root can be inferred. + Use this before writing or reviewing a DataWeave mapping to understand the current state and type context. + Optionally filter by doc:name or doc:id to target a specific Transform Message component. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.transformReadSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path xmlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.XML_FILE_PATH)); + if (xmlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("xmlFilePath must be an absolute path to an existing Mule XML file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String transformName = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_NAME)); + String transformId = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_ID)); + + ReadOutcome outcome = readTransforms(xmlPath, transformName, transformId); + result.setStatus(outcome.success() ? ToolInvocationStatus.success : ToolInvocationStatus.error); + result.addContent(outcome.message()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to read Transform Message: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static ReadOutcome readTransforms(Path xmlPath, String transformName, String transformId) throws Exception { + Document document = MuleTransformSupport.parseXml(xmlPath); + List matched = MuleTransformSupport.findTransforms(document, transformName, transformId); + if (matched.isEmpty()) { + return new ReadOutcome(false, + "No ee:transform element matched the given transformName or transformId in " + xmlPath.getFileName()); + } + + StringBuilder sb = new StringBuilder(); + sb.append("file=").append(xmlPath.toAbsolutePath()).append(System.lineSeparator()); + sb.append("transformCount=").append(matched.size()).append(System.lineSeparator()); + + for (Element transform : matched) { + sb.append(System.lineSeparator()); + sb.append("--- Transform: ").append(MuleTransformSupport.transformLabel(transform)).append(" ---") + .append(System.lineSeparator()); + + appendScriptsFromMessage(xmlPath, transform, sb); + appendScriptsFromVariables(xmlPath, transform, sb); + } + + return new ReadOutcome(true, sb.toString()); + } + + private static void appendScriptsFromMessage(Path xmlPath, Element transform, StringBuilder sb) { + for (Element messageEl : MuleTransformSupport.directChildren(transform, "message")) { + for (Element setPayloadEl : MuleTransformSupport.directChildren(messageEl, "set-payload")) { + appendScript(xmlPath, sb, MuleTransformSupport.TARGET_PAYLOAD, setPayloadEl); + } + for (Element setAttributesEl : MuleTransformSupport.directChildren(messageEl, "set-attributes")) { + appendScript(xmlPath, sb, MuleTransformSupport.TARGET_ATTRIBUTES, setAttributesEl); + } + } + } + + private static void appendScriptsFromVariables(Path xmlPath, Element transform, StringBuilder sb) { + for (Element variablesEl : MuleTransformSupport.directChildren(transform, "variables")) { + for (Element setVarEl : MuleTransformSupport.directChildren(variablesEl, "set-variable")) { + String varName = setVarEl.getAttribute("variableName"); + appendScript(xmlPath, sb, MuleTransformSupport.TARGET_VARIABLE_PREFIX + varName, setVarEl); + } + } + } + + private static void appendScript(Path xmlPath, StringBuilder sb, String target, Element element) { + MuleTransformSupport.ScriptContent content = MuleTransformSupport.readScriptContent(element, xmlPath); + String script = content.script(); + String outputType = extractOutputType(script); + sb.append("target=").append(target).append(System.lineSeparator()); + if (!content.resource().isBlank()) { + sb.append("resource=").append(content.resource()).append(System.lineSeparator()); + sb.append("resourceStatus=").append(content.resourceStatus()).append(System.lineSeparator()); + if (content.resourcePath() != null) { + sb.append("resourcePath=").append(content.resourcePath()).append(System.lineSeparator()); + } + } + sb.append("outputType=").append(outputType).append(System.lineSeparator()); + sb.append("script:").append(System.lineSeparator()).append(script).append(System.lineSeparator()); + } + + private static String extractOutputType(String script) { + for (String line : script.split("\\r?\\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("output ")) { + return trimmed.substring("output ".length()).trim(); + } + } + return "unknown"; + } + + private record ReadOutcome(boolean success, String message) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformSupport.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformSupport.java new file mode 100644 index 00000000..3d266cc2 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformSupport.java @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.io.InputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.w3c.dom.CDATASection; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +/** + * Shared XML and DataWeave helpers for Transform Message tools. + */ +public final class MuleTransformSupport { + public static final String EE_NS = "http://www.mulesoft.org/schema/mule/ee/core"; + public static final String DOC_NS = "http://www.mulesoft.org/schema/mule/documentation"; + public static final String TARGET_ATTRIBUTES = "attributes"; + public static final String TARGET_PAYLOAD = "payload"; + public static final String TARGET_VARIABLE_PREFIX = "variable:"; + + private static final Path MULE_SOURCE_PATH = Path.of("src", "main", "mule"); + private static final Path RESOURCES_PATH = Path.of("src", "main", "resources"); + + private MuleTransformSupport() { + } + + public static Document parseXml(Path file) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + trySetFeature(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); + trySetFeature(factory, "http://apache.org/xml/features/disallow-doctype-decl", true); + trySetFeature(factory, "http://xml.org/sax/features/external-general-entities", false); + trySetFeature(factory, "http://xml.org/sax/features/external-parameter-entities", false); + try (InputStream inputStream = Files.newInputStream(file)) { + return factory.newDocumentBuilder().parse(inputStream); + } + } + + static void serializeDocument(Document document, Path xmlPath) throws Exception { + String original = Files.readString(xmlPath, StandardCharsets.UTF_8); + boolean hasXmlDeclaration = original.stripLeading().startsWith(" findTransforms(Document document, String transformName, String transformId) { + List matched = new ArrayList<>(); + var transforms = document.getElementsByTagNameNS(EE_NS, "transform"); + for (int i = 0; i < transforms.getLength(); i++) { + if (transforms.item(i) instanceof Element element + && matchesTransform(element, transformName, transformId, transformName.isBlank() && transformId.isBlank())) { + matched.add(element); + } + } + return matched; + } + + public static List directChildren(Element parent, String localName) { + List children = new ArrayList<>(); + for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child instanceof Element element && EE_NS.equals(element.getNamespaceURI()) + && localName.equals(element.getLocalName())) { + children.add(element); + } + } + return children; + } + + public static Element firstDirectChild(Element parent, String localName) { + List children = directChildren(parent, localName); + return children.isEmpty() ? null : children.get(0); + } + + public static ScriptContent readScriptContent(Element element, Path xmlPath) { + String resource = element.getAttribute("resource"); + if (resource.isBlank()) { + return new ScriptContent(element.getTextContent().trim(), "", null, ""); + } + ResourceResolution resourceResolution = resolveResource(xmlPath, resource); + if (resourceResolution.path() != null && Files.isRegularFile(resourceResolution.path())) { + try { + String script = Files.readString(resourceResolution.path(), StandardCharsets.UTF_8).trim(); + return new ScriptContent(script, resource, resourceResolution.path(), "resolved"); + } catch (Exception e) { + return new ScriptContent("", resource, resourceResolution.path(), "unreadable: " + e.getMessage()); + } + } + return new ScriptContent("", resource, resourceResolution.path(), resourceResolution.status()); + } + + static WriteContentResult writeScriptContent(Document document, Element element, Path xmlPath, String dwlScript) + throws Exception { + String resource = element.getAttribute("resource"); + if (!resource.isBlank()) { + ResourceResolution resourceResolution = resolveResource(xmlPath, resource); + if (resourceResolution.path() == null || !Files.isRegularFile(resourceResolution.path())) { + return new WriteContentResult(false, false, null, + "External DWL resource could not be resolved: " + resource + " (" + resourceResolution.status() + ")"); + } + Files.writeString(resourceResolution.path(), dwlScript, StandardCharsets.UTF_8); + return new WriteContentResult(true, false, resourceResolution.path(), + "Updated external DWL resource " + resourceResolution.path().getFileName()); + } + + while (element.hasChildNodes()) { + element.removeChild(element.getFirstChild()); + } + CDATASection cdata = document.createCDATASection(dwlScript); + element.appendChild(cdata); + return new WriteContentResult(true, true, xmlPath, "Updated inline DataWeave script"); + } + + public static String transformLabel(Element transform) { + String docName = transform.getAttributeNS(DOC_NS, "name"); + if (docName.isBlank()) { + docName = transform.getAttribute("doc:name"); + } + String docId = transform.getAttributeNS(DOC_NS, "id"); + if (docId.isBlank()) { + docId = transform.getAttribute("doc:id"); + } + return docName.isBlank() ? "(unnamed)" : docName + (docId.isBlank() ? "" : " [id=" + docId + "]"); + } + + private static boolean matchesTransform(Element transform, String transformName, String transformId, + boolean matchAll) { + if (matchAll) { + return true; + } + String docName = transform.getAttributeNS(DOC_NS, "name"); + if (docName.isBlank()) { + docName = transform.getAttribute("doc:name"); + } + String docId = transform.getAttributeNS(DOC_NS, "id"); + if (docId.isBlank()) { + docId = transform.getAttribute("doc:id"); + } + if (!transformName.isBlank() && !transformId.isBlank()) { + return matchesName(transformName, docName) && matchesId(transformId, docId); + } + return (!transformName.isBlank() && matchesName(transformName, docName)) + || (!transformId.isBlank() && matchesId(transformId, docId)); + } + + private static boolean matchesName(String filter, String docName) { + String f = filter.trim(); + String n = docName.trim(); + // Exact match first, then case-insensitive, then substring (handles partial names from AI) + return n.equals(f) || n.equalsIgnoreCase(f) || n.toLowerCase().contains(f.toLowerCase()); + } + + private static boolean matchesId(String filter, String docId) { + String f = filter.trim(); + String d = docId.trim(); + return d.equals(f) || d.equalsIgnoreCase(f); + } + + private static ResourceResolution resolveResource(Path xmlPath, String resource) { + Path projectRoot = findProjectRoot(xmlPath); + if (projectRoot == null) { + return new ResourceResolution(null, "projectRootNotFound"); + } + Path resourcesRoot = projectRoot.resolve(RESOURCES_PATH).toAbsolutePath().normalize(); + Path resourcePath = Path.of(resource); + Path candidate = resourcePath.isAbsolute() ? resourcePath.normalize() + : resourcesRoot.resolve(resourcePath).normalize(); + if (!candidate.startsWith(resourcesRoot)) { + return new ResourceResolution(candidate, "outsideResources"); + } + return new ResourceResolution(candidate, Files.isRegularFile(candidate) ? "resolved" : "notFound"); + } + + private static Path findProjectRoot(Path xmlPath) { + Path current = xmlPath.toAbsolutePath().normalize().getParent(); + while (current != null) { + if (current.endsWith(MULE_SOURCE_PATH)) { + Path srcMain = current.getParent(); + Path src = srcMain == null ? null : srcMain.getParent(); + return src == null ? null : src.getParent(); + } + current = current.getParent(); + } + + current = xmlPath.toAbsolutePath().normalize().getParent(); + while (current != null) { + if (Files.isRegularFile(current.resolve("pom.xml"))) { + return current; + } + current = current.getParent(); + } + return null; + } + + private static void trySetFeature(DocumentBuilderFactory factory, String feature, boolean enabled) { + try { + factory.setFeature(feature, enabled); + } catch (Exception ignored) { + // Some XML parsers do not expose every hardening feature. + } + } + + private static void trySetAttribute(TransformerFactory factory, String attribute, String value) { + try { + factory.setAttribute(attribute, value); + } catch (Exception ignored) { + // Some transformer implementations do not expose every hardening attribute. + } + } + + public record ScriptContent(String script, String resource, Path resourcePath, String resourceStatus) { + } + + record WriteContentResult(boolean success, boolean xmlModified, Path modifiedPath, String message) { + } + + private record ResourceResolution(Path path, String status) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformWriteTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformWriteTool.java new file mode 100644 index 00000000..79902d8a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformWriteTool.java @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Writes a DataWeave 2.0 script into a Transform Message (ee:transform) component in a Mule XML file. + * Targets the ee:set-payload, ee:set-attributes, or a named ee:set-variable element. + * Requires user confirmation before modifying the XML file. + */ +public class MuleTransformWriteTool extends BaseTool { + static final String TOOL_NAME = "mule_write_transform"; + + /** + * Creates a Mule transform write tool. + */ + public MuleTransformWriteTool() { + this.name = TOOL_NAME; + } + + @Override + public boolean needConfirmation() { + return true; + } + + @Override + public ConfirmationMessages getConfirmationMessages() { + ConfirmationMessages messages = new ConfirmationMessages(); + messages.setTitle("Update Transform Message DataWeave Script"); + messages.setMessage( + "This will replace the DataWeave script inside the Transform Message component in the Mule XML file. " + + "Continue?"); + return messages; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Write a DataWeave script into a Transform Message component"); + toolInfo.setDescription(""" + Replace the DataWeave 2.0 script inside a Transform Message (ee:transform) component in a Mule XML file. + Use target 'payload', 'attributes', 'variable:name', or a variable name. + Identify the transform by transformName (doc:name) or transformId (doc:id). + If the target uses resource=\"...\", the external DWL file under src/main/resources is updated. + The dwlScript must be a complete DataWeave 2.0 script starting with %dw 2.0 and an output directive. + Always run mule_read_transform first to confirm the current state before writing. + Requires user confirmation before modifying the file. + """); + toolInfo.setInputSchema(MuleToolInputs.transformWriteSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path xmlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.XML_FILE_PATH)); + if (xmlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("xmlFilePath must be an absolute path to an existing Mule XML file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String dwlScript = MuleToolInputs.optionalString(input.get(MuleToolInputs.DWL_SCRIPT)); + if (dwlScript.isBlank()) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlScript is required and must not be blank."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String target = MuleToolInputs.optionalString(input.get(MuleToolInputs.TARGET)); + String transformName = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_NAME)); + String transformId = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_ID)); + + WriteOutcome outcome = writeTransform(xmlPath, transformName, transformId, target, dwlScript); + if (outcome.refreshPath() != null) { + refreshWorkspaceFile(outcome.refreshPath()); + } + + result.setStatus(outcome.success() ? ToolInvocationStatus.success : ToolInvocationStatus.error); + result.addContent(outcome.message()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to write Transform Message: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static WriteOutcome writeTransform(Path xmlPath, String transformName, String transformId, + String target, String dwlScript) throws Exception { + Document document = MuleTransformSupport.parseXml(xmlPath); + SingleTransformMatch match = findSingleTransform(document, transformName, transformId); + if (!match.success()) { + return new WriteOutcome(false, match.message(), null); + } + + TargetElement targetElement = findTargetElement(match.transform(), target); + if (!targetElement.success()) { + return new WriteOutcome(false, targetElement.message(), null); + } + + MuleTransformSupport.WriteContentResult writeResult = + MuleTransformSupport.writeScriptContent(document, targetElement.element(), xmlPath, dwlScript); + if (!writeResult.success()) { + return new WriteOutcome(false, writeResult.message(), null); + } + if (writeResult.xmlModified()) { + MuleTransformSupport.serializeDocument(document, xmlPath); + } + + String message = writeResult.message() + " for Transform Message '" + + MuleTransformSupport.transformLabel(match.transform()) + "' target='" + targetElement.label() + + "' in " + (writeResult.modifiedPath() == null ? xmlPath.getFileName() : writeResult.modifiedPath()); + return new WriteOutcome(true, message, writeResult.modifiedPath()); + } + + private static SingleTransformMatch findSingleTransform(Document document, String transformName, String transformId) { + List transforms = MuleTransformSupport.findTransforms(document, transformName, transformId); + if (transforms.isEmpty()) { + return new SingleTransformMatch(false, + "No matching ee:transform element found. Provide transformName or transformId, or verify the file path.", + null); + } + if (transforms.size() > 1) { + return new SingleTransformMatch(false, + "Multiple ee:transform elements matched. Provide a unique transformName or transformId.", null); + } + return new SingleTransformMatch(true, "", transforms.get(0)); + } + + private static TargetElement findTargetElement(Element transform, String target) { + String normalizedTarget = normalizeTarget(target); + if (MuleTransformSupport.TARGET_PAYLOAD.equals(normalizedTarget)) { + Element payload = firstMessageChild(transform, "set-payload"); + return payload == null ? missingTarget(normalizedTarget) : new TargetElement(true, "", payload, normalizedTarget); + } + if (MuleTransformSupport.TARGET_ATTRIBUTES.equals(normalizedTarget)) { + Element attributes = firstMessageChild(transform, "set-attributes"); + return attributes == null ? missingTarget(normalizedTarget) + : new TargetElement(true, "", attributes, normalizedTarget); + } + + String variableName = normalizedTarget.startsWith(MuleTransformSupport.TARGET_VARIABLE_PREFIX) + ? normalizedTarget.substring(MuleTransformSupport.TARGET_VARIABLE_PREFIX.length()) : normalizedTarget; + for (Element variablesEl : MuleTransformSupport.directChildren(transform, "variables")) { + for (Element setVarEl : MuleTransformSupport.directChildren(variablesEl, "set-variable")) { + if (variableName.equals(setVarEl.getAttribute("variableName"))) { + return new TargetElement(true, "", setVarEl, MuleTransformSupport.TARGET_VARIABLE_PREFIX + variableName); + } + } + } + return missingTarget(MuleTransformSupport.TARGET_VARIABLE_PREFIX + variableName); + } + + private static Element firstMessageChild(Element transform, String localName) { + for (Element messageEl : MuleTransformSupport.directChildren(transform, "message")) { + Element child = MuleTransformSupport.firstDirectChild(messageEl, localName); + if (child != null) { + return child; + } + } + return null; + } + + private static String normalizeTarget(String target) { + String normalized = target == null ? "" : target.trim(); + return normalized.isBlank() ? MuleTransformSupport.TARGET_PAYLOAD : normalized; + } + + private static TargetElement missingTarget(String target) { + return new TargetElement(false, + "Target element not found in transform. Verify that target '" + target + + "' exists in the Transform Message component.", + null, target); + } + + private static void refreshWorkspaceFile(Path xmlPath) { + try { + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile file = root.getFileForLocation( + new org.eclipse.core.runtime.Path(xmlPath.toAbsolutePath().toString())); + if (file != null && file.exists()) { + file.refreshLocal(IFile.DEPTH_ZERO, null); + } + } catch (Exception e) { + // Non-fatal: the file was written successfully; workspace refresh will happen on next build + } + } + + private record SingleTransformMatch(boolean success, String message, Element transform) { + } + + private record TargetElement(boolean success, String message, Element element, String label) { + } + + private record WriteOutcome(boolean success, String message, Path refreshPath) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitFullReviewTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitFullReviewTool.java index 99eb3be9..0ce5594c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitFullReviewTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitFullReviewTool.java @@ -31,9 +31,15 @@ public LanguageModelToolInformation getToolInformation() { toolInfo.setName(TOOL_NAME); toolInfo.setDisplayDescription("Review MUnit purpose, coverage, and quality"); toolInfo.setDescription(""" - Perform a full read-only MUnit review for Mule flows. Combines structure validation, logical test-purpose - checks, component coverage, branch and error-path review, assertion quality, external connector mock coverage, - and scenario completeness. Returns structured findings and next actions. + Perform a comprehensive read-only MUnit review for Mule flows. Combines all validation checks from + munit_validate_flow_tests with deeper scenario analysis: identifies which of happy-path, negative-path, + edge-data, connector-failure, and error-contract scenarios are missing per flow; flags assertion quality + issues (asserting implementation details instead of output contracts, no assertions on error handler behavior); + checks mock coverage completeness (every external connector call mocked vs. only some); reviews Choice + router branch coverage and scatter-gather route coverage; and identifies tests that duplicate each other. + Use this for a full suite audit before a release or when test quality is unclear. + Returns structured findings with missing scenario descriptions and recommended additional test cases. + This tool is read-only. """); toolInfo.setInputSchema(MuleToolInputs.munitValidationSchema()); return toolInfo; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitValidateFlowTestsTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitValidateFlowTestsTool.java index 208780e0..a2849089 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitValidateFlowTestsTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitValidateFlowTestsTool.java @@ -31,9 +31,15 @@ public LanguageModelToolInformation getToolInformation() { toolInfo.setName(TOOL_NAME); toolInfo.setDisplayDescription("Validate MUnit purpose, structure, and flow coverage"); toolInfo.setDescription(""" - Validate MUnit suites for Mule flows. Checks whether tests have a logical purpose, whether MUnit namespaces, - schema locations, munit:config, execution, validation, assertions, mocks, spies, and verify-call processors are - present, and whether flow components and behavioral aspects are covered. This tool is read-only. + Validate MUnit suites for Mule flows. Checks: MUnit and MUnit Tools namespace declarations, munit:config + presence, test execution elements (munit:execution), assertion elements (munit:assert-that or munit-tools), + mock-when usage for external connectors, spy and verify-call usage, and whether each test has a clear + logical purpose tied to a specific flow scenario (happy path, error path, branch path). + Coverage checks: identifies flows with no corresponding MUnit test, identifies munit:test elements with + no assertions (tests that never fail), identifies missing connector mocks (tests that would make real + external calls), and identifies untested Choice router branches. + Use this tool after generating tests to confirm structural completeness before running Maven. + This tool is read-only. """); toolInfo.setInputSchema(MuleToolInputs.munitValidationSchema()); return toolInfo; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunMuleMavenTestsTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunMuleMavenTestsTool.java index bd96edd4..15aa228a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunMuleMavenTestsTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunMuleMavenTestsTool.java @@ -35,6 +35,7 @@ public class RunMuleMavenTestsTool extends BaseTool { private static final String PROJECT_PATH = "projectPath"; private static final String GOALS = "goals"; private static final String MAX_OUTPUT_CHARS = "maxOutputChars"; + private static final String MAVEN_PROFILE = "mavenProfile"; private static final int DEFAULT_MAX_OUTPUT_CHARS = 12000; private static final Duration TIMEOUT = Duration.ofMinutes(10); @@ -51,18 +52,24 @@ public LanguageModelToolInformation getToolInformation() { toolInfo.setName(TOOL_NAME); toolInfo.setDisplayDescription("Run Maven or MUnit validation for a Mule project"); toolInfo.setDescription(""" - Run Maven validation for a MuleSoft project and return the command output. + Run Maven validation for a MuleSoft project and return command output. Use this after generating or modifying Mule XML, DataWeave, RAML/OpenAPI, or MUnit tests. - The default goal is test, which runs the project's Maven test lifecycle. + Default goal is "test" which runs the full Maven test lifecycle including MUnit suites. + MUnit-specific flags: pass "-Dmunit.test=.xml" in goals to run a single suite, + or "-DskipMunitTests=false" to force MUnit execution when tests are skipped by profile. + Multi-module projects: add "-pl " to the goals array to target a specific module. + Use mavenProfile to activate an environment-specific Maven profile (e.g., "dev", "test"). """); InputSchema inputSchema = new InputSchema(); inputSchema.setType("object"); Map properties = new LinkedHashMap<>(); properties.put(PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder")); InputSchemaPropertyValue goals = new InputSchemaPropertyValue("array", - "Maven goals to run, for example [\"test\"] or [\"-DskipMunitTests=false\", \"test\"]"); - goals.setItems(new InputSchemaPropertyValue("string", "A Maven goal or argument")); + "Maven goals and arguments, e.g. [\"test\"] or [\"-Dmunit.test=mySuite.xml\", \"test\"]"); + goals.setItems(new InputSchemaPropertyValue("string", "A Maven goal or argument flag")); properties.put(GOALS, goals); + properties.put(MAVEN_PROFILE, + new InputSchemaPropertyValue("string", "Optional Maven profile to activate with -P, e.g. dev or test")); properties.put(MAX_OUTPUT_CHARS, new InputSchemaPropertyValue("number", "Maximum output characters to return")); inputSchema.setProperties(properties); inputSchema.setRequired(List.of(PROJECT_PATH)); @@ -93,7 +100,7 @@ public CompletableFuture invoke(Map i return new LanguageModelToolResult[] { result }; } - List command = buildCommand(projectPath, input.get(GOALS)); + List command = buildCommand(projectPath, input.get(GOALS), input.get(MAVEN_PROFILE)); ProcessResult processResult = run(projectPath, command, getMaxOutputChars(input.get(MAX_OUTPUT_CHARS))); result.setStatus(processResult.exitCode == 0 ? ToolInvocationStatus.success : ToolInvocationStatus.error); result.addContent(processResult.render(command)); @@ -127,11 +134,15 @@ private ProcessResult run(Path projectPath, List command, int maxOutputC return new ProcessResult(process.exitValue(), output.toString()); } - private List buildCommand(Path projectPath, Object goalsInput) { + private List buildCommand(Path projectPath, Object goalsInput, Object profileInput) { List command = new ArrayList<>(); command.add(findMavenExecutable(projectPath)); List goals = parseGoals(goalsInput); command.addAll(goals.isEmpty() ? List.of("test") : goals); + if (profileInput instanceof String profile && !profile.isBlank()) { + command.add("-P"); + command.add(profile.trim()); + } return command; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/DataWeaveContentAssistProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/DataWeaveContentAssistProcessor.java new file mode 100644 index 00000000..0a84727d --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/DataWeaveContentAssistProcessor.java @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.completion; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.core.resources.IFile; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.contentassist.CompletionProposal; +import org.eclipse.jface.text.contentassist.ICompletionProposal; +import org.eclipse.jface.text.contentassist.IContentAssistProcessor; +import org.eclipse.jface.text.contentassist.IContextInformation; +import org.eclipse.jface.text.contentassist.IContextInformationValidator; +import org.eclipse.lsp4j.Position; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.completion.CompletionListener; +import com.microsoft.copilot.eclipse.core.completion.CompletionProvider; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompletionItem; +import com.microsoft.copilot.eclipse.core.utils.FileUtils; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Content assist processor that delivers Copilot inline completions inside DataWeave (.dwl) editors. + * + *

Eclipse registers this processor via the {@code org.eclipse.ui.workbench.texteditor.contentAssist} + * extension point for the {@code com.microsoft.copilot.eclipse.ui.dataweaveFile} content type. When the + * user triggers content assist (Ctrl+Space) in a .dwl editor, Eclipse calls + * {@link #computeCompletionProposals}, which fires a Copilot LSP completion request and waits briefly for + * the result before returning proposals to the standard Eclipse content assist popup. + */ +public class DataWeaveContentAssistProcessor implements IContentAssistProcessor { + + private static final long COMPLETION_WAIT_MS = 3_000; + + @Override + public ICompletionProposal[] computeCompletionProposals(ITextViewer viewer, int offset) { + IDocument document = viewer.getDocument(); + if (document == null) { + return new ICompletionProposal[0]; + } + + IFile file = getFileForDocument(document); + if (file == null) { + // Unable to determine the file for this document + return new ICompletionProposal[0]; + } + + Position position = toPosition(document, offset); + if (position == null) { + return new ICompletionProposal[0]; + } + + // Ensure the document is connected to the Copilot LSP. EditorLifecycleListener handles this for + // standard ITextEditor parts, but the DataWeave embedded editor is a custom SWT widget that does + // not adapt to ITextEditor, so connectDocumentIfNecessary skips it. We connect here explicitly + // using the IDocument we already have from the viewer. + CopilotLanguageServerConnection lsConnection = CopilotCore.getPlugin().getCopilotLanguageServer(); + if (lsConnection != null) { + lsConnection.connectDocument(document, file); + } + + CompletionProvider provider = CopilotCore.getPlugin().getCompletionProvider(); + if (provider == null) { + return new ICompletionProposal[0]; + } + + String fileUri = FileUtils.getResourceUri(file); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> resultRef = new AtomicReference<>(); + + CompletionListener listener = new CompletionListener() { + @Override + public void onCompletionResolved(String uriString, List completions) { + if (fileUri != null && fileUri.equals(uriString)) { + resultRef.set(completions); + latch.countDown(); + } + } + }; + + provider.addCompletionListener(listener); + try { + int documentVersion = document.hashCode(); + provider.triggerCompletion(file, position, documentVersion, false); + latch.await(COMPLETION_WAIT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + provider.removeCompletionListener(listener); + } + + List items = resultRef.get(); + if (items == null || items.isEmpty()) { + return new ICompletionProposal[0]; + } + + return toProposals(items, offset); + } + + @Override + public IContextInformation[] computeContextInformation(ITextViewer viewer, int offset) { + return null; + } + + @Override + public char[] getCompletionProposalAutoActivationCharacters() { + return null; + } + + @Override + public char[] getContextInformationAutoActivationCharacters() { + return null; + } + + @Override + public String getErrorMessage() { + return null; + } + + @Override + public IContextInformationValidator getContextInformationValidator() { + return null; + } + + private IFile getFileForDocument(IDocument document) { + // Try to get the file from the active editor + IFile file = UiUtils.getCurrentFile(); + if (file != null) { + return file; + } + + // Fallback: look for an open .dwl file. This helps support custom editors + // that don't integrate fully with Eclipse's editor infrastructure. + try { + List openFiles = UiUtils.getOpenedFiles(); + for (IFile openFile : openFiles) { + if ("dwl".equals(openFile.getFileExtension()) && openFile.exists()) { + return openFile; + } + } + } catch (Exception e) { + // Ignore and continue + } + + return null; + } + + private Position toPosition(IDocument document, int offset) { + try { + int line = document.getLineOfOffset(offset); + int lineStart = document.getLineOffset(line); + return new Position(line, offset - lineStart); + } catch (Exception e) { + return null; + } + } + + private ICompletionProposal[] toProposals(List items, int offset) { + List proposals = new ArrayList<>(); + for (CompletionItem item : items) { + String insertText = item.getText(); + if (insertText == null || insertText.isBlank()) { + continue; + } + // Use the displayText as the proposal label, falling back to the insert text itself + String displayText = item.getDisplayText(); + String label = (displayText != null && !displayText.isBlank()) ? displayText : insertText; + // Replace from the start of the current token (offset) with the full completion text + proposals.add(new CompletionProposal(insertText, offset, 0, insertText.length(), null, label, null, null)); + } + return proposals.toArray(new ICompletionProposal[0]); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java index f59f74ec..2c15179e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java @@ -45,6 +45,7 @@ import com.microsoft.copilot.eclipse.ui.dialogs.mcp.McpServerInstallManager.ActionType; import com.microsoft.copilot.eclipse.ui.dialogs.mcp.McpServerInstallManager.ButtonState; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SplitDropdownButton; import com.microsoft.copilot.eclipse.ui.utils.McpUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -301,15 +302,17 @@ public void widgetSelected(SelectionEvent e) { disabledInstallButton.setToolTipText(Messages.mcpServerItem_noInstallOptions); actionButton = disabledInstallButton; } else { - DropDownButton dropDownInstallButton = createDropDownInstallButton(actionComposite, initialState, + SplitDropdownButton dropDownInstallButton = createDropDownInstallButton(actionComposite, initialState, installOptions); actionButton = dropDownInstallButton.getButton(); } } } - private DropDownButton createDropDownInstallButton(Composite parent, ButtonState state, List options) { - DropDownButton dropDownInstallButton = new DropDownButton(parent, SWT.NONE); + private SplitDropdownButton createDropDownInstallButton( + Composite parent, ButtonState state, + List options) { + SplitDropdownButton dropDownInstallButton = new SplitDropdownButton(parent, SWT.NONE); dropDownInstallButton.setShowArrow(options.size() > 1); dropDownInstallButton.setText(state.getText()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java index aa6f8eaa..b130799d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java @@ -91,6 +91,7 @@ protected Control createDialogArea(Composite parent) { apiKeyText.setEchoChar('*'); apiKeyText.setText(apiKey != null ? apiKey : ""); apiKeyText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(apiKeyText); apiKeyText.addModifyListener(this::onFieldChanged); eyeOpenImg = UiUtils diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java index 4d20ddf6..858a41cd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java @@ -82,6 +82,7 @@ protected Control createDialogArea(Composite parent) { new Label(container, SWT.NONE).setText(Messages.preferences_page_byok_addModel_modelId); modelIdText = new Text(container, SWT.BORDER); modelIdText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(modelIdText); modelIdText.addModifyListener(this::onFieldChanged); // Provider-specific fields @@ -93,6 +94,7 @@ protected Control createDialogArea(Composite parent) { new Label(container, SWT.NONE).setText(Messages.preferences_page_byok_addModel_displayName); displayNameText = new Text(container, SWT.BORDER); displayNameText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(displayNameText); // Capabilities Composite caps = new Composite(container, SWT.NONE); @@ -119,6 +121,7 @@ private void createAzureSpecificFields(Composite container) { new Label(container, SWT.NONE).setText(Messages.preferences_page_byok_addModel_deploymentUrl); deploymentUrlText = new Text(container, SWT.BORDER); deploymentUrlText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(deploymentUrlText); deploymentUrlText.addModifyListener(this::onFieldChanged); // API Key * @@ -133,6 +136,7 @@ private void createAzureSpecificFields(Composite container) { apiKeyText = new Text(apiKeyRow, SWT.BORDER); apiKeyText.setEchoChar('*'); apiKeyText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(apiKeyText); apiKeyText.addModifyListener(this::onFieldChanged); eyeOpenImg = UiUtils diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java new file mode 100644 index 00000000..cbaa82c1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Dialog for adding a file-operation auto-approve rule with pattern, description, and allow/deny. + */ +public class AddFileOperationRuleDialog extends Dialog { + + private Text patternText; + private Text descriptionText; + private Button allowRadio; + + private String pattern; + private String description; + private boolean autoApprove; + + /** + * Creates the dialog. + * + * @param parent the parent shell + */ + public AddFileOperationRuleDialog(Shell parent) { + super(parent); + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_title); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite container = new Composite(area, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // Pattern * + Label patternLabel = new Label(container, SWT.NONE); + patternLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern + + " *"); + patternText = new Text(container, SWT.BORDER); + GridData patternData = new GridData(SWT.FILL, SWT.CENTER, true, false); + patternData.widthHint = 300; + patternText.setLayoutData(patternData); + patternText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern_hint); + patternText.addModifyListener(e -> updateOkButton()); + + // Description + Label descLabel = new Label(container, SWT.NONE); + descLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_description); + descriptionText = new Text(container, SWT.BORDER); + descriptionText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_description_hint); + descriptionText.setLayoutData( + new GridData(SWT.FILL, SWT.CENTER, true, false)); + + // Allow / Deny + Label approveLabel = new Label(container, SWT.NONE); + approveLabel.setText( + Messages.preferences_page_auto_approve_add_dialog_approve); + Composite radioGroup = new Composite(container, SWT.NONE); + radioGroup.setLayout(new GridLayout(2, false)); + allowRadio = new Button(radioGroup, SWT.RADIO); + allowRadio.setText(Messages.preferences_page_auto_approve_allow); + allowRadio.setSelection(true); + Button denyRadio = new Button(radioGroup, SWT.RADIO); + denyRadio.setText(Messages.preferences_page_auto_approve_deny); + + return area; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + updateOkButton(); + } + + private void updateOkButton() { + Button ok = getButton(OK); + if (ok != null) { + ok.setEnabled( + patternText != null && !patternText.getText().trim().isEmpty()); + } + } + + @Override + protected void okPressed() { + pattern = patternText.getText().trim(); + description = descriptionText.getText().trim(); + autoApprove = allowRadio.getSelection(); + super.okPressed(); + } + + public String getPattern() { + return pattern; + } + + public String getDescription() { + return description; + } + + public boolean isAutoApprove() { + return autoApprove; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java new file mode 100644 index 00000000..aa4af5de --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Dialog for adding a terminal auto-approve rule with command and allow/deny. + */ +public class AddTerminalRuleDialog extends Dialog { + + private Text commandText; + private Button allowRadio; + + private String command; + private boolean autoApprove; + + /** + * Creates the dialog. + * + * @param parent the parent shell + */ + public AddTerminalRuleDialog(Shell parent) { + super(parent); + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText(Messages.preferences_page_terminal_auto_approve_add_dialog_title); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite container = new Composite(area, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // Command * + Label commandLabel = new Label(container, SWT.NONE); + commandLabel.setText( + Messages.preferences_page_terminal_auto_approve_add_dialog_command + " *"); + commandText = new Text(container, SWT.BORDER); + GridData commandData = new GridData(SWT.FILL, SWT.CENTER, true, false); + commandData.widthHint = 300; + commandText.setLayoutData(commandData); + commandText.setMessage(Messages.preferences_page_terminal_auto_approve_add_dialog_placeholder); + commandText.addModifyListener(e -> updateOkButton()); + + // Allow / Deny + Label approveLabel = new Label(container, SWT.NONE); + approveLabel.setText( + Messages.preferences_page_auto_approve_add_dialog_approve); + Composite radioGroup = new Composite(container, SWT.NONE); + radioGroup.setLayout(new GridLayout(2, false)); + allowRadio = new Button(radioGroup, SWT.RADIO); + allowRadio.setText(Messages.preferences_page_auto_approve_allow); + allowRadio.setSelection(true); + Button denyRadio = new Button(radioGroup, SWT.RADIO); + denyRadio.setText(Messages.preferences_page_auto_approve_deny); + + return area; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + updateOkButton(); + } + + private void updateOkButton() { + Button ok = getButton(OK); + if (ok != null) { + ok.setEnabled(commandText != null && !commandText.getText().trim().isEmpty()); + } + } + + @Override + protected void okPressed() { + command = commandText.getText().trim(); + autoApprove = allowRadio.getSelection(); + super.okPressed(); + } + + public String getCommand() { + return command; + } + + public boolean isAutoApprove() { + return autoApprove; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java new file mode 100644 index 00000000..b4b8b3f9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferencePage; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.McpConfigService; + +/** + * Auto-Approve preference page for terminal and file operation auto-approval rules. + */ +public class AutoApprovePreferencePage extends PreferencePage + implements IWorkbenchPreferencePage { + + public static final String ID = + "com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage"; + + private TerminalAutoApproveSection terminalSection; + private FileOperationAutoApproveSection fileOperationSection; + private McpAutoApproveSection mcpSection; + private GlobalAutoApproveSection globalSection; + + @Override + public void init(IWorkbench workbench) { + setPreferenceStore(CopilotUi.getPlugin().getPreferenceStore()); + noDefaultAndApplyButton(); + } + + @Override + protected Control createContents(Composite parent) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + if (flags != null && !flags.isAutoApprovalEnabled()) { + return WrappableIconLink.createWithSharedImage(parent, + PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_INFO_TSK), + Messages.preferences_page_auto_approve_disabled_by_organization); + } + + Composite root = new Composite(parent, SWT.NONE); + GridLayout layout = new GridLayout(1, false); + layout.marginWidth = 0; + layout.marginHeight = 0; + root.setLayout(layout); + root.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + + IPreferenceStore store = getPreferenceStore(); + terminalSection = new TerminalAutoApproveSection(root, SWT.NONE); + terminalSection.loadFromPreferences(store); + + fileOperationSection = new FileOperationAutoApproveSection(root, SWT.NONE); + fileOperationSection.loadFromPreferences(store); + + mcpSection = new McpAutoApproveSection(root, SWT.NONE); + mcpSection.loadFromPreferences(store); + bindMcpConfigService(); + + globalSection = new GlobalAutoApproveSection(root, SWT.NONE); + globalSection.loadFromPreferences(store); + + root.addDisposeListener(e -> unbindMcpConfigService()); + + return root; + } + + @Override + public boolean performOk() { + if (terminalSection == null) { + return true; + } + IPreferenceStore store = getPreferenceStore(); + terminalSection.saveToPreferences(store); + fileOperationSection.saveToPreferences(store); + mcpSection.saveToPreferences(store); + globalSection.saveToPreferences(store); + return true; + } + + private void bindMcpConfigService() { + ChatServiceManager chatServiceManager = + CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpConfigService mcpConfigService = + chatServiceManager.getMcpConfigService(); + if (mcpConfigService != null) { + mcpConfigService.bindWithAutoApproveSection(mcpSection); + } + } + } + + private void unbindMcpConfigService() { + ChatServiceManager chatServiceManager = + CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpConfigService mcpConfigService = + chatServiceManager.getMcpConfigService(); + if (mcpConfigService != null) { + mcpConfigService.unbindWithAutoApproveSection(); + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java index 11a791ef..15059719 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java @@ -60,6 +60,24 @@ public void createFieldEditors() { addNote(parent, Messages.preferences_page_watched_files_note_content); addSeparator(parent); + Composite consoleContextComposite = createSectionComposite(parent, gdf); + BooleanFieldEditor consoleContextField = new BooleanFieldEditor(Constants.CONSOLE_CONTEXT_ENABLED, + Messages.preferences_page_console_context, SWT.WRAP, consoleContextComposite); + applyFieldWidthHint(consoleContextField, consoleContextComposite); + addField(consoleContextField); + + addNote(parent, Messages.preferences_page_console_context_note_content); + addSeparator(parent); + + Composite transformContextComposite = createSectionComposite(parent, gdf); + BooleanFieldEditor transformContextField = new BooleanFieldEditor(Constants.TRANSFORM_CONTEXT_ENABLED, + Messages.preferences_page_transform_context, SWT.WRAP, transformContextComposite); + applyFieldWidthHint(transformContextField, transformContextComposite); + addField(transformContextField); + + addNote(parent, Messages.preferences_page_transform_context_note_content); + addSeparator(parent); + // Add sub-agent toggle Composite subAgentComposite = createSectionComposite(parent, gdf); boolean policyAllowsSubAgent = isPolicyAllowsSubAgent(); @@ -103,6 +121,7 @@ public void createFieldEditors() { Messages.preferences_page_agent_max_requests, agentMaxRequestsComposite); agentMaxRequestsField.setValidRange(1, 500); agentMaxRequestsField.setErrorMessage(Messages.preferences_page_agent_max_requests_validation_error); + PreferencePageUtils.styleTextInput(agentMaxRequestsField.getTextControl(agentMaxRequestsComposite)); addField(agentMaxRequestsField); addNote(parent, Messages.preferences_page_agent_max_requests_desc); @@ -255,4 +274,4 @@ private void updateSubAgentToolConfiguration(boolean subAgentEnabled) { CopilotCore.LOGGER.error("Failed to update sub-agent tool configuration", e); } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java index 65d86abc..4bbef5b6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.preferences; +import com.google.gson.Gson; import org.eclipse.core.runtime.preferences.AbstractPreferenceInitializer; import org.eclipse.core.runtime.preferences.ConfigurationScope; import org.eclipse.core.runtime.preferences.IEclipsePreferences; @@ -11,6 +12,8 @@ import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; /** * A class to initialize the default preferences for the plugin. @@ -27,7 +30,9 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.ENABLE_STRICT_SSL, true); pref.setDefault(Constants.PROXY_KERBEROS_SP, ""); pref.setDefault(Constants.GITHUB_ENTERPRISE, ""); - pref.setDefault(Constants.WORKSPACE_CONTEXT_ENABLED, false); + pref.setDefault(Constants.WORKSPACE_CONTEXT_ENABLED, true); + pref.setDefault(Constants.CONSOLE_CONTEXT_ENABLED, true); + pref.setDefault(Constants.TRANSFORM_CONTEXT_ENABLED, false); pref.setDefault(Constants.SUB_AGENT_ENABLED, true); pref.setDefault(Constants.AGENT_MAX_REQUESTS, 25); pref.setDefault(Constants.ENABLE_SKILLS, true); @@ -60,6 +65,18 @@ public void initializeDefaultPreferences() { """); pref.setDefault(Constants.MCP_TOOLS_STATUS, "{}"); + // Auto-approve defaults + pref.setDefault(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(TerminalConfirmationHandler.DEFAULT_RULES)); + pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL, false); + pref.setDefault(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES)); + pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, true); + pref.setDefault(Constants.AUTO_APPROVE_MCP_SERVERS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_MCP_TOOLS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, false); + pref.setDefault(Constants.AUTO_APPROVE_YOLO_MODE, false); + IEclipsePreferences configPrefs = ConfigurationScope.INSTANCE .getNode(CopilotUi.getPlugin().getBundle().getSymbolicName()); boolean autoShowWhatsNew = configPrefs.getBoolean(Constants.AUTO_SHOW_WHAT_IS_NEW, true); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java index 473c9313..a6e6da99 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java @@ -38,6 +38,8 @@ protected Control createContents(Composite parent) { McpPreferencePage.ID); PreferencePageUtils.createPreferenceLink(getShell(), container, "Model Management", null, ByokPreferencePage.ID); + PreferencePageUtils.createPreferenceLink(getShell(), container, "Tool Auto Approve", null, + AutoApprovePreferencePage.ID); return container; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java index 437d3aba..d1c45249 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java @@ -218,6 +218,7 @@ private void createWorkspaceInstructionsField(Composite parent, GridLayout gl) { // disable the label of the input field, so that the input box can be positioned at the beginning // of the container. workspaceInstrField.getLabelControl(workspaceInstrFieldContainer).dispose(); + PreferencePageUtils.styleTextInput(workspaceInstrField.getTextControl(workspaceInstrFieldContainer)); addField(workspaceInstrField); // Add note using WrappableNoteLabel @@ -333,6 +334,7 @@ private void createGitCommitInstructionsField(Composite parent, GridLayout gl) { // disable the label of the input field, so that the input box can be positioned at the beginning // of the container. gitCommitInstrField.getLabelControl(gitCommitInstrFieldContainer).dispose(); + PreferencePageUtils.styleTextInput(gitCommitInstrField.getTextControl(gitCommitInstrFieldContainer)); addField(gitCommitInstrField); // Add note using WrappableNoteLabel diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java index 49657b8e..22f13ef4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java @@ -306,6 +306,7 @@ protected Control createDialogArea(Composite parent) { GridData nameData = new GridData(SWT.FILL, SWT.CENTER, true, false); nameData.widthHint = 300; nameText.setLayoutData(nameData); + PreferencePageUtils.styleTextInput(nameText); // Only show folder selection if there are multiple folders if (folders.size() > 1) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java new file mode 100644 index 00000000..93bc9d21 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java @@ -0,0 +1,496 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FileSafetyRuleInfo; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * File-operation auto-approve section with a rule table, action buttons, and + * unmatched-file-operation checkbox. + * + *

Default rules are fetched from CLS asynchronously and merged with + * local fallback defaults + * ({@link FileOperationConfirmationHandler#FALLBACK_DEFAULT_RULES}). + * Default rules cannot be removed; only user-added rules can be removed.

+ */ +public class FileOperationAutoApproveSection extends Composite { + + private static final int TABLE_HEIGHT_HINT = 200; + private static final Type FILE_OP_RULES_TYPE = + new TypeToken>() {}.getType(); + + private TableViewer tableViewer; + private final List defaultRules = + new ArrayList<>(); + private final List userRules = + new ArrayList<>(); + /** Combined view (defaults + user) shown in the table. */ + private final List allRules = + new ArrayList<>(); + private boolean defaultRulesLoaded; + private Button removeButton; + private Button toggleButton; + private Button resetButton; + private Button unmatchedCheckbox; + + /** Creates the file-operation auto-approve section inside the given parent. */ + public FileOperationAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_file_op_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Label description = new Label(group, SWT.WRAP); + description.setText( + Messages.preferences_page_file_op_auto_approve_description); + GridData descData = new GridData(SWT.FILL, SWT.TOP, true, false); + descData.widthHint = 400; + description.setLayoutData(descData); + + Composite container = new Composite(group, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + createTable(container); + createButtons(container); + + unmatchedCheckbox = new Button(group, SWT.CHECK); + unmatchedCheckbox.setText( + Messages.preferences_page_file_op_auto_approve_unmatched); + unmatchedCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_file_op_auto_approve_unmatched_note); + } + + private void createTable(Composite parent) { + tableViewer = new TableViewer(parent, + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + Table table = tableViewer.getTable(); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); + tableData.heightHint = TABLE_HEIGHT_HINT; + table.setLayoutData(tableData); + table.setHeaderVisible(true); + table.setLinesVisible(true); + + TableViewerColumn patternCol = + new TableViewerColumn(tableViewer, SWT.NONE); + patternCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_pattern); + patternCol.getColumn().setWidth(200); + patternCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).getPattern(); + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn descCol = + new TableViewerColumn(tableViewer, SWT.NONE); + descCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_description); + descCol.getColumn().setWidth(150); + descCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + String desc = + ((FileOperationAutoApproveRule) element).getDescription(); + return desc != null ? desc : ""; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn statusCol = + new TableViewerColumn(tableViewer, SWT.NONE); + statusCol.getColumn().setText( + Messages.preferences_page_auto_approve_column_status); + statusCol.getColumn().setWidth(100); + statusCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).isAutoApprove() + ? Messages.preferences_page_auto_approve_allow + : Messages.preferences_page_auto_approve_deny; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + tableViewer.setContentProvider(ArrayContentProvider.getInstance()); + tableViewer.addSelectionChangedListener(e -> updateButtonState()); + } + + private void createButtons(Composite parent) { + Composite btnGroup = new Composite(parent, SWT.NONE); + btnGroup.setLayout(new GridLayout(1, false)); + btnGroup.setLayoutData( + new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Button addButton = new Button(btnGroup, SWT.PUSH); + addButton.setText(Messages.preferences_page_auto_approve_add); + addButton.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + addButton.addListener(SWT.Selection, e -> onAdd()); + + removeButton = new Button(btnGroup, SWT.PUSH); + removeButton.setText( + Messages.preferences_page_auto_approve_remove); + removeButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + removeButton.setEnabled(false); + removeButton.addListener(SWT.Selection, e -> onRemove()); + + toggleButton = new Button(btnGroup, SWT.PUSH); + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + toggleButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + toggleButton.setEnabled(false); + toggleButton.addListener(SWT.Selection, e -> onToggle()); + + resetButton = new Button(btnGroup, SWT.PUSH); + resetButton.setText( + Messages.preferences_page_auto_approve_reset); + resetButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + resetButton.addListener(SWT.Selection, e -> onResetToDefaults()); + } + + private void onAdd() { + AddFileOperationRuleDialog dialog = + new AddFileOperationRuleDialog(getShell()); + if (dialog.open() == AddFileOperationRuleDialog.OK) { + String pattern = dialog.getPattern(); + if (isPatternExists(pattern)) { + MessageDialog.openWarning(getShell(), + Messages.preferences_page_file_op_auto_approve_duplicate_title, + Messages.preferences_page_file_op_auto_approve_duplicate_message); + return; + } + userRules.add(new FileOperationAutoApproveRule( + pattern, dialog.getDescription(), dialog.isAutoApprove())); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + private boolean isPatternExists(String pattern) { + return defaultRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)) + || userRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)); + } + + private void onRemove() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + userRules.remove(rule); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onToggle() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + rule.setAutoApprove(!rule.isAutoApprove()); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onResetToDefaults() { + boolean confirmed = MessageDialog.openQuestion(getShell(), + Messages.preferences_page_file_op_auto_approve_reset_title, + Messages.preferences_page_auto_approve_reset_message); + if (confirmed) { + userRules.clear(); + resetDefaultRulesToOriginal(); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + /** + * Resets default rules' autoApprove to their original values. + * CLS defaults use {@code requiresConfirmation=true} → {@code autoApprove=false}. + * Local fallback defaults are already {@code autoApprove=false}. + */ + private void resetDefaultRulesToOriginal() { + for (FileOperationAutoApproveRule rule : defaultRules) { + rule.setAutoApprove(false); + } + } + + private void updateButtonState() { + boolean hasSelection = + !tableViewer.getStructuredSelection().isEmpty(); + FileOperationAutoApproveRule selected = hasSelection + ? (FileOperationAutoApproveRule) tableViewer + .getStructuredSelection().getFirstElement() + : null; + boolean isDefault = selected != null && selected.isDefault(); + + removeButton.setEnabled(hasSelection && !isDefault); + toggleButton.setEnabled(hasSelection && !isDefault); + if (hasSelection) { + toggleButton.setText(selected.isAutoApprove() + ? Messages.preferences_page_auto_approve_deny + : Messages.preferences_page_auto_approve_allow); + } else { + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + } + resetButton.setEnabled(!isMatchingDefaults()); + } + + /** + * Checks whether the current rule set matches defaults exactly + * (no user rules, all defaults at original autoApprove values). + */ + private boolean isMatchingDefaults() { + if (!userRules.isEmpty()) { + return false; + } + for (FileOperationAutoApproveRule rule : defaultRules) { + if (rule.isAutoApprove()) { + return false; + } + } + return true; + } + + /** Loads file-operation rules and unmatched-file-operation preference from the store. */ + public void loadFromPreferences(IPreferenceStore store) { + List savedRules = parseSavedRules(store); + + // Initialize with local fallback defaults + applyFallbackDefaults(); + + // Separate saved rules into defaults (override autoApprove) and user + Set defaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.clear(); + for (FileOperationAutoApproveRule saved : savedRules) { + if (defaultPatterns.contains(saved.getPattern())) { + // Restore toggled autoApprove for default rules + defaultRules.stream() + .filter(d -> d.getPattern().equals(saved.getPattern())) + .findFirst() + .ifPresent(d -> d.setAutoApprove(saved.isAutoApprove())); + } else { + userRules.add(saved); + } + } + + rebuildAllRules(); + tableViewer.setInput(allRules); + + unmatchedCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + updateButtonState(); + + fetchDefaultRulesFromCls(); + } + + private List parseSavedRules( + IPreferenceStore store) { + String json = + store.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isNotBlank(json) && !"[]".equals(json.trim())) { + try { + List loaded = + new Gson().fromJson(json, FILE_OP_RULES_TYPE); + if (loaded != null) { + return loaded; + } + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file operation auto-approve rules", e); + } + } + return List.of(); + } + + private void applyFallbackDefaults() { + defaultRules.clear(); + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + fallback.isAutoApprove(), true)); + } + } + + /** + * Fetches default file safety rules from CLS asynchronously. + * On success, merges CLS rules with local fallback and updates + * the table. On failure, keeps local fallback defaults. + */ + private void fetchDefaultRulesFromCls() { + CopilotLanguageServerConnection conn = + CopilotCore.getPlugin().getCopilotLanguageServer(); + if (conn == null) { + return; + } + conn.getDefaultFileSafetyRules().thenAccept(result -> { + if (result == null || result.getDefaultRules() == null) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed() || defaultRulesLoaded) { + return; + } + applyClsDefaults(result.getDefaultRules()); + }, FileOperationAutoApproveSection.this); + }).exceptionally(ex -> { + // CLS not available — keep local fallback defaults + CopilotCore.LOGGER.error( + "Failed to fetch default file safety rules from CLS", ex); + return null; + }); + } + + /** + * Applies CLS-provided default rules, merging with local fallback. + * CLS rules take priority; local fallbacks fill gaps. + */ + private void applyClsDefaults(List clsRules) { + // Preserve any user-toggled autoApprove on existing defaults + Map toggledDefaults = new HashMap<>(); + for (FileOperationAutoApproveRule existing : defaultRules) { + toggledDefaults.put(existing.getPattern(), existing.isAutoApprove()); + } + + defaultRules.clear(); + Set clsPatterns = new HashSet<>(); + for (FileSafetyRuleInfo clsRule : clsRules) { + String pattern = clsRule.getPattern(); + clsPatterns.add(pattern); + boolean autoApprove = !clsRule.isRequiresConfirmation(); + // Restore user toggle if they had changed this default + if (toggledDefaults.containsKey(pattern)) { + autoApprove = toggledDefaults.get(pattern); + } + String desc = clsRule.getDescription() != null + ? clsRule.getDescription() : ""; + defaultRules.add(new FileOperationAutoApproveRule( + pattern, desc, autoApprove, true)); + } + + // Add local fallbacks for patterns not in CLS + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + if (!clsPatterns.contains(fallback.getPattern())) { + boolean autoApprove = fallback.isAutoApprove(); + if (toggledDefaults.containsKey(fallback.getPattern())) { + autoApprove = toggledDefaults.get(fallback.getPattern()); + } + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + autoApprove, true)); + } + } + + // Remove user rules that overlap with new defaults + Set allDefaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.removeIf(r -> allDefaultPatterns.contains(r.getPattern())); + + defaultRulesLoaded = true; + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + + /** Rebuilds the combined list shown in the table. */ + private void rebuildAllRules() { + allRules.clear(); + allRules.addAll(defaultRules); + allRules.addAll(userRules); + } + + /** Saves file-operation rules and unmatched-file-operation preference to the store. */ + public void saveToPreferences(IPreferenceStore store) { + // Save all rules (defaults + user) to preferences. + // On next load, defaults are re-identified by pattern matching. + store.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(allRules)); + store.setValue(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, + unmatchedCheckbox.getSelection()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java index 6b7da3e3..2d46bbcd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java @@ -84,6 +84,7 @@ public void createFieldEditors() { StringFieldEditor sftGhe = new StringFieldEditor(Constants.GITHUB_ENTERPRISE, Messages.preferences_page_github_enterprise, ctnGhe); sftGhe.getLabelControl(ctnGhe).setToolTipText(Messages.preferences_page_github_enterprise_tooltip); + PreferencePageUtils.styleTextInput(sftGhe.getTextControl(ctnGhe)); addField(sftGhe); // What's new group @@ -131,4 +132,4 @@ public boolean performOk() { return result; } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java new file mode 100644 index 00000000..3aaa8c7a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.Constants; + +/** + * Global auto-approve preference section with a YOLO mode checkbox + * that bypasses all confirmation dialogs when enabled. + */ +public class GlobalAutoApproveSection extends Composite { + + private static final int TOOLTIP_LINE_LENGTH = 90; + + private Button yoloCheckbox; + + /** Creates the global auto-approve section inside the given parent. */ + public GlobalAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_global_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Composite yoloRow = new Composite(group, SWT.NONE); + GridLayout yoloRowLayout = new GridLayout(2, false); + yoloRowLayout.marginWidth = 0; + yoloRowLayout.marginHeight = 0; + yoloRow.setLayout(yoloRowLayout); + yoloRow.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + + yoloCheckbox = new Button(yoloRow, SWT.CHECK); + yoloCheckbox.setText( + Messages.preferences_page_global_auto_approve_label); + yoloCheckbox.setLayoutData( + new GridData(SWT.LEFT, SWT.CENTER, false, false)); + yoloCheckbox.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (yoloCheckbox.getSelection()) { + MessageDialog dialog = new MessageDialog( + getShell(), + Messages.preferences_page_global_auto_approve_confirm_title, + null, + Messages.preferences_page_global_auto_approve_confirm_message, + MessageDialog.WARNING, + new String[] { + Messages.preferences_page_global_auto_approve_confirm_button, + Messages.preferences_page_global_auto_approve_cancel_button + }, + 1); + int result = dialog.open(); + if (result != 0) { + yoloCheckbox.setSelection(false); + } + } + } + }); + + Label warningIcon = new Label(yoloRow, SWT.NONE); + warningIcon.setImage(PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_WARN_TSK)); + warningIcon.setToolTipText(wrapTooltip( + Messages.preferences_page_global_auto_approve_confirm_message)); + warningIcon.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_global_auto_approve_confirm_message); + } + + /** Loads global auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + yoloCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)); + } + + /** Saves global auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_YOLO_MODE, + yoloCheckbox.getSelection()); + } + + private static String wrapTooltip(String text) { + StringBuilder wrapped = new StringBuilder(text.length()); + int lineLength = 0; + for (String word : text.split(" ")) { + if (lineLength > 0 && lineLength + word.length() + 1 > TOOLTIP_LINE_LENGTH) { + wrapped.append('\n'); + lineLength = 0; + } else if (lineLength > 0) { + wrapped.append(' '); + lineLength++; + } + wrapped.append(word); + lineLength += word.length(); + } + return wrapped.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java index d04cc224..aee19770 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java @@ -5,10 +5,12 @@ import java.net.URI; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; import org.apache.commons.lang3.StringUtils; @@ -27,6 +29,8 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsStatusCollection; @@ -92,6 +96,14 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy // Set transcript directory for CLS session persistence and restoration getSettings().getGithubSettings().getCopilotSettings().getAgent() .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedTerminal( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + syncTerminalRulesToCls(); + syncFileOperationRulesToCls(); // Set workspace context instructions when it is enabled if (preferenceStore.getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED)) { @@ -183,6 +195,26 @@ public void propertyChange(PropertyChangeEvent event) { .setEnableSkills(PreferencesUtils.isSkillsEnabled()); singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); break; + case Constants.AUTO_APPROVE_UNMATCHED_TERMINAL: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedTerminal( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_TERMINAL_RULES: + syncTerminalRulesToCls(); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_UNMATCHED_FILE_OP: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_FILE_OP_RULES: + syncFileOperationRulesToCls(); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; default: return; } @@ -233,11 +265,62 @@ public void syncMcpRegistrationConfiguration() { syncSingleConfiguration(new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings())); } + /** + * Converts terminal auto-approve rules from preference store JSON to the Map format + * expected by CLS and syncs them. + */ + private void syncTerminalRulesToCls() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + Map rulesMap = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(json)) { + try { + List rules = + new Gson().fromJson(json, + new TypeToken>() { + }.getType()); + if (rules != null) { + rules.forEach(r -> rulesMap.put(r.getCommand(), r.isAutoApprove())); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse terminal rules for CLS sync", e); + } + } + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(rulesMap); + } + + /** + * Converts file-operation auto-approve rules from preference store JSON to the Map format + * expected by CLS and syncs them. + */ + private void syncFileOperationRulesToCls() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + Map rulesMap = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(json)) { + try { + List rules = + new Gson().fromJson(json, + new TypeToken>() { + }.getType()); + if (rules != null) { + rules.forEach(r -> rulesMap.put(r.getPattern(), r.isAutoApprove())); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse file-operation rules for CLS sync", e); + } + } + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(rulesMap); + } + /** * Initializes the MCP tools status from the preference store for built-in agent mode only. * Custom agent modes get their tool configuration from the LSP/file, not from preferences. */ public void initializeMcpToolsStatus() { + // Migrate old preferences if needed (strip plugin display name prefixes from server names) + migrateMcpToolsStatusIfNeeded(); + // Load per-mode tool status String savedModeToolsStatus = preferenceStore.getString(Constants.MCP_TOOLS_MODE_STATUS); @@ -338,7 +421,9 @@ private void updateMcpToolsStatus(String mcpToolsStatus, String modeId) { // This is an MCP server McpServerToolsStatusCollection serverToolsStatus = new McpServerToolsStatusCollection(); - serverToolsStatus.setName(serverName); + // Extract the simple server name (strip plugin prefix if present) + String simpleName = extractSimpleServerName(serverName); + serverToolsStatus.setName(simpleName); List toolStatusList = new ArrayList<>(); serverToolsStatus.setTools(toolStatusList); @@ -531,6 +616,87 @@ public void setAutoShowCompletion(boolean autoShowCompletion) { preferenceStore.setValue(Constants.AUTO_SHOW_COMPLETION, autoShowCompletion); } + /** + * Migrates old MCP tools status preferences to remove plugin display name prefixes from server names. + * This handles backward compatibility when server registration changed from prefixed names to simple names. + */ + private void migrateMcpToolsStatusIfNeeded() { + try { + // Migrate MCP_TOOLS_MODE_STATUS (per-mode tool status) + String savedModeToolsStatus = preferenceStore.getString(Constants.MCP_TOOLS_MODE_STATUS); + if (StringUtils.isNotBlank(savedModeToolsStatus) && savedModeToolsStatus.contains(": ")) { + Map>> modeToolStatus = GsonUtils.getDefault() + .fromJson(savedModeToolsStatus, new TypeToken>>>() { + }.getType()); + + boolean modified = false; + for (Map> modeTools : modeToolStatus.values()) { + // Create a new map with migrated server names + Map> migratedTools = new LinkedHashMap<>(); + for (Map.Entry> entry : modeTools.entrySet()) { + String simpleName = extractSimpleServerName(entry.getKey()); + if (!simpleName.equals(entry.getKey())) { + modified = true; + } + migratedTools.put(simpleName, entry.getValue()); + } + modeTools.clear(); + modeTools.putAll(migratedTools); + } + + if (modified) { + String migratedJson = GsonUtils.getDefault().toJson(modeToolStatus); + preferenceStore.setValue(Constants.MCP_TOOLS_MODE_STATUS, migratedJson); + } + } + + // Migrate MCP_TOOLS_STATUS (legacy agent mode tool status) + String savedMcpToolsStatus = preferenceStore.getString(Constants.MCP_TOOLS_STATUS); + if (StringUtils.isNotBlank(savedMcpToolsStatus) && savedMcpToolsStatus.contains(": ")) { + Map> toolStatusMap = GsonUtils.getDefault() + .fromJson(savedMcpToolsStatus, new TypeToken>>() { + }.getType()); + + // Create a new map with migrated server names + Map> migratedTools = new LinkedHashMap<>(); + boolean modified = false; + for (Map.Entry> entry : toolStatusMap.entrySet()) { + String simpleName = extractSimpleServerName(entry.getKey()); + if (!simpleName.equals(entry.getKey())) { + modified = true; + } + migratedTools.put(simpleName, entry.getValue()); + } + + if (modified) { + String migratedJson = GsonUtils.getDefault().toJson(migratedTools); + preferenceStore.setValue(Constants.MCP_TOOLS_STATUS, migratedJson); + } + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to migrate MCP tools status preferences", e); + } + } + + /** + * Extracts the simple server name from a potentially prefixed display name. + * If the server name contains ": " (plugin display name prefix), returns the part after it. + * Otherwise, returns the server name as-is. + * + * @param displayName the display name that may include a plugin prefix + * @return the simple server name without the plugin prefix + */ + private String extractSimpleServerName(String displayName) { + if (StringUtils.isBlank(displayName)) { + return displayName; + } + int colonIndex = displayName.indexOf(": "); + if (colonIndex > 0) { + return displayName.substring(colonIndex + 2); + } + return displayName; + } + /** * Disposes the resources of this LanguageServerSettingManager. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java new file mode 100644 index 00000000..bc52d63e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.CheckStateChangedEvent; +import org.eclipse.jface.viewers.CheckboxTreeViewer; +import org.eclipse.jface.viewers.ICheckStateListener; +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsCollection; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpToolInformation; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * MCP auto-approve preference section with a trust-annotations checkbox + * and a tree viewer for per-server/tool approval management. + */ +public class McpAutoApproveSection extends Composite { + + private static final int TREE_HEIGHT_HINT = 200; + private static final Type STRING_LIST_TYPE = + new TypeToken>() {}.getType(); + + private Button trustAnnotationsCheckbox; + private CheckboxTreeViewer treeViewer; + private Group group; + + private List serverCollections = + new ArrayList<>(); + + // Current check state (lowercased for case-insensitive matching) + private final Set checkedServers = new HashSet<>(); + private final Set checkedTools = new HashSet<>(); + + /** Creates the MCP auto-approve section inside the given parent. */ + public McpAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_mcp_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + // Trust annotations checkbox + trustAnnotationsCheckbox = new Button(group, SWT.CHECK); + trustAnnotationsCheckbox.setText( + Messages.preferences_page_mcp_auto_approve_trust_annotations); + trustAnnotationsCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_mcp_auto_approve_trust_annotations_note); + + // Server/tool approval label + Label serverToolsLabel = new Label(group, SWT.NONE); + serverToolsLabel.setText( + Messages.preferences_page_mcp_auto_approve_server_tools_label); + GridData labelData = new GridData(SWT.FILL, SWT.TOP, true, false); + serverToolsLabel.setLayoutData(labelData); + + // Tree viewer for server/tool approval + treeViewer = new CheckboxTreeViewer(group, + SWT.BORDER | SWT.FULL_SELECTION); + GridData treeData = new GridData(SWT.FILL, SWT.FILL, true, false); + treeData.heightHint = TREE_HEIGHT_HINT; + treeViewer.getTree().setLayoutData(treeData); + + treeViewer.setContentProvider(new McpTreeContentProvider()); + treeViewer.setLabelProvider(new McpTreeLabelProvider()); + treeViewer.addCheckStateListener(new McpCheckStateListener()); + treeViewer.setInput(serverCollections); + } + + /** Loads MCP auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + trustAnnotationsCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)); + + checkedServers.clear(); + checkedTools.clear(); + + List servers = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : servers) { + checkedServers.add(s.toLowerCase(Locale.ROOT)); + } + + List tools = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : tools) { + checkedTools.add(t.toLowerCase(Locale.ROOT)); + } + + refreshTreeCheckState(); + } + + /** Saves MCP auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, + trustAnnotationsCheckbox.getSelection()); + + store.setValue(Constants.AUTO_APPROVE_MCP_SERVERS, + new Gson().toJson(new ArrayList<>(checkedServers))); + store.setValue(Constants.AUTO_APPROVE_MCP_TOOLS, + new Gson().toJson(new ArrayList<>(checkedTools))); + } + + /** + * Updates the server/tool collections displayed in the tree viewer. + * Called from the MCP config service when server data changes. + */ + public void updateServerCollections( + List collections) { + if (isDisposed()) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed()) { + return; + } + this.serverCollections = collections != null + ? collections : Collections.emptyList(); + treeViewer.setInput(serverCollections); + refreshTreeCheckState(); + requestLayout(); + }, this); + } + + private void refreshTreeCheckState() { + if (treeViewer.getTree().isDisposed()) { + return; + } + for (McpServerToolsCollection server : serverCollections) { + // Expand to ensure child TreeItems exist before setChecked + treeViewer.expandToLevel(server, 1); + + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + boolean serverChecked = checkedServers.contains(serverLower); + + List tools = server.getTools(); + if (tools == null) { + tools = Collections.emptyList(); + } + + if (serverChecked) { + // Server is approved: check server and all its tools + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, true); + } + } else { + // Check individual tools + int checkedCount = 0; + for (McpToolInformation tool : tools) { + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + boolean toolChecked = checkedTools.contains(toolKey); + treeViewer.setChecked(tool, toolChecked); + if (toolChecked) { + checkedCount++; + } + } + // Update parent check/gray state + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + } + } + } + } + + private static List loadJsonList(IPreferenceStore store, + String key) { + String json = store.getString(key); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + key, e); + return Collections.emptyList(); + } + } + + /** Content provider for the server/tool tree. */ + private static class McpTreeContentProvider + implements ITreeContentProvider { + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof List list) { + return list.toArray(); + } + return new Object[0]; + } + + @Override + public Object[] getChildren(Object parentElement) { + if (parentElement instanceof McpServerToolsCollection server) { + List tools = server.getTools(); + return tools != null ? tools.toArray() : new Object[0]; + } + return new Object[0]; + } + + @Override + public Object getParent(Object element) { + return null; + } + + @Override + public boolean hasChildren(Object element) { + if (element instanceof McpServerToolsCollection server) { + List tools = server.getTools(); + return tools != null && !tools.isEmpty(); + } + return false; + } + } + + /** Label provider for the server/tool tree. */ + private static class McpTreeLabelProvider extends LabelProvider { + @Override + public String getText(Object element) { + if (element instanceof McpServerToolsCollection server) { + return server.getName() != null ? server.getName() : ""; + } + if (element instanceof McpToolInformation tool) { + return tool.getName() != null ? tool.getName() : ""; + } + return ""; + } + } + + /** Handles check state changes in the server/tool tree. */ + private class McpCheckStateListener implements ICheckStateListener { + @Override + public void checkStateChanged(CheckStateChangedEvent event) { + Object element = event.getElement(); + boolean checked = event.getChecked(); + + if (element instanceof McpServerToolsCollection server) { + handleServerCheckChanged(server, checked); + } else if (element instanceof McpToolInformation tool) { + handleToolCheckChanged(tool, checked); + } + } + + private void handleServerCheckChanged( + McpServerToolsCollection server, boolean checked) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + treeViewer.setGrayed(server, false); + + if (checked) { + checkedServers.add(serverLower); + } else { + checkedServers.remove(serverLower); + } + + // Check/uncheck all children + List tools = server.getTools(); + if (tools != null) { + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, checked); + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + } + } + } + + private void handleToolCheckChanged( + McpToolInformation tool, boolean checked) { + // Find parent server + McpServerToolsCollection parentServer = findParentServer(tool); + if (parentServer == null) { + return; + } + String serverLower = parentServer.getName() != null + ? parentServer.getName().toLowerCase(Locale.ROOT) : ""; + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + + // Update parent check/gray state + updateParentState(parentServer); + } + + private void updateParentState(McpServerToolsCollection server) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + List tools = server.getTools(); + if (tools == null || tools.isEmpty()) { + return; + } + int checkedCount = 0; + for (McpToolInformation tool : tools) { + if (treeViewer.getChecked(tool)) { + checkedCount++; + } + } + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + checkedServers.remove(serverLower); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + checkedServers.add(serverLower); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + checkedServers.remove(serverLower); + } + } + + private McpServerToolsCollection findParentServer( + McpToolInformation tool) { + for (McpServerToolsCollection server : serverCollections) { + List tools = server.getTools(); + if (tools != null && tools.contains(tool)) { + return server; + } + } + return null; + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java index a3682cf9..d2a86ee6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java @@ -214,6 +214,7 @@ protected void doFillIntoGrid(Composite parent, int numColumns) { }; mcpField.getLabelControl(mcpFieldContainer).setToolTipText(Messages.preferences_page_mcp_tooltip); + PreferencePageUtils.styleTextInput(mcpField.getTextControl(mcpFieldContainer)); // @formatter:off mcpField.getLabelControl(mcpFieldContainer).setLayoutData(new GridData( SWT.LEFT, @@ -313,6 +314,7 @@ protected void doLoadDefault() { updateRegistryButtonState(); } }; + PreferencePageUtils.styleTextInput(mcpRegistryField.getTextControl(mcpRegistryFieldContainer)); addField(mcpRegistryField); // Add Open MCP Registry button diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index 918b1e74..ecad06d9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -57,6 +57,10 @@ public class Messages extends NLS { public static String preferences_page_github_enterprise; public static String preferences_page_watched_files; public static String preferences_page_watched_files_note_content; + public static String preferences_page_console_context; + public static String preferences_page_console_context_note_content; + public static String preferences_page_transform_context; + public static String preferences_page_transform_context_note_content; public static String preferences_page_restart_question; public static String preferences_page_sub_agent; public static String preferences_page_sub_agent_note_content; @@ -154,6 +158,60 @@ public class Messages extends NLS { public static String setting_managed_by_organization; public static String setting_disabled_by_organization; + // Shared Auto-Approve strings + public static String preferences_page_auto_approve_disabled_by_organization; + public static String preferences_page_auto_approve_column_status; + public static String preferences_page_auto_approve_add; + public static String preferences_page_auto_approve_remove; + public static String preferences_page_auto_approve_reset; + public static String preferences_page_auto_approve_reset_message; + public static String preferences_page_auto_approve_allow; + public static String preferences_page_auto_approve_deny; + public static String preferences_page_auto_approve_add_dialog_approve; + + // Terminal Auto-Approve + public static String preferences_page_terminal_auto_approve_title; + public static String preferences_page_terminal_auto_approve_description; + public static String preferences_page_terminal_auto_approve_column_command; + public static String preferences_page_terminal_auto_approve_reset_title; + public static String preferences_page_terminal_auto_approve_unmatched; + public static String preferences_page_terminal_auto_approve_unmatched_note; + public static String preferences_page_terminal_auto_approve_add_dialog_title; + public static String preferences_page_terminal_auto_approve_add_dialog_message; + public static String preferences_page_terminal_auto_approve_add_dialog_command; + public static String preferences_page_terminal_auto_approve_add_dialog_placeholder; + + // File Operations Auto Approve + public static String preferences_page_file_op_auto_approve_title; + public static String preferences_page_file_op_auto_approve_description; + public static String preferences_page_file_op_auto_approve_column_pattern; + public static String preferences_page_file_op_auto_approve_column_description; + public static String preferences_page_file_op_auto_approve_reset_title; + public static String preferences_page_file_op_auto_approve_unmatched; + public static String preferences_page_file_op_auto_approve_unmatched_note; + public static String preferences_page_file_op_auto_approve_add_dialog_title; + public static String preferences_page_file_op_auto_approve_add_dialog_message; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern; + public static String preferences_page_file_op_auto_approve_add_dialog_description; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern_hint; + public static String preferences_page_file_op_auto_approve_add_dialog_description_hint; + public static String preferences_page_file_op_auto_approve_duplicate_title; + public static String preferences_page_file_op_auto_approve_duplicate_message; + + // MCP Auto-Approve + public static String preferences_page_mcp_auto_approve_title; + public static String preferences_page_mcp_auto_approve_trust_annotations; + public static String preferences_page_mcp_auto_approve_trust_annotations_note; + public static String preferences_page_mcp_auto_approve_server_tools_label; + + // Global Auto-Approve + public static String preferences_page_global_auto_approve_title; + public static String preferences_page_global_auto_approve_label; + public static String preferences_page_global_auto_approve_confirm_title; + public static String preferences_page_global_auto_approve_confirm_message; + public static String preferences_page_global_auto_approve_confirm_button; + public static String preferences_page_global_auto_approve_cancel_button; + static { // initialize resource bundle NLS.initializeMessages(BUNDLE_NAME, Messages.class); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java index 01736806..e8a7f3e7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java @@ -5,6 +5,7 @@ import java.net.MalformedURLException; import java.net.URL; +import java.util.List; import java.util.function.Consumer; import org.eclipse.swt.SWT; @@ -16,16 +17,20 @@ import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Link; import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; import org.eclipse.ui.PartInitException; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.dialogs.PreferencesUtil; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** * Utility class for Copilot preference pages. */ public final class PreferencePageUtils { + private static final String TEXT_INPUT_CSS_CLASS = "copilot-preference-text-input"; // Private constructor to prevent instantiation private PreferencePageUtils() { @@ -117,6 +122,54 @@ public static void inheritParentBackground(Control control) { applyBackground(control, control.getParent().getBackground()); } + /** + * Applies readable dark-mode colors to editable text inputs in Copilot preference surfaces. + * + * @param text the text input to style + */ + public static void styleTextInput(Text text) { + if (text == null || text.isDisposed() || !UiUtils.isDarkTheme()) { + return; + } + + appendCssClass(text, TEXT_INPUT_CSS_CLASS); + Color background = CssConstants.getChatBackgroundColor(text.getDisplay()); + Color foreground = CssConstants.getChatForegroundColor(text.getDisplay()); + applyTextInputColors(text, background, foreground); + text.getDisplay().asyncExec(() -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.Settings, e -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.FocusIn, e -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.FocusOut, e -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.Modify, e -> applyTextInputColors(text, background, foreground)); + text.addDisposeListener(e -> { + disposeColor(background); + disposeColor(foreground); + }); + } + + private static void applyTextInputColors(Text text, Color background, Color foreground) { + if (text == null || text.isDisposed() || background == null || background.isDisposed() + || foreground == null || foreground.isDisposed()) { + return; + } + + text.setBackground(background); + text.setForeground(foreground); + text.redraw(); + } + + private static void appendCssClass(Control control, String className) { + Object currentClassNames = control.getData(CssConstants.CSS_CLASS_NAME_KEY); + if (currentClassNames instanceof String names && !names.isBlank()) { + if (!List.of(names.split("\\s+")).contains(className)) { + control.setData(CssConstants.CSS_CLASS_NAME_KEY, names + " " + className); + } + return; + } + + control.setData(CssConstants.CSS_CLASS_NAME_KEY, className); + } + private static void applyBackground(Control control, Color background) { if (control == null || control.isDisposed() || background == null || background.isDisposed()) { return; @@ -130,4 +183,10 @@ private static void applyBackground(Control control, Color background) { } } } + + private static void disposeColor(Color color) { + if (color != null && !color.isDisposed()) { + color.dispose(); + } + } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java new file mode 100644 index 00000000..db41cb10 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; + +/** + * Terminal auto-approve section with a rule table, action buttons, and + * unmatched-command checkbox. + */ +public class TerminalAutoApproveSection extends Composite { + + private static final int TABLE_HEIGHT_HINT = 200; + private static final Type TERMINAL_RULES_TYPE = + new TypeToken>() {}.getType(); + + private TableViewer tableViewer; + private List rules = new ArrayList<>(); + private Button removeButton; + private Button toggleButton; + private Button resetButton; + private Button unmatchedCheckbox; + + /** Creates the terminal auto-approve section inside the given parent. */ + public TerminalAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_terminal_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Label description = new Label(group, SWT.WRAP); + description.setText( + Messages.preferences_page_terminal_auto_approve_description); + GridData descData = new GridData(SWT.FILL, SWT.TOP, true, false); + descData.widthHint = 400; + description.setLayoutData(descData); + + Composite container = new Composite(group, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + createTable(container); + createButtons(container); + + unmatchedCheckbox = new Button(group, SWT.CHECK); + unmatchedCheckbox.setText( + Messages.preferences_page_terminal_auto_approve_unmatched); + unmatchedCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_terminal_auto_approve_unmatched_note); + } + + private void createTable(Composite parent) { + tableViewer = new TableViewer(parent, + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + Table table = tableViewer.getTable(); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); + tableData.heightHint = TABLE_HEIGHT_HINT; + table.setLayoutData(tableData); + table.setHeaderVisible(true); + table.setLinesVisible(true); + + TableViewerColumn commandCol = + new TableViewerColumn(tableViewer, SWT.NONE); + commandCol.getColumn().setText( + Messages.preferences_page_terminal_auto_approve_column_command); + commandCol.getColumn().setWidth(300); + commandCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((TerminalAutoApproveRule) element).getCommand(); + } + }); + + TableViewerColumn statusCol = + new TableViewerColumn(tableViewer, SWT.NONE); + statusCol.getColumn().setText( + Messages.preferences_page_auto_approve_column_status); + statusCol.getColumn().setWidth(100); + statusCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((TerminalAutoApproveRule) element).isAutoApprove() + ? Messages.preferences_page_auto_approve_allow + : Messages.preferences_page_auto_approve_deny; + } + }); + + tableViewer.setContentProvider(ArrayContentProvider.getInstance()); + tableViewer.addSelectionChangedListener(e -> updateButtonState()); + } + + private void createButtons(Composite parent) { + Composite btnGroup = new Composite(parent, SWT.NONE); + btnGroup.setLayout(new GridLayout(1, false)); + btnGroup.setLayoutData( + new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Button addButton = new Button(btnGroup, SWT.PUSH); + addButton.setText(Messages.preferences_page_auto_approve_add); + addButton.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + addButton.addListener(SWT.Selection, e -> onAdd()); + + removeButton = new Button(btnGroup, SWT.PUSH); + removeButton.setText( + Messages.preferences_page_auto_approve_remove); + removeButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + removeButton.setEnabled(false); + removeButton.addListener(SWT.Selection, e -> onRemove()); + + toggleButton = new Button(btnGroup, SWT.PUSH); + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + toggleButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + toggleButton.setEnabled(false); + toggleButton.addListener(SWT.Selection, e -> onToggle()); + + resetButton = new Button(btnGroup, SWT.PUSH); + resetButton.setText( + Messages.preferences_page_auto_approve_reset); + resetButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + resetButton.addListener(SWT.Selection, e -> onResetToDefaults()); + } + + private void onAdd() { + AddTerminalRuleDialog dialog = new AddTerminalRuleDialog(getShell()); + if (dialog.open() == AddTerminalRuleDialog.OK) { + String command = dialog.getCommand(); + // Remove existing rule with same command, then add at end + rules.removeIf(r -> r.getCommand().equals(command)); + rules.add(new TerminalAutoApproveRule(command, dialog.isAutoApprove())); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onRemove() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + rules.remove(sel.getFirstElement()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onToggle() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + TerminalAutoApproveRule rule = + (TerminalAutoApproveRule) sel.getFirstElement(); + rule.setAutoApprove(!rule.isAutoApprove()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onResetToDefaults() { + boolean confirmed = MessageDialog.openQuestion(getShell(), + Messages.preferences_page_terminal_auto_approve_reset_title, + Messages.preferences_page_auto_approve_reset_message); + if (confirmed) { + rules.clear(); + rules.addAll(TerminalConfirmationHandler.DEFAULT_RULES.stream() + .map(r -> new TerminalAutoApproveRule( + r.getCommand(), r.isAutoApprove())) + .toList()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void updateButtonState() { + boolean hasSelection = + !tableViewer.getStructuredSelection().isEmpty(); + removeButton.setEnabled(hasSelection); + toggleButton.setEnabled(hasSelection); + if (hasSelection) { + TerminalAutoApproveRule rule = (TerminalAutoApproveRule) + tableViewer.getStructuredSelection().getFirstElement(); + toggleButton.setText(rule.isAutoApprove() + ? Messages.preferences_page_auto_approve_deny + : Messages.preferences_page_auto_approve_allow); + } else { + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + } + resetButton.setEnabled(!isMatchingDefaults()); + } + + private boolean isMatchingDefaults() { + List defaults = TerminalConfirmationHandler.DEFAULT_RULES; + if (rules.size() != defaults.size()) { + return false; + } + for (int i = 0; i < rules.size(); i++) { + if (!rules.get(i).getCommand().equals(defaults.get(i).getCommand()) + || rules.get(i).isAutoApprove() != defaults.get(i).isAutoApprove()) { + return false; + } + } + return true; + } + + /** Loads terminal rules and unmatched-command preference from the store. */ + public void loadFromPreferences(IPreferenceStore store) { + String json = store.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + rules.clear(); + if (StringUtils.isNotBlank(json) && !"[]".equals(json.trim())) { + try { + List loaded = + new Gson().fromJson(json, TERMINAL_RULES_TYPE); + if (loaded != null) { + rules.addAll(loaded); + } + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse terminal auto-approve rules from preferences", e); + } + } + tableViewer.setInput(rules); + + unmatchedCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + updateButtonState(); + } + + /** Saves terminal rules and unmatched-command preference to the store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(rules)); + store.setValue(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL, + unmatchedCheckbox.getSelection()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index cec6e395..5e19c5d7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -92,10 +92,14 @@ preferences_page_custom_instructions_chat_load_scope_all=all projects in workspa preferences_page_custom_instructions_chat_load_scope_referenced=projects inferred from chat-attached files preferences_page_custom_instructions_chat_load_scope_combo_tooltip=Decide which of the custom instructions will be used in the Copilot chat. preferences_page_watched_files= Enable workspace context (experimental) +preferences_page_console_context= Enable console context (experimental) preferences_page_custom_instructions_git_commit= Git Commit Instructions preferences_page_custom_instructions_git_commit_desc=Set custom instructions for Copilot Chat when generating commit messages. preferences_page_custom_instructions_git_commit_note= Access this feature in the Git Staging view by clicking the Copilot icon. You can find this view in the Git perspective or add it via the 'Window' > 'Show View' menu. preferences_page_watched_files_note_content= Allow the use of @workspace in Ask Mode. Enabling this feature may affect startup performance. +preferences_page_console_context_note_content= Allow the use of @console in Ask, Agent, and Plan modes. Console output is attached only when @console starts the message. +preferences_page_transform_context= Enable transform context (experimental) +preferences_page_transform_context_note_content= Allow the use of @transform in Ask, Agent, and Plan modes. The active Mule XML editor's Transform Message elements are attached only when @transform starts the message. preferences_page_restart_question=You need to restart Eclipse to apply the changes. Would you like to restart now? preferences_page_sub_agent= Enable sub-agent preferences_page_sub_agent_note_content= Allow Copilot to use sub-agents for complex multi-step tasks. @@ -143,3 +147,59 @@ preferences_page_skills_enabled_note_content=Controls whether agent skills can b # enterprise support setting_disabled_by_organization=This setting is disabled by your organization. setting_managed_by_organization=This setting is managed by your organization. + + +preferences_page_auto_approve_disabled_by_organization=Tool auto-approval rules are disabled by your organization's administrator. Please contact your organization's administrator for more information. + +# Shared Auto Approve strings (reusable by all auto-approve sections) +preferences_page_auto_approve_column_status=Auto Approve +preferences_page_auto_approve_add=Add... +preferences_page_auto_approve_remove=Remove +preferences_page_auto_approve_reset=Reset to Defaults +preferences_page_auto_approve_reset_message=This will replace all current rules with the defaults. Continue? +preferences_page_auto_approve_allow=Allow +preferences_page_auto_approve_deny=Deny +preferences_page_auto_approve_add_dialog_approve=Auto Approve: + +# Terminal Auto Approve +preferences_page_terminal_auto_approve_title=Terminal Auto Approve +preferences_page_terminal_auto_approve_description=Controls whether chat-initiated terminal commands are automatically approved. Set to Allow to auto approve matching commands; set to Deny to always require explicit approval. +preferences_page_terminal_auto_approve_column_command=Command +preferences_page_terminal_auto_approve_reset_title=Reset Terminal Auto Approve Rules +preferences_page_terminal_auto_approve_unmatched=Auto approve commands not covered by rules +preferences_page_terminal_auto_approve_unmatched_note=When enabled, terminal commands not covered by the rules above are automatically approved. Use this setting at your own discretion. +preferences_page_terminal_auto_approve_add_dialog_title=Add Terminal Command Rule +preferences_page_terminal_auto_approve_add_dialog_message=Enter the command name or regex pattern (e.g., npm, git, /^apt-get\\b/) +preferences_page_terminal_auto_approve_add_dialog_command=Command or Regex: +preferences_page_terminal_auto_approve_add_dialog_placeholder=e.g., npm, git, /^apt-get\\b/ + +# File Operations Auto Approve +preferences_page_file_op_auto_approve_title=File Operations Auto Approve +preferences_page_file_op_auto_approve_description=Controls whether file operations generated by Copilot are approved automatically. Rules only apply to files within the workspace; operations on files outside the workspace always require confirmation. Set to Allow to auto approve operations on matching files; set to Deny to always require explicit approval. +preferences_page_file_op_auto_approve_column_pattern=Pattern +preferences_page_file_op_auto_approve_column_description=Description +preferences_page_file_op_auto_approve_reset_title=Reset File Operations Auto Approve Rules +preferences_page_file_op_auto_approve_unmatched=Auto approve file operations not covered by rules +preferences_page_file_op_auto_approve_unmatched_note=When enabled, file operations not covered by the rules above are automatically approved. File operations outside the workspace always require confirmation. +preferences_page_file_op_auto_approve_add_dialog_title=Add File Operation Auto Approve Rule +preferences_page_file_op_auto_approve_add_dialog_message=Enter the glob pattern (e.g., **/.idea/**/* or **/*.config) +preferences_page_file_op_auto_approve_add_dialog_pattern=Pattern: +preferences_page_file_op_auto_approve_add_dialog_description=Description: +preferences_page_file_op_auto_approve_add_dialog_pattern_hint=e.g., **/.idea/**/* or **/*.config +preferences_page_file_op_auto_approve_add_dialog_description_hint=Optional description +preferences_page_file_op_auto_approve_duplicate_title=Duplicate Pattern +preferences_page_file_op_auto_approve_duplicate_message=A rule for this pattern already exists. + +# MCP Auto Approve +preferences_page_mcp_auto_approve_title=MCP Auto Approve +preferences_page_mcp_auto_approve_trust_annotations=Trust MCP tool annotations +preferences_page_mcp_auto_approve_trust_annotations_note=When enabled, Copilot uses MCP tool annotations to automatically approve read-only tool calls without confirmation. +preferences_page_mcp_auto_approve_server_tools_label=MCP Server and Tool Approval + +# Global Auto Approve +preferences_page_global_auto_approve_title=Global Auto Approve +preferences_page_global_auto_approve_label=Auto approve all tool calls +preferences_page_global_auto_approve_confirm_title=Enable Global Auto Approve? +preferences_page_global_auto_approve_confirm_message=Global auto-approve disables manual approval completely for all tools. When enabled, ALL tool calls (terminal commands, file edits, built-in tools, and MCP tools) will be automatically approved without any confirmation. This is extremely dangerous and is never recommended. +preferences_page_global_auto_approve_confirm_button=Confirm +preferences_page_global_auto_approve_cancel_button=Cancel diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java index b8a6621c..e3804909 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java @@ -43,6 +43,28 @@ public static Color getInputPlaceHolderColor(Display display) { return new Color(display, 128, 128, 128); } + /** + * Get the chat surface background color based on the current theme. + */ + public static Color getChatBackgroundColor(Display display) { + if (UiUtils.isDarkTheme()) { + return new Color(display, 47, 47, 47); + // return new Color(display, 222, 225, 229); + // return new Color(display, 30, 31, 34); + } + return new Color(display, 255, 255, 255); + } + + /** + * Get the chat surface text color based on the current theme. + */ + public static Color getChatForegroundColor(Display display) { + if (UiUtils.isDarkTheme()) { + return new Color(display, 255, 255, 255); + } + return new Color(display, 0, 0, 0); + } + /** * Get the border color for UI elements based on the current theme. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java similarity index 89% rename from com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java rename to com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java index 55aa6301..c1e908d8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java @@ -14,7 +14,7 @@ * The Eclipse Foundation - initial API and implementation *******************************************************************************/ -package com.microsoft.copilot.eclipse.ui.dialogs.mcp; +package com.microsoft.copilot.eclipse.ui.swt; import java.util.ArrayList; import java.util.List; @@ -36,12 +36,18 @@ import org.eclipse.swt.widgets.Shell; /** - * A drop down button from Eclipse Foundation that can show a menu when the arrow is clicked. + * A split button that distinguishes between clicking the label area (primary action) + * and clicking the arrow area (opens a dropdown menu). Originally from Eclipse Foundation. + * + *

Selection listeners receive {@code e.detail == SWT.ARROW} when the arrow + * area is clicked, and {@code e.detail == 0} for the primary area. */ -public class DropDownButton { +public class SplitDropdownButton { private boolean showArrow; + private Color separatorColor; + private Rectangle arrowBounds; private String padding = null; @@ -71,9 +77,10 @@ public void paintControl(PaintEvent e) { try { int inset = 3; int lineX = arrowBounds.x; + Color lineColor = separatorColor != null ? separatorColor : shadowColor; gc.setLineWidth(1); - gc.setForeground(shadowColor); - gc.setBackground(shadowColor); + gc.setForeground(lineColor); + gc.setBackground(lineColor); gc.drawLine(lineX, arrowBounds.y + inset - 1, lineX, e.y + buttonBounds.height - inset); Color arrowColor = button.getForeground(); @@ -93,12 +100,12 @@ public void paintControl(PaintEvent e) { }; /** - * Creates a new DropDownButton. + * Creates a new SplitButton. * * @param parent the parent composite * @param style the style of button */ - public DropDownButton(Composite parent, int style) { + public SplitDropdownButton(Composite parent, int style) { button = new Button(parent, SWT.PUSH); } @@ -125,6 +132,17 @@ public boolean isShowArrow() { return showArrow; } + /** + * Overrides the separator line color. When {@code null} (default), + * the system shadow color is used. + * + * @param color the color for the separator, or {@code null} for the default + */ + public void setSeparatorColor(Color color) { + this.separatorColor = color; + button.redraw(); + } + /** * Sets the text of the button. * @@ -291,7 +309,8 @@ private DropDownSelectionListenerWrapper findWrapper(final SelectionListener lis * @param listener the listener to remove */ public void removeSelectionListener(SelectionListener listener) { - DropDownSelectionListenerWrapper wrapper = findWrapper(listener); + DropDownSelectionListenerWrapper wrapper = + findWrapper(listener); if (wrapper != null) { button.removeSelectionListener(wrapper); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java index 016bbb2a..6c731aef 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java @@ -12,6 +12,7 @@ import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage; import com.microsoft.copilot.eclipse.ui.preferences.CompletionsPreferencesPage; @@ -33,7 +34,7 @@ private PreferencesUtils() { public static String[] getAllPreferenceIds() { return new String[] { CopilotPreferencesPage.ID, GeneralPreferencesPage.ID, ChatPreferencesPage.ID, CompletionsPreferencesPage.ID, CustomInstructionPreferencePage.ID, CustomModesPreferencePage.ID, - McpPreferencePage.ID, ByokPreferencePage.ID }; + McpPreferencePage.ID, ByokPreferencePage.ID, AutoApprovePreferencePage.ID }; } /** @@ -49,6 +50,24 @@ public static boolean isSkillsEnabled() { && flags != null && flags.isClientPreviewFeatureEnabled(); } + /** + * Returns whether console context is enabled for chat. + * + * @return {@code true} if the user enabled console context, {@code false} otherwise + */ + public static boolean isConsoleContextEnabled() { + CopilotUi plugin = CopilotUi.getPlugin(); + return plugin != null && plugin.getPreferenceStore().getBoolean(Constants.CONSOLE_CONTEXT_ENABLED); + } + + /** + * @return {@code true} if the user enabled transform context (@transform command), {@code false} otherwise + */ + public static boolean isTransformContextEnabled() { + CopilotUi plugin = CopilotUi.getPlugin(); + return plugin != null && plugin.getPreferenceStore().getBoolean(Constants.TRANSFORM_CONTEXT_ENABLED); + } + /** * Returns the current value for the scope used for loading custom instructions in the chat. * diff --git a/docs/CHAT_WINDOW_CONTAINERS.md b/docs/CHAT_WINDOW_CONTAINERS.md new file mode 100644 index 00000000..eb254fa7 --- /dev/null +++ b/docs/CHAT_WINDOW_CONTAINERS.md @@ -0,0 +1,128 @@ +# GitHub Copilot Chat Window Containers + +This document maps the main SWT containers that make up the GitHub Copilot chat window. Styling is applied through Eclipse e4 CSS data keys: + +![GitHub Copilot chat window container map](chat-window-containers.svg) + +- CSS class key: `CssConstants.CSS_CLASS_NAME_KEY` +- CSS ID key: `CssConstants.CSS_ID_KEY` + +## Root Layout + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Chat root | `Composite` owned by `ChatView` | `#chat-container` | Root container for the Copilot chat view. All top-level chat UI is attached here. | `ChatView#createPartControl` | +| Top banner | `TopBanner` | `#chat-top-banner` | Header/title area at the top of the chat view. | `TopBanner` | +| Content wrapper | `Composite` | `#chat-content-wrapper` | Hosts the active central page: conversation, welcome page, agent mode page, or chat history. | `ChatView#createContentWrapper` | +| Handoff container | `HandoffContainer` | Styled by `#chat-container > HandoffContainer` | Optional container shown above the input area when the current mode exposes handoff actions. | `ChatView#createHandoffContainer`, `HandoffContainer` | +| Action bar wrapper | `ActionBar` | `#chat-action-bar-wrapper` | Outer wrapper for the chat input stack. | `ChatView#createActionBar`, `ActionBar` | + +Typical signed-in chat/agent layout: + +```text +#chat-container + TopBanner (#chat-top-banner) + Composite (#chat-content-wrapper) + ChatContentViewer (#chat-content-viewer) + Composite (cmpContent) + UserTurnWidget + CopilotTurnWidget + HandoffContainer (optional) + ActionBar (#chat-action-bar-wrapper) +``` + +## Conversation Content + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Conversation scroller | `ChatContentViewer` | `#chat-content-viewer` | Scrollable conversation surface. | `ChatContentViewer` | +| Turn list content | `Composite` field `cmpContent` | Styled through `#chat-content-viewer > Composite` | Inner content composite that receives turn widgets and error widgets. | `ChatContentViewer` | +| User message container | `UserTurnWidget` | SWTBot key `user-turn`; styled by `#chat-content-viewer > Composite > UserTurnWidget` | One user chat turn. Extends `BaseTurnWidget`. | `UserTurnWidget` | +| Copilot reply container | `CopilotTurnWidget` | SWTBot key `copilot-turn`; styled by `#chat-content-viewer > Composite > CopilotTurnWidget` | One Copilot chat turn. Extends `ThinkingTurnWidget` and supports thinking/model footer content. | `CopilotTurnWidget` | +| Shared turn container | `BaseTurnWidget` | No direct CSS ID | Shared base layout for user, Copilot, and subagent turns. Creates avatar/title row and text/code/warning/tool content. | `BaseTurnWidget` | +| Message text | `StyledText` from `SourceViewer` / `ChatMarkupViewer` | `.chat-message-text` | Rendered text content inside a turn. | `UserTurnWidget`, `ChatMarkupViewer` | +| Code block container | `SourceViewerComposite` | No direct CSS ID | Rendered code block inside a turn. | `BaseTurnWidget#createCodeBlock` | +| Warning container | `WarnWidget` | No direct CSS ID | Inline warning or quota/error warning inside a turn. | `BaseTurnWidget#createWarnDialog` | +| Error container | `ErrorWidget` | No direct CSS ID | Error banner rendered inside the conversation content list. | `ChatContentViewer#renderErrorMessage` | +| Tool confirmation container | `InvokeToolConfirmationDialog` | `.bg-command-panel`, `.btn-primary` for child styling | Inline tool invocation confirmation inside a Copilot turn. | `BaseTurnWidget`, `InvokeToolConfirmationDialog` | + +## Handoff Area + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Handoff container | `HandoffContainer` | Styled by `#chat-container > HandoffContainer` | Shows mode handoff options, hidden when no handoffs exist. | `HandoffContainer` | +| Handoff label | `Label` | `.text-secondary` | Text label such as `PROCEED FROM ...`. | `HandoffContainer#show` | +| Handoff buttons row | `Composite` local `buttonsContainer` | No direct CSS ID | Row-layout container for handoff buttons. | `HandoffContainer#show` | +| Handoff button | `HandoffButtonWidget` | No direct CSS ID | Individual handoff action button. | `HandoffButtonWidget` | + +## Chat Input Area + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Action bar wrapper | `ActionBar` | `#chat-action-bar-wrapper` | Outer input stack, including optional banners and bars. | `ActionBar` | +| Static banner | `StaticBanner` | No direct CSS ID | Optional warning/info banner displayed above the input area. | `ActionBar#showStaticBanner`, `StaticBanner` | +| Input area | `Composite` field `inputArea` | No direct CSS ID | Transparent wrapper for optional todo/working-set bars and the bordered input. | `ActionBar` | +| Todo list bar | `TodoListBar` | `#todo-list-title` for title child | Optional task list bar shown above the input when agent todo data exists. | `TodoListService`, `TodoListBar` | +| Working set bar | `WorkingSetBar` | Uses `#file-row` / `#file-row-hover` for file rows | Optional changed-files summary bar shown above the input. | `FileToolService`, `WorkingSetBar` | +| Bordered input container | `Composite` local `borderedActionBar` | `#chat-action-bar` | Visual input box containing references, text input, and bottom controls. | `ActionBar` | +| References row | `Composite` field `cmpFileRef` | No direct CSS ID | Holds `AddContextButton`, current file reference, and referenced files. | `ActionBar` | +| Add context control | `AddContextButton` | No direct CSS ID | Button for adding context/references. | `ActionBar`, `AddContextButton` | +| Current file reference | `CurrentReferencedFile` | Child labels use `.text-secondary` | Shows current referenced file state. | `ActionBar`, `CurrentReferencedFile` | +| Referenced file item | `ReferencedFile` | `#normal-referenced-file-name`, `#not-supported-referenced-file-name` for filename label | Individual referenced file pill/item. | `ReferencedFile` | +| Chat input text | `ChatInputTextViewer` | Child text widget may receive CSS classes through `CssConstants.CSS_CLASS_NAME_KEY` | Actual editable chat input. | `ActionBar`, `ChatInputTextViewer` | +| Action area | `Composite` field `cmpActionArea` | No direct CSS ID | Bottom row of controls under the text input. | `ActionBar` | +| Control bar | `Composite` local `cmpControlBar` | No direct CSS ID | Left-side controls: chat mode, model picker, breakpoint, MCP tools, context-size donut. | `ActionBar` | +| Right button group | `Composite` field `bottomRightButtonsComposite` | No direct CSS ID | Right-side send/cancel/job buttons. | `ActionBar` | + +## Welcome, Loading, And History Pages + +These are mutually exclusive content pages hosted either directly under `#chat-container` or inside `#chat-content-wrapper`, depending on authentication and chat state. + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Loading page | `LoadingViewer` | Styled by `#chat-container > LoadingViewer` | Initial loading state while chat services initialize. | `ChatView#createLoadingPage` | +| Before-login welcome page | `BeforeLoginWelcomeViewer` | Styled by `#chat-container > BeforeLoginWelcomeViewer` | Signed-out welcome/sign-in page. | `ChatView#createBeforeLoginWelcomePage` | +| No-subscription page | `NoSubscriptionViewer` | Styled by `#chat-container > NoSubscriptionViewer` | Signed-in but no usable Copilot subscription state. | `ChatView#createNoSubscriptionPage` | +| After-login welcome page | `AfterLoginWelcomeViewer` | Styled by `#chat-content-wrapper > AfterLoginWelcomeViewer` | Default empty chat welcome page. | `ChatView#createAfterLoginWelcomePage` | +| Agent mode page | `AgentModeViewer` | Styled by `#chat-content-wrapper > AgentModeViewer` | Empty agent-mode landing page. | `ChatView#createAgentModePage` | +| Chat history page | `ChatHistoryViewer` | `#chat-history-viewer` | Conversation history list. | `ChatView#showChatHistory`, `ChatHistoryViewer` | + +## Agent And Subagent Containers + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Thinking turn | `ThinkingTurnWidget` | No direct CSS ID | Turn type that supports streamed thinking blocks. | `ThinkingTurnWidget` | +| Thinking block | `ThinkingBlock` | Child labels use `.text-secondary` | Collapsible/rendered thinking content within a Copilot turn. | `ThinkingBlock` | +| Thinking section | `ThinkingSection` | No direct CSS ID | Rendered section within a thinking block. | `ThinkingSection` | +| Agent status label | `AgentStatusLabel` | Child labels use `.text-secondary` | Displays agent/tool progress status. | `BaseTurnWidget#appendToolCallStatus`, `AgentStatusLabel` | +| Agent tool cancel label | `AgentToolCancelLabel` | Child label uses `.text-secondary` | Cancellation/status label for agent tool execution. | `AgentToolCancelLabel` | +| Agent message widget | `AgentMessageWidget` | Buttons use `.btn-primary` / `.btn-secondary` | Rendered GitHub coding-agent/job message. | `BaseTurnWidget#appendAgentMessage` | +| Subagent block | `SubagentMessageBlock` | `.subagent-message-block` | Bordered container for subagent execution within a Copilot turn. | `SubagentMessageBlock` | +| Subagent content area | `Composite` field `contentArea` | Styled through `.subagent-message-block > Composite` | Inner container for the subagent turn widget. | `SubagentMessageBlock` | +| Subagent turn | `SubagentTurnWidget` | Styled through `.subagent-message-block > Composite > SubagentTurnWidget` | Turn widget used inside a subagent block. | `SubagentTurnWidget` | + +## Primary Theme Selectors + +The main chat containers are styled in `com.microsoft.copilot.eclipse.ui/css/light.css` and `com.microsoft.copilot.eclipse.ui/css/dark.css`. + +Important selectors: + +```css +#chat-container +#chat-top-banner +#chat-content-wrapper +#chat-action-bar-wrapper +#chat-action-bar +#chat-container > HandoffContainer +#chat-content-viewer +#chat-content-viewer > Composite +#chat-content-viewer > Composite > UserTurnWidget +#chat-content-viewer > Composite > CopilotTurnWidget +#chat-content-viewer StyledText.chat-message-text +#chat-content-viewer .chat-message-text +#chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block +#chat-history-viewer +#file-row +#file-row-hover +#todo-list-title +``` diff --git a/docs/DEVELOPER_NOTES_MULESOFT_SUPPORT.md b/docs/DEVELOPER_NOTES_MULESOFT_SUPPORT.md new file mode 100644 index 00000000..89c393f4 --- /dev/null +++ b/docs/DEVELOPER_NOTES_MULESOFT_SUPPORT.md @@ -0,0 +1,69 @@ +# Developer Notes: MuleSoft Development Support Hardening + +## What Changed + +- Added two Mule Transform Message tools: + - `mule_read_transform` to inspect `ee:transform` content in Mule XML. + - `mule_write_transform` to update Transform Message DataWeave safely. +- Added shared transform support utilities in `MuleTransformSupport` so read/write tools use the same XML parsing, transform lookup, resource resolution, and serialization rules. +- Updated `FormatOptionProvider` so `.dwl` files use the DataWeave language id and receive normal formatting fallback behavior. +- Expanded the MuleSoft agent assets so they expose both local Studio/project tools and official MuleSoft MCP tools using the repo-supported `mulesoft/` syntax. +- Corrected the MUnit MCP tool name in agent assets from `generate_or_modify_munit` to `mulesoft/generate_or_modify_munit_test`. + +## Behavior Added + +- Transform reads now cover: + - inline `ee:set-payload` + - inline `ee:set-attributes` + - inline `ee:set-variable` + - `resource="..."` backed DWL files resolved from `src/main/resources` +- Transform writes now cover: + - `payload` + - `attributes` + - `variable:name` and plain variable names + - external DWL resources when the target element uses `resource="..."` +- Transform write operations now fail cleanly when: + - no transform matches + - more than one transform matches + - the requested target does not exist + - the DataWeave script is blank +- XML handling is hardened with secure parser settings and serialization that avoids unnecessary XML declaration churn. + +## Agent And Prompt Assets + +- Updated [com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md) to: + - keep local MuleSoft tools available + - register official MCP tools with `mulesoft/` prefixes + - mention transform read/write workflow explicitly +- Updated the bundled MuleSoft assets under: + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md) + +## Tests Added + +- `MuleAgentToolsTest` + - verifies read/write transform tool metadata + - verifies payload, attributes, variable, and external DWL read behavior + - verifies payload, attributes, and variable write behavior + - verifies no-op/error write paths do not modify XML + - verifies local MuleSoft agent assets expose the expected tool names +- `FormatOptionProviderTests` + - verifies `.dwl` receives default formatting fallback behavior + +## Validation + +- Passed: + - `./mvnw -pl com.microsoft.copilot.eclipse.core,com.microsoft.copilot.eclipse.ui,com.microsoft.copilot.eclipse.anypoint,com.microsoft.copilot.eclipse.ui.test -am -Dcheckstyle.skip=true -Dtest=MuleAgentToolsTest verify` + - `./mvnw -pl com.microsoft.copilot.eclipse.core.test -am -Dcheckstyle.skip=true -Dtest=FormatOptionProviderTests verify` +- Not rerun in normal mode: + - the repo still has the unrelated `ChatInputTextViewer.java` Checkstyle issue outside this MuleSoft change set + +## Notes + +- I left unrelated worktree changes alone, including `.gitignore` edits and the generated Tycho consumer POM churn. +- The transform tool implementation now shares parsing/serialization logic in `MuleTransformSupport` to keep future Mule XML edits consistent. diff --git a/docs/MULESOFT_COPILOT_IMPROVEMENTS.md b/docs/MULESOFT_COPILOT_IMPROVEMENTS.md new file mode 100644 index 00000000..d2bea928 --- /dev/null +++ b/docs/MULESOFT_COPILOT_IMPROVEMENTS.md @@ -0,0 +1,446 @@ +# MuleSoft Copilot Chat Improvements + +**Date**: May 21, 2026 +**Scope**: Enhanced agent instructions, prompt files, and tool descriptions for Mulesoft/Anypoint Studio integration +**Impact**: Domain-specific guidance for niche Mulesoft platform; addresses gaps in API-led architecture, DataWeave, error handling, security threats, logging, connector governance, and deployment + +--- + +## Why These Changes + +The GitHub Copilot Chat plugin was already well-integrated with Mulesoft (14 local tools, MCP server, 6 prompts), but because **Mulesoft is a niche platform** unlike Python or Java: + +1. **Thin prompts**: Original 6 prompts were 4–9 lines, naming what to check but not **how** or **what good looks like** +2. **Generic tool descriptions**: Tools didn't explain *why* each matters for Mulesoft or *when* to invoke them +3. **Missing domain depth**: No guidance on API-led layering, Mulesoft-specific injection attacks (XXE, XPath), DataWeave patterns, or platform-specific deployment +4. **Agent instructions too surface-level**: Mentioned API-led architecture without defining it; error handling without specific patterns + +**Result**: The AI had good mechanics but lacked the **Mulesoft expertise** to give developer advice beyond generic integration patterns. + +--- + +## What Changed + +### 1. Six Prompt Files Expanded (4–9 lines → 40–70 lines) + +Prompt files are templates that guide the AI when a user invokes a slash command like `/mule-code-review`. Each was rewritten with **concrete guidance, Mulesoft anti-patterns, and actionable checklists**. + +#### `mule-code-review.prompt.md` +**Purpose**: General code quality review +**New Content**: +- Flow naming conventions: camelCase verb-noun (e.g., `getCustomerByIdFlow`) +- When to use sub-flows vs. private flows +- Every HTTP-facing flow **must** have an `` handler +- On Error Continue vs. On Error Propagate rules (Continue only for optional enrichment) +- Correlation ID must be set at HTTP Listener and propagated in logs and outbound calls +- Global config deduplication rules +- DataWeave null-safety patterns (`default` operator on optional fields) +- APIkit route coverage — every endpoint in RAML/OpenAPI must have a router flow + +**Use it**: User types `/mule-code-review` → Agent gets detailed checklist instead of generic "review flows" + +--- + +#### `mule-security-review.prompt.md` +**Purpose**: Security vulnerability detection +**New Content** (Mulesoft-specific threats): +- **XPath injection**: Parameterize XPath expressions or reject user input in XPath queries +- **XML External Entity (XXE)**: Secure XML parsers to prevent external entity expansion +- **SQL injection**: Database connector queries must be parameterized (`:variable`), never concatenated +- **Insecure deserialization**: DataWeave `read()` on untrusted input without schema validation +- **Transport security**: HTTPS only on public endpoints, TLS context configured, no `insecure="true"` +- **Authentication**: Every public flow must validate credentials (API key, OAuth, JWT, Basic Auth) +- **Logging safety**: Never log passwords, tokens, PII fields without masking +- **Secure properties**: All secrets must use `${secure::property.name}`, not plain `${property.name}` + +**Use it**: User types `/mule-security-review` → Agent scans for Mulesoft-specific injection attacks, not just secrets + +--- + +#### `mule-performance-review.prompt.md` +**Purpose**: Performance and scalability +**New Content** (Mulesoft-specific optimization): +- **DataWeave streaming**: Payloads > 1 MB should use `streaming=true` to avoid materializing in memory +- **Nested maps (O(n²) anti-pattern)**: Flag and rewrite using `groupBy` for indexing +- **Batch job sizing**: Balance memory pressure vs. throughput; default 100 records/block may be wrong +- **maxConcurrency tuning**: Match CPU cores for compute-bound, higher for IO-bound flows +- **Scatter-gather**: Flag when maxConcurrency is not set and route count is dynamic +- **Database connector pooling**: minPoolSize, maxPoolSize, maxWait must be configured +- **N+1 queries**: Detect DB select inside loops; recommend bulk queries with `IN (...)` or joins +- **Caching**: In-memory cache via `` for repeated static/slow-changing data + +**Use it**: User types `/mule-performance-review` → Agent identifies DataWeave materialization, batch sizing, pooling config issues, not just generic optimization + +--- + +#### `deployment-readiness.prompt.md` +**Purpose**: Pre-deployment validation +**New Content** (Platform-specific): +- **Universal**: Health endpoint contract, log levels, MUnit pass rate, secure properties +- **CloudHub 1.0**: Worker sizing (minimum `Medium`), persistent queues for VM/MQ, static IP +- **CloudHub 2.0 / Runtime Fabric**: Resource limits, replica count (2+ for HA), liveness probes +- **On-premises**: Cluster config, JVM heap tuning, application path permissions +- **Smoke test checklist**: Post-deployment validation steps (health endpoint, key endpoint, logs, monitoring) + +**Use it**: User types `/deployment-readiness` → Agent asks for target platform and returns platform-specific checklist + +--- + +#### `api-spec-review.prompt.md` +**Purpose**: API contract validation +**New Content**: +- **APIkit binding**: Every spec endpoint **must** have a corresponding router flow; flag orphaned routes +- **Error contract**: Error codes in spec must match error handlers in Mule; error responses must match schema +- **Examples validation**: Examples must be valid against their declared schema +- **Security scheme**: OAuth/API key/JWT must be defined **and implemented** in flows +- **Versioning**: Use URL path (e.g., `/v1/`) not header-based versioning; document breaking changes +- **Named schemas**: Avoid inline anonymous objects; all reusable types in `types` section + +**Use it**: User types `/api-spec-review` → Agent validates spec completeness and APIkit router coverage + +--- + +#### `generate-munit-tests.prompt.md` +**Purpose**: Test generation and coverage +**New Content** (Mulesoft test scenarios): +- **Happy path, negative path, error path, edge data**: Four required scenarios per flow +- **Async flow testing**: Use VM queue polling with timeout; assert side effects (DB writes, MQ publishes) +- **Batch job testing**: Unit-test individual steps, integration-test full batch with 3–5 fixture records including one that fails +- **Scatter-gather**: Mock each route independently; add one test with a failing route +- **Transactional flows**: Mock second connector to fail and verify first connector's write was rolled back +- **Choice router branches**: Each when-condition + otherwise branch needs its own test +- **Correlation ID assertions**: Verify correlation ID is logged in all flow entry/error handler outputs +- **Test naming**: Descriptive names (`getCustomer_validId_returns200`) not `test1` + +**Use it**: User types `/generate-munit-tests` → Agent generates tests covering async, batch, scatter-gather, transactional flows—not just happy path + +--- + +### 2. Three New Prompt Files (90–120 lines each) + +These address domains that had no dedicated prompt in the original configuration. + +#### `dataweave-best-practices.prompt.md` +**Why**: DataWeave is Mulesoft's unique transformation language. No other platform has it. No dedicated quality review existed. + +**Content**: +- **Output type declaration**: Mandatory `output application/json` etc.; missing output causes type inference bugs +- **Null-safety**: `default` operator on all optional accesses; no exception-based null handling +- **Functional patterns**: `map`, `filter`, `reduce`, `groupBy` over imperative `if/else` loops +- **Performance anti-patterns**: Nested maps (O(n²)), inline regex compiled per iteration, unnecessary serialization +- **Streaming**: `streaming=true` for unknown-size payloads; know the limitations (no `sizeOf()`, `[-1]`, `reverse()`) +- **Modularity**: Repeated logic extracted to `.dwl` modules in `src/main/resources/dwl/` +- **Type safety**: Document input types; use named type definitions + +**Use it**: User types `/dataweave-best-practices` → Agent reviews all Transform Message components for null-safety, performance, and functional patterns + +--- + +#### `connector-governance.prompt.md` +**Why**: Connector configuration was mentioned but never systematically reviewed. Gaps in version compatibility, pooling, and deprecated connectors. + +**Content**: +- **Version compatibility**: Check Mulesoft compatibility matrix; flag EOL connectors (HTTP v1, File v1) +- **Redundant connectors**: Flag two versions of same connector in POM or duplicate global configs +- **Connection pooling**: Database/JMS/HTTP must have explicit pool config (minPoolSize, maxPoolSize, maxWait, connectionIdleTimeout) +- **Timeout and retry**: Every HTTP Request must have `responseTimeout`; flag `reconnect-forever` in production +- **Authentication consistency**: Same upstream service should use same auth method across flows +- **Deprecated and risky**: Flag Groovy/JS/Python scripting, Java Module static method calls, VM for cross-app messaging + +**Use it**: User types `/connector-governance` → Agent audits connector versions, pooling config, and deprecated patterns + +--- + +#### `logging-observability.prompt.md` +**Why**: Logging was reduced to "redact PII"—no positive guidance on structured logging, correlation IDs, or metrics. + +**Content**: +- **Correlation ID strategy**: Set at HTTP Listener from header or generate `uuid()`; propagate in all outbound HTTP headers +- **Log levels**: ERROR for failures, WARN for retries, INFO for flow entry/exit, DEBUG for diagnostics (prod disabled) +- **Structured logging**: JSON format not string concatenation; include `correlationId`, `flowName`, `operation`, metadata +- **PII/secrets**: Never log passwords/tokens/keys; mask if unavoidable (e.g., `email[0..2] ++ "***"`) +- **Error handler logging**: Every `` logs with `correlationId`, `flowName`, `errorType`, `errorDescription` +- **Log4j2 config**: Root logger `INFO` in prod; async appender for high throughput; JSON layout preferred +- **Anypoint Monitoring**: Enable for visibility; custom metrics for performance tracking + +**Use it**: User types `/logging-observability` → Agent reviews log4j2, Logger components, and correlation ID propagation + +--- + +### 3. Both Agent Files Deepened (65 lines → 110 lines) + +The two main agent definitions (`mulesoft-agent.agent.md` and `mulesoft-engineer.agent.md`) now include concrete sections instead of surface-level guidance. + +#### New Sections in Both Agents: + +**API-Led Architecture** +``` +- Experience API: consumer-facing, routes to Process APIs +- Process API: orchestrates business logic across System APIs +- System API: one-to-one backend adapter, no business logic +Rule: Never let System API call Experience API; preserve boundaries +``` + +**Error Handling Contract** +``` +- All HTTP-facing flows must have with typed error matchers +- only for truly optional enrichment steps +- Every error handler logs: correlationId, flow.name, error.errorType, error.description (JSON) +- Error responses: consistent JSON shape { "code", "message", "correlationId" }, correct HTTP status codes +- Correlation ID set at HTTP Listener, propagated in all outbound HTTP headers and logs +``` + +**DataWeave Standards** +``` +- Always read_transform before editing +- Every script must declare output type +- All optional field accesses use default operator +- Prefer map/filter/reduce over imperative patterns +- Flag nested maps over large collections (pre-index with groupBy) +- Streaming: output application/json streaming=true for unknown/large payloads +- Extract repeated logic to .dwl modules +``` + +**Logging Discipline** +``` +- INFO: flow entry/exit with correlationId, flowName, key identifiers (no full payload) +- ERROR: every with correlationId, flowName, errorType, errorDescription +- DEBUG: connector calls, DataWeave diagnostics (disabled in production) +- Never log passwords, tokens, API keys, or PII fields without masking +- Use structured JSON format in Logger message expressions +``` + +**Connector Governance** +``` +- Align versions with Mule runtime compatibility matrix +- Database configs: set minPoolSize, maxPoolSize, maxWait +- HTTP Request configs: set responseTimeout +- Outbound HTTP: HTTPS only, TLS context configured, insecure="true" never allowed +- Retry: finite reconnect with count/frequency, never reconnect-forever in production +- Flag deprecated: HTTP v1, File Connector v1, Scripting Module (Groovy/JS/Python) +``` + +**MUnit Testing** +``` +- Every public flow: happy path, invalid input, connector failure, error-response contract +- Mock all external connectors by doc:name, not sub-flows +- Cover every Choice branch including otherwise +- Run munit_validate_flow_tests after generating +- Use munit_full_review for suite audits before release +``` + +**Use it**: The agent now asks smarter questions and catches issues automatically because it understands these Mulesoft patterns. + +--- + +### 4. Six Tool Descriptions Enriched (3–4 lines → 8–15 lines) + +Tool descriptions appear when the AI considers whether to invoke a tool. Enriched descriptions help the AI know **when and why** to use each tool. + +#### Before vs. After Examples: + +**MuleProjectScanTool** +- Before: "Scan Mule project structure and metadata" +- After: Includes what data is returned (runtime version, connectors, flows, API specs, MUnit coverage, diagnostics), how to use results (check version compatibility, identify missing test coverage), and when to invoke (before code review or XML edits) + +**MuleSecurityReviewTool** +- Before: "Hardcoded secrets, insecure HTTP, missing validation, unsafe logging" +- After: Specific Mulesoft threats (SQL injection via DB connector string concatenation, XXE in XML parsing, XPath injection, parameter masking), common high-severity findings, classification severity levels + +**ApiSchemaAnalyzeTool** +- Before: "Governance-oriented diagnostics" +- After: Concrete governance issues (missing metadata, inline schemas vs. named types, missing error responses, no security scheme, examples don't validate against schema), APIkit compatibility checks + +**MunitValidateFlowTestsTool** +- Before: "Validate structure and flow coverage" +- After: Specific checks (munit:config presence, assertion elements, mock-when usage, no assertions = always passes, untested Choice branches), use before running Maven + +**MunitFullReviewTool** +- Before: "Broad suite reviews" +- After: Full scenario coverage analysis (happy, negative, error, edge, connector-failure, error-contract), assertion quality, mock completeness, branching coverage, test duplication, use before release + +--- + +## How to Use These Improvements + +### For End Users (Developers in Anypoint Studio) + +All improvements are **automatically available** via slash commands in the chat. No configuration needed. + +#### Example Workflow: + +1. **New project?** Start with: + ``` + /mule-code-review + ``` + Agent runs `mule_project_scan` → `mule_code_review` and returns findings on flow naming, error handlers, global configs, correlation IDs, API-led boundaries + +2. **Before pushing to production?** + ``` + /mule-security-review + /mule-performance-review + /deployment-readiness + ``` + Agent detects injection risks, DataWeave materialization, batch sizing, pooling config, log levels, health endpoints + +3. **Writing a DataWeave transform?** + ``` + /dataweave-best-practices + ``` + Agent reviews all Transform Message components for null-safety, streaming, functional patterns + +4. **Configuring connectors?** + ``` + /connector-governance + ``` + Agent audits connector versions, pooling, timeouts, deprecated patterns + +5. **Adding logs or monitoring?** + ``` + /logging-observability + ``` + Agent reviews correlation ID propagation, log levels, structured format, Anypoint Monitoring setup + +6. **Generating MUnit tests?** + ``` + /generate-munit-tests + ``` + Agent creates tests covering happy, negative, error, edge, async, batch, scatter-gather, transactional scenarios + +--- + +### For Developers Asking General Questions + +The agent instructions now guide the AI to ask smarter clarifying questions: + +**Before:** +- User: "How should I structure my error handling?" +- Agent: "Use error handlers. Try/catch blocks are good." + +**After:** +- User: "How should I structure my error handling?" +- Agent: "Are this flows HTTP-facing or internal? If HTTP-facing, every public flow needs `` with typed error matchers, correlation ID logging, and consistent error response shape. What's your target platform—CloudHub, Runtime Fabric, or on-prem?" + +--- + +### For Code Review + +Agent can now provide **Mulesoft-expert-level** code reviews: + +**Before:** +- Review flow: "Flow looks OK. Add error handling." + +**After:** +- Review flow: "Flow `getCustomer` lacks on-error-propagate. HTTP Listener must catch connectivity errors from DB connector. Global config `dbConnConfig` is missing pool settings; set minPoolSize=2, maxPoolSize=10, maxWait=5000. Transform Message lacks output directive. Tests missing correlation ID assertion." + +--- + +## Files Modified + +| Type | Files | Changes | +|------|-------|---------| +| **Prompts Expanded** | 6 files in `.github/prompts/` | 4–9 lines → 40–70 lines each | +| **Prompts New** | 3 new files in `.github/prompts/` | 90–120 lines each | +| **Agents Deepened** | 2 files: `mulesoft-agent.agent.md`, `mulesoft-engineer.agent.md` | 65 lines → 110 lines each | +| **Tool Descriptions** | 6 Java files in `chat/tools/` | 3–4 lines → 8–15 lines each | + +--- + +## Validation + +To verify the improvements: + +1. **Open Eclipse Anypoint Studio** with the plugin installed +2. **Open Chat panel** (Copilot icon) +3. **Type `/` to see all commands** — you should see all 9 slash commands (6 original + 3 new prompts): + - `/mule-code-review` + - `/mule-security-review` + - `/mule-performance-review` + - `/deployment-readiness` + - `/api-spec-review` + - `/generate-munit-tests` + - `/dataweave-best-practices` (new) + - `/connector-governance` (new) + - `/logging-observability` (new) + +4. **Test a prompt**: Invoke `/mule-code-review` on a sample Mule project + - Agent should run `mule_project_scan` automatically + - Agent should mention flow naming conventions, error handlers, global config dedup, correlation ID propagation, APIkit coverage + - Not generic "review flows" advice + +5. **Test tool descriptions**: In the chat, look for tool names in agent output + - `mule_project_scan` description should mention "runtime version, connector inventory, flow/sub-flow counts, diagnostics" + - `mule_security_review` should mention "XXE, XPath injection, SQL injection, insecure deserialization" + - Not generic "scan project" or "review security" + +--- + +## Impact Summary + +| Category | Before | After | +|----------|--------|-------| +| **Prompt Depth** | 4–9 lines, naming what to check | 40–70 lines, concrete guidance + anti-patterns | +| **New Prompts** | 0 specialized prompts for DataWeave, connectors, logging | 3 dedicated prompts | +| **Tool Descriptions** | Generic ("review flows", "check security") | Specific ("check flow naming, error handlers, correlation ID", "detect XXE, SQL injection, deserialization risks") | +| **Agent Instructions** | Surface-level mention of API-led, error handling | Detailed rules: layer hierarchy, error contract, DataWeave patterns, logging discipline | +| **Domain Knowledge** | Generic integration patterns | Mulesoft-specific: DataWeave streaming, batch sizing, connector pooling, XXE/XPath/SQL injection, correlation ID propagation | + +--- + +## Example: Before vs. After + +### User Request: "Review my Mule flow for security issues" + +**Before:** +``` +Agent: "Let me review your flows for security issues. +- Check for hardcoded secrets? Yes, I see some. +- Missing secure properties? Yes. +- Insecure HTTP? Yes. +Good luck fixing those. Try using ${secure::property} syntax." +``` + +**After:** +``` +Agent: "Let me scan your project first... +[runs mule_project_scan] + +High-risk findings: +1. Database connector query in concatenates user input: + SELECT * FROM customers WHERE id = '${payload.customerId}' + → SQL injection risk. Use parameterized query: SELECT * FROM customers WHERE id = :customerId + +2. HTTP Listener on public endpoint has no : + If DB connector fails, unhandled exception returns 500 with stack trace. + → Add error handler that catches DB:QUERY_EXECUTION and returns { "code": "DB_ERROR", "message": "Customer lookup failed", "correlationId": "..." } + +3. Secure Configuration Properties module not in POM: + ${secure::dbPassword} won't encrypt values at rest. + → Add dependency: mule-secure-configuration-property-module + +4. HTTP Request to external service has no TLS context: + + → Add tlsContext and remove insecure="true" + +[Validation commands provided] +Test: mvn test -Dmunit.test=*Test.xml +``` + +--- + +## Next Steps for the Team + +1. **Commit and merge** these changes to the `dev/mule-support` branch +2. **Test in Eclipse Anypoint Studio** with sample projects +3. **Gather feedback** from developers using the prompts +4. **Iterate** if new Mulesoft-specific patterns emerge (e.g., batch error handling, Runtime Fabric deployment specifics) + +--- + +## Notes + +- All improvements are **backward-compatible**. Existing tool registrations and agent setup unchanged. +- New prompts are automatically indexed by the language server and appear in slash command autocomplete. +- Tool descriptions are used by the AI to decide when to invoke tools and how to interpret results—no UI changes needed. +- Agent instructions are loaded into the AI's context at the start of each chat session—developers see improvements immediately. + diff --git a/docs/chat-window-containers.svg b/docs/chat-window-containers.svg new file mode 100644 index 00000000..6364a103 --- /dev/null +++ b/docs/chat-window-containers.svg @@ -0,0 +1,130 @@ + + GitHub Copilot Chat Window Container Map + Diagram showing the SWT container hierarchy for the GitHub Copilot chat window. + + + + + + GitHub Copilot Chat Window Containers + SWT hierarchy, CSS IDs/classes, and optional runtime containers + + + Chat root + Composite owned by ChatView · #chat-container + + + TopBanner + #chat-top-banner · header/title row + + + + + Content Wrapper + Composite · #chat-content-wrapper + + + ChatContentViewer + #chat-content-viewer · scrollable conversation + + + cmpContent + inner Composite · receives turns and errors + + + UserTurnWidget + SWTBot: user-turn + + + CopilotTurnWidget + SWTBot: copilot-turn + + + + + Alternate pages in this wrapper: AfterLoginWelcomeViewer, AgentModeViewer, ChatHistoryViewer + + + + + HandoffContainer + optional · #chat-container > HandoffContainer + + buttonsContainer + HandoffButtonWidget rows + + + + + ActionBar + #chat-action-bar-wrapper · chat input stack + + StaticBanner + + TodoListBar + + WorkingSetBar + + #chat-action-bar + + + + + Inside #chat-action-bar + + cmpFileRef + AddContextButton, CurrentReferencedFile + + ChatInputTextViewer + editable input text widget + + cmpActionArea + cmpControlBar + bottomRightButtonsComposite + + + Agent/Subagent Containers + + ThinkingTurnWidget + base for streamed thinking + + ThinkingBlock / ThinkingSection + reasoning display containers + + SubagentMessageBlock + .subagent-message-block + contentArea → SubagentTurnWidget + + + State Pages + Direct under #chat-container: + LoadingViewer + BeforeLoginWelcomeViewer + NoSubscriptionViewer + Under #chat-content-wrapper: + AfterLoginWelcomeViewer · AgentModeViewer · ChatHistoryViewer + + + + + + Source: docs/CHAT_WINDOW_CONTAINERS.md and com.microsoft.copilot.eclipse.ui chat SWT classes +