diff --git a/.github/workflows/sharepoint-sidepane.yml b/.github/workflows/sharepoint-sidepane.yml new file mode 100644 index 00000000..7af024e0 --- /dev/null +++ b/.github/workflows/sharepoint-sidepane.yml @@ -0,0 +1,45 @@ +name: SharePoint sidepane + +on: + pull_request: + paths: + - 'ui/embed/sharepoint-customizer/**' + - '.github/workflows/sharepoint-sidepane.yml' + push: + branches: [main] + paths: + - 'ui/embed/sharepoint-customizer/**' + - '.github/workflows/sharepoint-sidepane.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + name: Package (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + defaults: + run: + working-directory: ui/embed/sharepoint-customizer + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm install --no-audit --no-fund + - run: npm test + - name: Build and inspect configured production package (no tenant access) + run: npm run deploy -- --site-url https://contoso.sharepoint.com/sites/team --app-client-id 11111111-1111-4111-8111-111111111111 --properties tests/fixtures/deployment.json --package-only + - name: Ensure tracked source was not modified + run: git diff --exit-code + - uses: actions/upload-artifact@v4 + with: + name: sidepane-${{ matrix.os }} + path: ui/embed/sharepoint-customizer/sharepoint/solution/sidebar-agent.sppkg + retention-days: 7 diff --git a/ui/embed/README.md b/ui/embed/README.md index 3a336443..f4682dce 100644 --- a/ui/embed/README.md +++ b/ui/embed/README.md @@ -18,7 +18,7 @@ Plug a Copilot Studio chat agent into an existing platform. | [minimizable-widget/](./minimizable-widget/) | Minimizable/expandable WebChat widget for any website | | [pcf-canvas-app/](./pcf-canvas-app/) | PCF control to embed a chat agent in Power Apps canvas apps | | [servicenow-widget/](./servicenow-widget/) | Floating chat widget for ServiceNow Service Portal | -| [sharepoint-customizer/](./sharepoint-customizer/) | SharePoint app customizer with SSO | +| [sharepoint-customizer/](./sharepoint-customizer/) | Fluent SharePoint side panel for GHCP and standard harness agents, with SSO, local files, and optional agent activity | | [typeahead-suggestions/](./typeahead-suggestions/) | Typeahead suggestions for WebChat | | [WebChat React](../custom-ui/webchat-react/) | WebChat React client with auth (Node) — *M365 Agents SDK repo* | | [Web Client](../custom-ui/webclient/) | Web client with auth (Node) — *M365 Agents SDK repo* | diff --git a/ui/embed/sharepoint-customizer/.gitignore b/ui/embed/sharepoint-customizer/.gitignore index 1eac77fa..7868cf47 100644 --- a/ui/embed/sharepoint-customizer/.gitignore +++ b/ui/embed/sharepoint-customizer/.gitignore @@ -12,6 +12,7 @@ lib release solution/* !solution/*.sppkg +sharepoint/solution/ temp .heft @@ -32,3 +33,7 @@ obj # Styles Generated Code *.scss.ts + +# Local review configuration and browser artifacts +*.local.json +.playwright-cli/ diff --git a/ui/embed/sharepoint-customizer/README.md b/ui/embed/sharepoint-customizer/README.md index ef3c2346..15185c7d 100644 --- a/ui/embed/sharepoint-customizer/README.md +++ b/ui/embed/sharepoint-customizer/README.md @@ -4,250 +4,312 @@ parent: Embed grand_parent: UI nav_order: 6 --- -# Copilot Studio Agent Side Panel for SharePoint -![Copilot Studio Agent in SharePoint](./images/agent%20sidepanel.png) +# Copilot Studio side panel for SharePoint -## Summary +Beta SDK dependency +{: .label .label-yellow } -This sample demonstrates how to integrate a Microsoft Copilot Studio agent into SharePoint using a SharePoint Framework (SPFx) Application Customizer. It leverages the ReactWebChat component with the [Fluent UI theme pack](https://github.com/microsoft/BotFramework-WebChat#experimental-fluent-ui-theme-pack) and the [Microsoft 365 Agents SDK for NodeJS/TypeScript](https://github.com/microsoft/Agents-for-js) to establish a secure connection with a Copilot Studio agent configured with ['Authenticate with Microsoft'](https://learn.microsoft.com/en-us/microsoft-copilot-studio/configuration-end-user-authentication#authenticate-with-microsoft). - -The key advantage of using the Microsoft 365 Agents SDK is its ability to connect to agents that support Microsoft authentication, which enables [tenant graph grounding](https://learn.microsoft.com/en-us/microsoft-copilot-studio/knowledge-copilot-studio#tenant-graph-grounding) capabilities. +Embed a Microsoft-authenticated Copilot Studio agent in a SharePoint Framework (SPFx) Application Customizer. The sample works with **both GHCP and standard harness agents**, using the Microsoft 365 Agents SDK and WebChat's Fluent composer for messages, local files, cards, and suggested replies. {: .important } -> ⚠️ **This is an open-source reference sample only.** -> For production deployments, we strongly recommend using the [built-in SharePoint channel in Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/publication-add-bot-to-sharepoint#configure-the-sharepoint-channel). - -## Features of this sample +> This is an open-source reference sample, not a production-supported SharePoint channel. For production deployments, consider the [built-in SharePoint channel](https://learn.microsoft.com/en-us/microsoft-copilot-studio/publication-add-bot-to-sharepoint#configure-the-sharepoint-channel). Its capabilities differ from this custom SDK integration; this sample does not enable file uploads in the built-in channel. -- **Single Sign-On (SSO)**: Leverages the current user's SharePoint authentication for seamless agent access -- **Sliding Panel UI**: Modern, responsive chat interface that slides in from the right side of the page -- **Customizable Appearance**: Configure header color and agent title to match your branding -- **New Conversation Support**: Users can start fresh conversations without leaving the page -- **Fluent UI Styling**: Uses the Fluent UI theme pack for a native Microsoft 365 look and feel -- **Tenant Graph Grounding**: Enables the agent to access and search Microsoft 365 content through [tenant graph grounding](https://learn.microsoft.com/en-us/microsoft-copilot-studio/knowledge-copilot-studio#tenant-graph-grounding) +{: .warning } +> **This sample currently uses a beta SDK client.** Both `@microsoft/agents-copilotstudio-client` and `@microsoft/agents-activity` are pinned to `1.10.0-beta.7.g65f69dfa27`. These SDK dependencies are prerelease, so review their suitability before production use. WebChat and its Fluent theme use the released version `4.19.2`. -## Prerequisites +## Screenshots -Before you begin, ensure you have the following: +The deployed side panel with an example SharePoint list-management agent. Select a screenshot to view it full size. Requester names are redacted in the list-results image. -1. **Published Copilot Studio Agent** - - Agent must be configured with ['Authenticate with Microsoft'](https://learn.microsoft.com/en-us/microsoft-copilot-studio/configuration-end-user-authentication#authenticate-with-microsoft) authentication - - Agent should be published and accessible - - Consider enabling [tenant graph grounding](https://learn.microsoft.com/en-us/microsoft-copilot-studio/knowledge-copilot-studio#tenant-graph-grounding) for access to Microsoft 365 content +| Welcome and starters | Agent conversation | Agent activity and list results | +|----------------------|--------------------|---------------------------------| +| [Side panel welcome screen with a starter card, attachment picker, refresh, and collapse controls](./images/sidepane-welcome.png) | [Agent replying to the starter prompt with its SharePoint list-management capabilities](./images/sidepane-conversation.png) | [Follow-up conversation showing collapsed agent activity and SharePoint list results, with requester names redacted](./images/sidepane-agent-activity.png) | -2. **Azure Portal Access** - - Required for creating and configuring app registrations - - Permissions to create new app registrations in your Azure AD tenant +## Features -3. **SharePoint Administrator Access** - - Required to deploy the SPFx extension to the app catalog +- **Agent compatibility:** works with both GHCP and standard harness agents. Configure the environment ID and agent schema name; no harness-selection setting is needed. +- **Microsoft sign-in:** try silent authentication with the current SharePoint user's account, then offer a **Sign in** button when interaction is required or the silent iframe times out. +- **Clean Fluent panel:** responsive sizing, site theme integration, accessible controls, Escape/light-dismiss, and focus restoration. +- **Choose the opening experience:** default to native Fluent starter cards without starting a conversation, or opt into the agent's conversation-start flow instead. +- **Local attachments:** WebChat's native picker, drag-and-drop, clipboard files/images, multiple attachments, previews, and removal. +- **Optional agent activity:** independently enable client-visible reasoning and tool activity. Both are hidden by default. +- **Existing WebChat features:** Markdown, cards, suggested replies, typing, and streamed responses remain available. +- **Launcher:** an icon-only Copilot Studio button opens the agent from SharePoint's supported Top placeholder, below the Microsoft 365 suite header. The sample does not inject elements into the suite header or override SharePoint's built-in Copilot button. +- **Conversation lifecycle:** the single collapse chevron hides the panel while preserving the conversation; **New conversation** (the circular refresh arrow) clears its messages, pending files, and activity details, then follows the configured opening experience. -4. **Development Environment** - - Node.js v22 (as per SPFx 1.21.1 requirements) - - npm or yarn package manager - - Git for cloning the repository +There is no SharePoint/OneDrive picker, voice integration, or cross-page conversation persistence in this sample. -## Technical Architecture +## Prerequisites -This solution uses: -- **[Microsoft 365 Agents SDK for NodeJS/TypeScript](https://github.com/microsoft/Agents-for-js)**: Provides secure connection and authentication handling for Copilot Studio agents -- **[BotFramework-WebChat with Fluent UI Theme](https://github.com/microsoft/BotFramework-WebChat#experimental-fluent-ui-theme-pack)**: Renders the chat interface with Microsoft 365 styling -- **SharePoint Framework**: Enables deployment as a site-wide extension -- **[Tenant Graph Grounding](https://learn.microsoft.com/en-us/microsoft-copilot-studio/knowledge-copilot-studio#tenant-graph-grounding)**: Allows the agent to search and access Microsoft 365 content +- A published Copilot Studio agent accessible to the signed-in user, configured to [authenticate with Microsoft](https://learn.microsoft.com/en-us/microsoft-copilot-studio/configuration-end-user-authentication#authenticate-with-microsoft). +- Access to create or use an Entra single-page application registration, and consent for delegated `CopilotStudio.Copilots.Invoke`. +- A modern SharePoint site for local sideloading. An app catalog and deployment permissions are only needed for packaged installation. +- Node.js **22.14 or later within v22**, npm, and the SPFx 1.21.1 toolchain. +- For file analysis, an agent/runtime that accepts uploaded files. For standard-harness agents, enable **Settings > Generative AI > File processing capabilities > File uploads**. +- For activity details, an agent that provides reasoning or tool activity to its users. Enabling a UI flag does not make an agent provide additional activity. -## Setup Instructions +## How it works -### Step 1: Configure App Registration +The Application Customizer adds a Copilot Studio launcher to SharePoint's supported Top placeholder. Opening it displays a Fluent chat panel that follows the site's theme and signs users in with Microsoft. -This step requires permissions to create application identities in your Azure tenant. For this sample, create a Native Client Application Identity (no secrets required): +By default, the panel shows a welcome screen and starter cards; the agent conversation begins when the user sends a message. Set `startConversation: true` to use the agent's own welcome flow instead. -1. **Open Azure Portal** - - Navigate to [portal.azure.com](https://portal.azure.com) - - Go to **Azure Active Directory** (Entra ID) +Both GHCP and standard harness agents use the same panel and configuration. File attachments and optional reasoning/tool activity are available when supported by the configured agent. Collapsing the panel preserves the current conversation; **New conversation** clears it. -2. **Create a new App Registration** - - Click **App registrations** → **New registration** - - Provide a **Name** (e.g., "SharePoint Copilot Studio Agent") - - Choose **"Accounts in this organization directory only"** - - Under **Redirect URI**: - - Select **"Single-page application"** from the platform dropdown - - Enter your first SharePoint site URL without the trailing slash (e.g., `https://contoso.sharepoint.com/sites/mysite`) - - Click **Register** +The unmodified Copilot Studio SVG is from [Microsoft's official icon collection](https://learn.microsoft.com/en-us/power-platform/guidance/icons) and is included for this sample/training experience. Microsoft's separate icon usage terms apply; review them before reusing the logo in a production application. -3. **Configure Authentication - Add SharePoint URLs** - - Go to **Authentication** in the Manage section - - Under **Single-page application**, add redirect URIs: - - `https://localhost:4321` (only if you plan to test locally - this is the default SPFx port) - - For each SharePoint site where you'll deploy the extension, add: - - The site URL **without** trailing slash (e.g., `https://contoso.sharepoint.com/sites/mysite`) - - Under **Implicit grant and hybrid flows**, ensure both checkboxes are **unchecked** (SPAs use PKCE flow) - - Click **Save** +## Setup - **Example for multiple sites:** - ``` - https://localhost:4321 (for local testing - default SPFx port) - https://contoso.sharepoint.com/sites/hr - https://contoso.sharepoint.com/sites/finance - https://contoso.sharepoint.com/sites/it - ``` +### 1. Configure Microsoft authentication -4. **Configure API Permissions** - - In your new application, go to **API Permissions** in the Manage section - - Click **Add Permission** - - In the side panel, click the **APIs my organization uses** tab - - Search for **"Power Platform API"** or use the GUID `8578e004-a5c6-46e7-913e-12f58912df43` - - Under **Delegated permissions**, expand **CopilotStudio** and check: - - `CopilotStudio.Copilots.Invoke` - - Click **Add Permissions** - - (Optional) Click **Grant admin consent** for your organization - -5. **Note Required Values** - - On the **Overview** page, copy and save: - - **Application (client) ID** (e.g., `12345678-1234-1234-1234-123456789012`) - - **Directory (tenant) ID** (e.g., `87654321-4321-4321-4321-210987654321`) +1. In [Entra app registrations](https://portal.azure.com/), create or select an application for this sample. For a new registration, choose accounts in your organization. See [Register an application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) for the portal steps. +2. Add a **Single-page application** redirect URI matching the SharePoint **site URL without a trailing slash**, for example `https://contoso.sharepoint.com/sites/my-site`. +3. Add a redirect URI for each site where the extension will run. Do not enable implicit grant; MSAL uses authorization code with PKCE. +4. Under **API permissions > Add a permission > APIs my organization uses**, find **Power Platform API** (`8578e004-a5c6-46e7-913e-12f58912df43`). +5. Add delegated **CopilotStudio.Copilots.Invoke** and obtain consent as required by your tenant. +6. Record the application/client ID and directory/tenant ID. **No client secret belongs in this application.** {: .note } -> If you don't see "Power Platform API" in the list, you need to add it to your tenant first. See [Power Platform API Authentication](https://learn.microsoft.com/en-us/power-platform/admin/programmability-authentication) and follow Step 2 to add the API. +> The redirect URI is the SharePoint site even during local sideloading. Serving scripts from `https://localhost:4321` does not make localhost the sign-in redirect. MSAL caches authentication in session storage. If silent sign-in cannot complete, select **Sign in** to open a popup and complete any required MFA. The popup is opened from your click, without repeating the failed silent iframe request. -### Step 2: Get Copilot Studio Configuration +If Power Platform API is missing from your tenant, see [Power Platform API authentication](https://learn.microsoft.com/en-us/power-platform/admin/programmability-authentication). -1. Open your Copilot Studio environment -2. Navigate to your published agent -3. Ensure the agent is configured with ['Authenticate with Microsoft'](https://learn.microsoft.com/en-us/microsoft-copilot-studio/configuration-end-user-authentication#authenticate-with-microsoft) -4. Optionally enable [tenant graph grounding](https://learn.microsoft.com/en-us/microsoft-copilot-studio/knowledge-copilot-studio#tenant-graph-grounding) for Microsoft 365 content access -5. Collect the required configuration values using one of these methods: +### 2. Collect the agent settings - **Option A: Get Direct Connect URL (Recommended)** - - Go to **Channels** → **Web app** - - Under **Microsoft 365 Agents SDK**, copy the **Connection string** - - This will look like: `https://xxxxxxx.07.environment.api.powerplatform.com/...` +In Copilot Studio, open the published agent's metadata and record its **environment ID** and **schema name**. Use the schema name, not the display name or agent GUID. - **Option B: Get Environment ID and Agent Identifier** - - Go to **Settings** → **Advanced** → **Metadata** - - Copy the following values: - - **Environment ID** - - **Schema name** (this is your Agent Identifier, e.g., `cr770_myAgent`) +Use the environment ID and schema name for the normal configuration. Leave `directConnectUrl` unset unless you need the advanced connection override retained for existing deployments. -### Step 3: Clone the Repository +### 3. Install dependencies ```bash git clone https://github.com/microsoft/CopilotStudioSamples.git cd CopilotStudioSamples/ui/embed/sharepoint-customizer +npm install ``` -### Step 4: Configure the Extension - -1. Open `sharepoint/assets/elements.xml` in your editor -2. Update the `ClientSideComponentProperties` with your values: - -```xml -ClientSideComponentProperties="{ - "appClientId": "YOUR_APP_CLIENT_ID", - "tenantId": "YOUR_TENANT_ID", - "environmentId": "YOUR_ENVIRONMENT_ID", - "agentIdentifier": "YOUR_AGENT_IDENTIFIER", - "directConnectUrl": "YOUR_DIRECT_CONNECT_URL", - "showTyping": true, - "headerBackgroundColor": "white", - "agentTitle": "Copilot Studio Agent" -}" +### 4. Create a local configuration + +Create `properties.local.json` in the sample directory. Files named `*.local.json` are gitignored. + +```json +{ + "appClientId": "YOUR_APP_CLIENT_ID", + "tenantId": "YOUR_TENANT_ID", + "environmentId": "YOUR_ENVIRONMENT_ID", + "schemaName": "YOUR_AGENT_SCHEMA_NAME", + "agentTitle": "Team assistant", + "startConversation": false, + "showTyping": true, + "enableFileUpload": true, + "showReasoning": false, + "showToolCalls": false, + "starterPrompts": [ + { "title": "Get started", "text": "What can you help me with?" } + ] +} ``` -**Configuration Options:** +| Property | Required | Description / default | +|----------|----------|-----------------------| +| `appClientId` | Yes | Entra SPA application ID | +| `tenantId` | Yes | Directory/tenant ID | +| `environmentId` | For the default connection | Power Platform environment ID | +| `schemaName` | For the default connection | Agent schema name | +| `agentIdentifier` | No | Legacy alias for `schemaName`; canonical `schemaName` takes precedence | +| `directConnectUrl` | No | Advanced HTTPS connection override; leave unset for the normal configuration | +| `agentTitle` | No | `Copilot Studio Agent` | +| `headerBackgroundColor` | No | Optional launcher-bar CSS color override; otherwise follows the site theme | +| `startConversation` | No | `false`: show the local welcome/starter cards and wait for the first submitted message. `true`: start the agent's conversation immediately and hide the local welcome/starter cards | +| `showTyping` | No | `true`; ordinary typing is independent of reasoning/tool visibility | +| `enableFileUpload` | No | `true` | +| `showReasoning` | No | `false` | +| `showToolCalls` | No | `false` | +| `starterPrompts` | No | Array of `{ "title": "...", "text": "..." }`, used only when `startConversation` is `false`; defaults to the **Get started** card above. Use `[]` for a welcome screen without starter cards | -| Property | Required | Description | Default | -|----------|----------|-------------|---------| -| `appClientId` | Yes | Your Azure AD app registration client ID | - | -| `tenantId` | Yes | Your Azure AD tenant ID | - | -| `environmentId` | Conditional | Copilot Studio environment ID (required if not using directConnectUrl) | - | -| `agentIdentifier` | Conditional | Your agent's identifier (required if not using directConnectUrl) | - | -| `directConnectUrl` | Conditional | Direct connection URL (use this OR environmentId + agentIdentifier) | - | -| `showTyping` | No | Show typing indicators | `true` | -| `headerBackgroundColor` | No | Header bar color (accepts any CSS color value) | `white` | -| `agentTitle` | No | Display title for the agent | `"Copilot Studio Agent"` | +Use JSON booleans, not strings such as `"false"`. Keep tokens, passwords, client secrets, and browser state out of the configuration. -{: .note } -> You must provide either `directConnectUrl` OR both `environmentId` and `agentIdentifier`. +Starter prompts are public extension configuration, not system instructions. They are not automatically retrieved from the agent's authoring settings. Native cards put the configured text into the composer and focus it; they do not send a message until the user submits. The two opening modes are mutually exclusive: with `startConversation: true`, the agent's own welcome flow runs instead, and its messages and suggested replies render normally. -### Step 5: Test Locally (Optional) +## Sideload on a real SharePoint page -Before building and deploying, you can test the extension locally: +Application Customizers must be tested on a **live modern SharePoint page**, not the local workbench. Debug sideloading does not require an app-catalog installation and does not create a persistent site customization. -```bash -# Install dependencies -npm install +1. Trust the SPFx development certificate if it is not already trusted: -# Check if gulp is installed, if not, install it -which gulp || npm install -g gulp-cli -# On Windows, use: where gulp || npm install -g gulp-cli + ```bash + npx gulp trust-dev-cert + ``` -# Serve the solution locally -gulp serve --nobrowser -``` +2. Start the local HTTPS server and leave it running: -After running `gulp serve`, navigate to the following URL (replace the values with your configuration): + ```bash + npm run serve + ``` + +3. Confirm `https://localhost:4321/temp/build/manifests.js` loads in the review browser. Resolve certificate/local-network permission prompts normally; do not disable browser security. +4. Generate a debug URL using an actual modern page URL from your site: -{% raw %} + ```bash + npm run sideload -- \ + "https://contoso.sharepoint.com/sites/my-site/" \ + properties.local.json + ``` + +5. Open the generated URL in your signed-in browser and select **Load debug scripts**. Open the agent from the launcher. + +The helper preserves existing query parameters and encodes `customActions` safely. It uses this extension's manifest ID and omits any settings you did not supply. To use a different local port, configure the server and pass its manifest URL as the third argument. + +To end the review, leave the debug URL or close that tab and stop the development server. No app uninstall or persistent custom-action removal is necessary. + +### Review with Playwright + +Use a named, headed browser session and the generated debug URL: + +```bash +playwright-cli -s=sharepoint-review open "" --browser msedge --headed --persistent +playwright-cli -s=sharepoint-review snapshot ``` -https://YOUR-TENANT.sharepoint.com/sites/YOUR-SITE/SitePages/Home.aspx?debugManifestsFile=https://localhost:4321/temp/build/manifests.js&loadSPFX=true&customActions={%224c6e29f2-7eee-4f9f-bbd2-20c8859d0ba2%22:{%22location%22:%22ClientSideExtension.ApplicationCustomizer%22,%22properties%22:{%22appClientId%22:%22YOUR_APP_CLIENT_ID%22,%22tenantId%22:%22YOUR_TENANT_ID%22,%22directConnectUrl%22:%22YOUR_DIRECT_CONNECT_URL%22,%22showTyping%22:true,%22headerBackgroundColor%22:%22white%22,%22agentTitle%22:%22Your%20Agent%20Title%22}}} + +Complete sign-in/MFA yourself when required. To use an already signed-in Edge browser that supports remote debugging, enable **Allow remote debugging for this browser instance** at `edge://inspect/#remote-debugging`, then attach: + +```bash +playwright-cli -s=sharepoint-review attach --cdp=msedge +playwright-cli -s=sharepoint-review tab-new "" ``` -{% endraw %} -- Replace `YOUR-TENANT`, `YOUR-SITE`, `YOUR_APP_CLIENT_ID`, `YOUR_TENANT_ID`, and `YOUR_DIRECT_CONNECT_URL` with your actual values -- Ensure you've added `https://localhost:4321` to your app registration's redirect URIs if testing locally +Keep actions confined to the review tab. On completion, use `detach` for an attached browser; do not close someone else's browser. -### Step 6: Build and Package +Review the default configuration first, then generate URLs with `showReasoning` and `showToolCalls` enabled independently. Check: -Once you've tested locally (or if you're ready to deploy directly): +- A reply and a follow-up prompt that retains context. +- With the default `startConversation: false`, editable starter text and a conversation that begins only after the first submission. +- With `startConversation: true`, an agent-supplied welcome message and no local starter cards. +- A small, synthetic local document whose contents the agent can identify. +- Picker, paste/drop, attachment removal, rejection of oversized/unsupported files, and attachment-only messages. +- Reasoning/tool activity when supported, collapsed details, and no detail text when flags are disabled. +- Long streamed replies: follow the latest text through completion, but preserve the reading position when the user deliberately scrolls up. +- Close/reopen, **New conversation**, keyboard focus/Escape, narrow viewports, and error/reconnect behavior. -```bash -# Build the solution -gulp build +An upload appearing in the transcript is **not** proof that the agent processed it. Likewise, a working Copilot Studio test pane does not prove the sideloaded SDK connection works. Keep screenshots and diagnostic metadata free of tokens, user identities, and private SharePoint content. + +## File input behavior + +The sample accepts DOCX, CSV, PDF, TXT, JPG/JPEG, PNG, WebP, and GIF filenames, with a **15 MiB (15 × 1024 × 1024 bytes)** individual-file cap. Empty files are rejected. The checks apply to picker, paste/drop, and outgoing attachments; invalid selections are removed with an explanation before sending. + +Files are sent directly in the authenticated conversation. They are not uploaded into a SharePoint library or a new storage service. If a local file cannot be read, the send is stopped and an error is shown. -# Bundle the solution -gulp bundle --ship +{: .note } +> This is a conservative client policy, not a guarantee that an agent can process every accepted file. Supported formats and processing limits can differ between agents. See [Allow file input from users](https://learn.microsoft.com/en-us/microsoft-copilot-studio/image-input-analysis). + +Do not upload sensitive files merely to test the sample. Earlier uploaded content may continue to count against the agent's conversation limits; start a new conversation when appropriate. + +## Reasoning and tool visibility + +**Agent activity** is supported for both GHCP and standard harness agents. It appears in a collapsible group, closed by default, and remains in the conversation after the response finishes. + +The two settings are independent and both default to `false`; no harness-selection setting is needed. + +| Setting | What it shows | +|---------|---------------| +| `showReasoning` | User-visible reasoning provided by the agent | +| `showToolCalls` | Tool names and activity status provided by the agent | + +Only activity made available by the agent is shown. These settings do not expose hidden model reasoning, tool arguments, or raw tool results. The amount of detail varies by agent. + +Tools can pause for clarification or connector sign-in. A started step or connection card is not proof that an action completed successfully. Missing completion information is labeled, and failed responses show interrupted activity. + +Choose safe, read-only agent actions for review. Do not trigger a business-changing tool just to demonstrate the disclosure. + +## Automated deployment (Windows, macOS, Linux) + +Complete **Setup** above first. You need an **existing tenant app catalog**, permission to manage its apps, and permission to install apps/customize the target site. The deployment script uses the pinned local [CLI for Microsoft 365](https://pnp.github.io/cli-microsoft365/user-guide/connecting-microsoft-365/). + +**Two different identities:** `--app-client-id` is the existing **sidepane SPA registration** from Setup. The CLI uses a separate **deployment app/login**, with the SharePoint permissions and consent required by your chosen authentication method. Invocation consent for `CopilotStudio.Copilots.Invoke` does not grant SharePoint deployment permissions. Follow [Use your own CLI identity](https://pnp.github.io/cli-microsoft365/user-guide/using-own-identity/); the script does not create apps, change authentication, or grant permissions. + +1. Sign the local CLI in using your authorized deployment registration and an explicit tenant GUID. Skip this if the correct login is already active: + + ```text + npm exec -- m365 login --authType deviceCode --appId DEPLOYER_CLIENT_ID --tenant YOUR_TENANT_ID + npm exec -- m365 status + npm exec -- m365 spo set --url https://contoso.sharepoint.com + npm exec -- m365 spo tenant appcatalogurl get + ``` -# Package the solution -gulp package-solution --ship + Set the tenant's root SharePoint URL explicitly as shown; this avoids requiring an additional Graph `Sites.Read.All` permission for CLI setup. + +2. Use the returned catalog URL, the target **site URL** (not a page/debug URL), and your public `properties.local.json` settings: + + ```text + npm run deploy -- --site-url https://contoso.sharepoint.com/sites/team --app-catalog-url https://contoso.sharepoint.com/sites/apps --app-client-id YOUR_APP_CLIENT_ID --properties properties.local.json + ``` + +3. Open a normal modern page on that site, without sideload/debug parameters. Confirm one Copilot Studio launcher, sign in, and send a message and a follow-up. No localhost server is needed. + +The script validates settings and the current login/catalog/site, builds in an isolated temporary directory, XML-escapes the configuration, and checks the production package. It then uploads/enables the app in the catalog, installs it **only on the target site**, and verifies the installed version and one Web-scoped customizer with the requested properties. Its feature creates the initial registration; the script never adds a second custom action. Tracked templates and local settings are not modified. + +`--app-client-id` is required and overrides `appClientId` in the JSON file; you can omit that JSON property for deployment. The script prints the exact SPA redirect URI to register, but does not inspect or change Entra registrations. Output is `sharepoint/solution/sidebar-agent.sppkg`, with verified deployment metadata in `sharepoint/solution/deployment.local.json`. Both are gitignored. The package contains public configuration, not secrets. + +### Preview, package only, and updates + +Append `--dry-run` to validate inputs and print the plan **without building or contacting Microsoft 365**. It is not an authentication/permission check. Append `--package-only` to build and inspect the `.sppkg` without signing in or contacting SharePoint; `--app-catalog-url` is optional in both offline modes. For example: + +```text +npm run deploy -- --site-url https://contoso.sharepoint.com/sites/team --app-client-id YOUR_APP_CLIENT_ID --properties properties.local.json --package-only ``` -This will create a `.sppkg` file in the `sharepoint/solution` folder. +An identical package is reused on subsequent runs; an installed app is not installed again, and drifted site properties are updated on the existing action. For changed code or packaged configuration, increase **both** solution and feature versions in `config/package-solution.json`, then add `--replace` to authorize the catalog update. The script refuses downgrades and different content at the same version. -### Step 7: Deploy to SharePoint +An explicitly authorized upgrade can replace the original, older tenant-capable package if it was not deployed globally and no matching tenant-wide registration is active. The new package is still site-only. Active global deployment is never silently converted. -Upload the `.sppkg` file from `sharepoint/solution` to your SharePoint App Catalog. For detailed instructions on creating an app catalog, deploying apps, and adding them to sites, see [Add custom apps to SharePoint](https://learn.microsoft.com/en-us/sharepoint/use-app-catalog#add-custom-apps). +{: .warning } +> A tenant catalog is shared. Replacing this solution can affect other installed sites, and its packaged settings become the defaults for future installations. `--replace` acknowledges that broader effect; it does not make the catalog update site-local. Do not run competing deployments or use replacement to configure unrelated sites blindly. -## Troubleshooting +Existing tenant-wide, Site-scoped, duplicate, or standalone registrations must be resolved explicitly before this site-install workflow. Nothing is automatically removed. On failure, the script stops, lists completed remote operations, and leaves the package available for inspection; it does not uninstall apps or roll back a shared catalog. Correct the reported permissions/state issue and rerun, or use the manual path below. A successful metadata check still requires the browser conversation check in step 3. -### "Unable to acquire token" error +Run `npm run deploy -- --help` for all options. -If you encounter this error: -1. Open your browser's Developer Tools (F12) -2. Go to the **Network** tab -3. Look for failed authentication requests (usually to `login.microsoftonline.com`) -4. Click on the failed requests to see detailed error messages -5. Follow the authentication URLs that MSAL is attempting - the actual error details are often revealed in the response or redirect parameters +## Manual deployment -Common causes: -- Missing or incorrect redirect URIs in your app registration (check for trailing slashes!) -- Incorrect permissions in the app registration (ensure `CopilotStudio.Copilots.Invoke` is granted) -- Incorrect tenant ID or client ID -- MSAL authentication flow being blocked by browser settings +This is the same supported site-install model, without CLI deployment authentication. Follow Microsoft's [Deploy an SPFx extension](https://learn.microsoft.com/en-us/sharepoint/dev/spfx/extensions/get-started/serving-your-extension-from-sharepoint) and [Manage apps using the SharePoint Apps site](https://learn.microsoft.com/en-us/sharepoint/use-app-catalog) for the SharePoint screens. This sample uses **SPFx 1.21.1/Gulp**, not the Heft commands shown for newer versions. -### "The redirect URI specified in the request does not match the redirect URIs configured for the application" error +1. Complete **Setup**, including the runtime SPA registration, exact target-site redirect URI, delegated invocation permission, consent, and published-agent access. +2. Replace placeholders in `sharepoint/assets/elements.xml` with your runtime app client ID, tenant ID, environment ID, and agent schema name. Set any UI flags you need. Keep JSON booleans unquoted and preserve XML escaping: `"` for `"`, `&` for `&`, `<` for `<`, and `>` for `>`. Do not put credentials in this file. Alternatively, use `--package-only` above to generate the configured package without editing XML. +3. If you edited XML manually, build from the sample directory: -Check that the current SharePoint URL has been added as an allowed redirect URI for the provided app registration. + ```text + npx gulp bundle --ship + npx gulp package-solution --ship + ``` -## Additional Resources +4. Upload `sharepoint/solution/sidebar-agent.sppkg` to your **existing tenant Apps site/app catalog**. Select **Only enable this app**, not **Enable this app and add it to all sites**. +5. On the target SharePoint site, use **Site contents > New > App** to add the catalog app. Installation activates the package feature and registers the customizer; do **not** also register it manually. +6. Open a normal modern page, confirm one launcher, sign in if prompted, and verify a reply and same-conversation follow-up without a development server. -- [Built-in SharePoint Channel for Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/publication-add-bot-to-sharepoint#configure-the-sharepoint-channel) -- [Authenticate with Microsoft in Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/configuration-end-user-authentication#authenticate-with-microsoft) -- [Tenant Graph Grounding in Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/knowledge-copilot-studio#tenant-graph-grounding) -- [Microsoft 365 Agents SDK for NodeJS/TypeScript](https://github.com/microsoft/Agents-for-js) -- [BotFramework-WebChat with Fluent UI Theme](https://github.com/microsoft/BotFramework-WebChat#experimental-fluent-ui-theme-pack) -- [Copilot Studio Documentation](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) -- [SharePoint Framework Documentation](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/sharepoint-framework-overview) +The default package requires site installation (`skipFeatureDeployment: false`) and includes only `elements.xml`. `ClientSideInstance.xml` is not packaged and must not be enabled alongside this site action. For upgrades, bump solution/feature versions together, replace the catalog package deliberately, and update the app on the target site. Existing custom-action properties may need an explicit update; the automated path performs that reconciliation. -## License +### Validation + +Run `npm test` for configuration, attachments, GHCP and standard harness agent support, and deployment regression tests; `npm run build` checks the SPFx development build. The **SharePoint sidepane** GitHub Actions workflow runs tests and actual production `--package-only` builds on Windows, macOS, and Linux with synthetic settings. It has no tenant credentials and does not deploy to SharePoint. Live site installation, authentication/consent, and agent responses must be verified separately in your tenant. + +## Troubleshooting -This project is licensed under the MIT License - see the LICENSE file for details. +| Symptom | Check | +|---------|-------| +| Debug scripts do not load | Local server, trusted HTTPS certificate, actual manifest URL, debug-script/local-network prompts, and tenant restrictions | +| Agent does not connect | Verify the environment ID and schema name; leave `directConnectUrl` unset for the normal configuration | +| Agent is unavailable | Verify publication, sharing, tenant, delegated invocation permission, and service availability | +| Sign-in/redirect failure | Register the exact SharePoint site redirect without a trailing slash, verify consent, and permit the interactive sign-in popup | +| `AADSTS50011` | Add the exact **Redirect URI** shown in the panel to the indicated **Application ID** under **Authentication > Single-page application** in Entra. An unregistered URI can also leave an error inside the silent iframe and appear only as an MSAL timeout | +| `monitor_window_timeout` during silent sign-in | Select **Sign in** to view Microsoft's interactive sign-in page and any detailed error. A timeout alone does not identify the cause. The panel preserves the full error text returned by MSAL and shows the client ID and redirect URI, but cannot read a cross-origin Microsoft error page that never redirects back. Do not relax iframe sandbox restrictions; see [MSAL timeout guidance](https://learn.microsoft.com/en-us/entra/msal/javascript/browser/errors#monitor_window_timeout) | +| Passkey crashes in an automated browser | Use another tenant-approved sign-in method, or attach to a normally signed-in Edge instance; do not bypass MFA | +| File appears but is not analyzed | Verify agent file-input settings, supported content/model/channel, and service-side limits | +| No reasoning/tool details | Enable the correct flag and verify that the published agent provides that activity | +| Response fails after sending | A request may already have reached the agent. **Reconnect** starts a new conversation; avoid blindly repeating operations with side effects | + +## Resources + +- [Microsoft 365 Agents SDK for JavaScript](https://github.com/microsoft/Agents-for-js) +- [Bot Framework WebChat](https://github.com/microsoft/BotFramework-WebChat) +- [SPFx Application Customizer debugging](https://learn.microsoft.com/en-us/sharepoint/dev/spfx/extensions/get-started/build-a-hello-world-extension#debug-your-application-customizer) +- [Tenant graph grounding](https://learn.microsoft.com/en-us/microsoft-copilot-studio/knowledge-copilot-studio#tenant-graph-grounding) diff --git a/ui/embed/sharepoint-customizer/config/package-solution.json b/ui/embed/sharepoint-customizer/config/package-solution.json index a4a21d53..1255abe9 100644 --- a/ui/embed/sharepoint-customizer/config/package-solution.json +++ b/ui/embed/sharepoint-customizer/config/package-solution.json @@ -3,9 +3,9 @@ "solution": { "name": "sidebar-agent-client-side-solution", "id": "54cbba6a-2459-43a9-80a4-bc0675a81ac0", - "version": "1.0.0.0", + "version": "1.1.0.1", "includeClientSideAssets": true, - "skipFeatureDeployment": true, + "skipFeatureDeployment": false, "isDomainIsolated": false, "developer": { "name": "", @@ -30,11 +30,10 @@ "title": "Application Extension - Deployment of custom action", "description": "Deploys a custom action with ClientSideComponentId association", "id": "f757a2ed-fea6-4478-abd2-080c4a1f6a84", - "version": "1.0.0.0", + "version": "1.1.0.1", "assets": { "elementManifests": [ - "elements.xml", - "ClientSideInstance.xml" + "elements.xml" ] } } diff --git a/ui/embed/sharepoint-customizer/config/serve.json b/ui/embed/sharepoint-customizer/config/serve.json index 0337190d..e73547aa 100644 --- a/ui/embed/sharepoint-customizer/config/serve.json +++ b/ui/embed/sharepoint-customizer/config/serve.json @@ -4,7 +4,7 @@ "https": true, "serveConfigurations": { "default": { - "pageUrl": "https://m365x66364744.sharepoint.com/sites/GlobalSales/SitePages/Home.aspx", + "pageUrl": "https://YOUR-TENANT.sharepoint.com/sites/YOUR-SITE/", "customActions": { "4c6e29f2-7eee-4f9f-bbd2-20c8859d0ba2": { "location": "ClientSideExtension.ApplicationCustomizer", @@ -12,15 +12,19 @@ "appClientId": "APP-REGISTRATION-ID", "tenantId": "TENANT-ID", "environmentId": "ENV-ID", - "agentIdentifier": "AGENT-ID", - "directConnectUrl": "YOUR-DIRECT-CONNECT-URL", - "showTyping": true + "schemaName": "AGENT-SCHEMA-NAME", + "startConversation": false, + "showTyping": true, + "enableFileUpload": true, + "showReasoning": false, + "showToolCalls": false, + "starterPrompts": [{ "title": "Get started", "text": "What can you help me with?" }] } } } }, "sidebarAgent": { - "pageUrl": "https://m365x66364744.sharepoint.com/sites/GlobalSales/SitePages/Home.aspx", + "pageUrl": "https://YOUR-TENANT.sharepoint.com/sites/YOUR-SITE/", "customActions": { "4c6e29f2-7eee-4f9f-bbd2-20c8859d0ba2": { "location": "ClientSideExtension.ApplicationCustomizer", @@ -28,9 +32,13 @@ "appClientId": "APP-REGISTRATION-ID", "tenantId": "TENANT-ID", "environmentId": "ENV-ID", - "agentIdentifier": "AGENT-ID", - "directConnectUrl": "YOUR-DIRECT-CONNECT-URL", - "showTyping": true + "schemaName": "AGENT-SCHEMA-NAME", + "startConversation": false, + "showTyping": true, + "enableFileUpload": true, + "showReasoning": false, + "showToolCalls": false, + "starterPrompts": [{ "title": "Get started", "text": "What can you help me with?" }] } } } diff --git a/ui/embed/sharepoint-customizer/gulpfile.js b/ui/embed/sharepoint-customizer/gulpfile.js index be291870..9b113a54 100644 --- a/ui/embed/sharepoint-customizer/gulpfile.js +++ b/ui/embed/sharepoint-customizer/gulpfile.js @@ -1,6 +1,19 @@ 'use strict'; const build = require('@microsoft/sp-build-web'); +const path = require('node:path'); + +// The SDK beta's Activity browser mapping points at TypeScript; use its shipped JavaScript shim. +const activityRoot = path.dirname(require.resolve('@microsoft/agents-activity/package.json')); +build.configureWebpack.mergeConfig({ + additionalConfiguration: config => { + config.resolve.alias = { + ...config.resolve.alias, + [path.join(activityRoot, 'src/browser/crypto.ts')]: path.join(activityRoot, 'dist/src/browser/crypto.js') + }; + return config; + } +}); build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`); diff --git a/ui/embed/sharepoint-customizer/images/sidepane-agent-activity.png b/ui/embed/sharepoint-customizer/images/sidepane-agent-activity.png new file mode 100644 index 00000000..65eb7efb Binary files /dev/null and b/ui/embed/sharepoint-customizer/images/sidepane-agent-activity.png differ diff --git a/ui/embed/sharepoint-customizer/images/sidepane-conversation.png b/ui/embed/sharepoint-customizer/images/sidepane-conversation.png new file mode 100644 index 00000000..97fa3282 Binary files /dev/null and b/ui/embed/sharepoint-customizer/images/sidepane-conversation.png differ diff --git a/ui/embed/sharepoint-customizer/images/sidepane-welcome.png b/ui/embed/sharepoint-customizer/images/sidepane-welcome.png new file mode 100644 index 00000000..364a644d Binary files /dev/null and b/ui/embed/sharepoint-customizer/images/sidepane-welcome.png differ diff --git a/ui/embed/sharepoint-customizer/package.json b/ui/embed/sharepoint-customizer/package.json index 8fe1cf5c..392eb74e 100644 --- a/ui/embed/sharepoint-customizer/package.json +++ b/ui/embed/sharepoint-customizer/package.json @@ -9,19 +9,25 @@ "scripts": { "build": "gulp bundle", "clean": "gulp clean", - "test": "gulp test" + "test": "tsc -p tsconfig.test.json && node --test tests/*.test.cjs", + "serve": "gulp serve --nobrowser", + "sideload": "node scripts/sideload.cjs", + "deploy": "node scripts/deploy.cjs" }, "dependencies": { "@azure/msal-browser": "^4.13.1", "@fluentui/react": "^8.121.4", - "@microsoft/agents-copilotstudio-client": "^1.0.0", + "@microsoft/agents-activity": "1.10.0-beta.7.g65f69dfa27", + "@microsoft/agents-copilotstudio-client": "1.10.0-beta.7.g65f69dfa27", "@microsoft/decorators": "1.21.1", "@microsoft/sp-application-base": "1.21.1", "@microsoft/sp-core-library": "1.21.1", - "botframework-webchat": "4.18.1-main.20250804.93043a9", - "botframework-webchat-fluent-theme": "4.18.1-main.20250804.93043a9", + "@microsoft/sp-component-base": "1.21.1", + "botframework-webchat": "4.19.2", + "botframework-webchat-fluent-theme": "4.19.2", "react": "17.0.1", "react-dom": "17.0.1", + "rxjs": "7.8.2", "tslib": "2.3.1" }, "devDependencies": { @@ -30,10 +36,12 @@ "@microsoft/rush-stack-compiler-5.3": "0.1.0", "@microsoft/sp-build-web": "1.21.1", "@microsoft/sp-module-interfaces": "1.21.1", + "@pnp/cli-microsoft365": "11.5.0", "@rushstack/eslint-config": "4.0.1", "@types/webpack-env": "~1.15.2", "ajv": "^6.12.5", "eslint": "8.57.1", + "fflate": "0.8.2", "gulp": "4.0.2", "typescript": "~5.3.3" } diff --git a/ui/embed/sharepoint-customizer/scripts/deploy.cjs b/ui/embed/sharepoint-customizer/scripts/deploy.cjs new file mode 100644 index 00000000..eb16295a --- /dev/null +++ b/ui/embed/sharepoint-customizer/scripts/deploy.cjs @@ -0,0 +1,458 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { spawn } = require('node:child_process'); +const { parseArgs, isDeepStrictEqual } = require('node:util'); +const { createHash, randomUUID } = require('node:crypto'); +const { unzipSync, strFromU8 } = require('fflate'); +const { validateProperties } = require('./properties.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const LOCATION = 'ClientSideExtension.ApplicationCustomizer'; +const HELP = `Deploy the SharePoint sidepane to one site (Node 22, Windows/macOS/Linux). + +npm run deploy -- --site-url --app-catalog-url --app-client-id --properties + +--app-client-id Existing sidepane SPA registration; overrides appClientId in JSON. +--properties Tenant/agent settings and optional UI flags, never credentials. +--site-url Target site; also the SPA redirect URI, without a trailing slash. +--app-catalog-url Existing tenant app catalog. Required only for live deployment. +--dry-run Validate and describe the plan; no build, login, or remote calls. +--package-only Build and inspect the configured .sppkg; no login or remote calls. +--replace Authorize replacing a different catalog package with a NEWER version. +--help Show this help. + +Live deployment reuses your existing m365 login to the explicit tenant GUID. +The deployer's app/permissions are separate from the sidepane SPA registration. +The script never logs in, grants permissions, or enables tenant-wide activation. +Catalog replacement can affect other installations. Bump solution/feature versions +before deploying changed code or packaged defaults. Identical packages are reused. +Failures stop deployment and report completed remote operations; no automatic rollback.`; + +function siteUrl(value, name) { + if (!value) throw new Error(`${name} is required.`); + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || + url.port || !/\.sharepoint\.(com|us|de|cn)$/i.test(url.hostname) || + /(?:^|\/)_layouts(?:\/|$)|\.aspx$/i.test(url.pathname)) { + throw new Error(`${name} must be a SharePoint HTTPS site URL, not a page, debug link, or URL with credentials.`); + } + return url.href.replace(/\/+$/, ''); +} + +function guid(value, name) { + if (typeof value !== 'string' || !GUID.test(value)) throw new Error(`${name} must be a GUID, not a placeholder.`); + return value.toLowerCase(); +} + +async function readJson(file) { + try { return JSON.parse(await fs.readFile(file, 'utf8')); } + catch (error) { throw new Error(`Cannot read JSON from ${file}: ${error.message}`); } +} + +async function loadOptions(argv, cwd = process.cwd(), root = ROOT) { + const { values } = parseArgs({ + args: argv, allowPositionals: false, + options: { + 'site-url': { type: 'string' }, 'app-catalog-url': { type: 'string' }, + 'app-client-id': { type: 'string' }, properties: { type: 'string' }, + 'dry-run': { type: 'boolean' }, 'package-only': { type: 'boolean' }, + replace: { type: 'boolean' }, help: { type: 'boolean' } + } + }); + if (values.help) return { help: true }; + if (values['dry-run'] && values['package-only']) throw new Error('Choose --dry-run or --package-only, not both.'); + if (!values.properties) throw new Error('--properties is required.'); + const fileProperties = await readJson(path.resolve(cwd, values.properties)); + if (!fileProperties || Array.isArray(fileProperties) || typeof fileProperties !== 'object') { + throw new Error('Properties must be a JSON object.'); + } + const properties = validateProperties({ ...fileProperties, appClientId: guid(values['app-client-id'], '--app-client-id') }); + properties.tenantId = guid(properties.tenantId, 'tenantId'); + if (properties.environmentId) properties.environmentId = guid(properties.environmentId, 'environmentId'); + for (const name of ['schemaName', 'agentIdentifier', 'directConnectUrl']) { + if (properties[name]?.includes('YOUR_')) throw new Error(`Replace the ${name} placeholder.`); + } + const target = siteUrl(values['site-url'], '--site-url'); + const catalog = values['app-catalog-url'] ? siteUrl(values['app-catalog-url'], '--app-catalog-url') : undefined; + if (!catalog && !values['package-only'] && !values['dry-run']) throw new Error('--app-catalog-url is required for live deployment.'); + if (catalog && new URL(catalog).hostname !== new URL(target).hostname) { + throw new Error('The site and tenant app catalog must be on the same SharePoint host.'); + } + const metadata = await readJson(path.join(root, 'config/package-solution.json')); + const manifest = await readJson(path.join(root, 'src/extensions/sidebarAgent/SidebarAgentApplicationCustomizer.manifest.json')); + const solution = metadata.solution; + if (solution.skipFeatureDeployment !== false || solution.includeClientSideAssets !== true || + solution.features?.length !== 1 || + !isDeepStrictEqual(solution.features[0].assets?.elementManifests, ['elements.xml'])) { + throw new Error('Use a site-installed package with included assets and only elements.xml; tenant-wide activation is not supported.'); + } + versionParts(solution.version); + if (solution.features[0].version !== solution.version) throw new Error('Keep solution and feature versions equal.'); + const packagePath = path.resolve(root, 'sharepoint', metadata.paths.zippedPackage); + if (path.dirname(packagePath) !== path.join(root, 'sharepoint/solution') || !packagePath.endsWith('.sppkg')) { + throw new Error('The package must be a .sppkg directly inside sharepoint/solution.'); + } + return { + root, properties, siteUrl: target, appCatalogUrl: catalog, packagePath, + version: solution.version, productId: guid(solution.id, 'solution.id'), + componentId: guid(manifest.id, 'component.id'), featureId: guid(solution.features[0].id, 'feature.id'), + dryRun: !!values['dry-run'], packageOnly: !!values['package-only'], replace: !!values.replace + }; +} + +function versionParts(version) { + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+\.\d+$/.test(version) || + version.split('.').some(part => Number(part) > 65535)) throw new Error(`Invalid package version: ${version}`); + return version.split('.').map(Number); +} + +function compareVersions(a, b) { + const left = versionParts(a), right = versionParts(b); + for (let index = 0; index < 4; index++) if (left[index] !== right[index]) return Math.sign(left[index] - right[index]); + return 0; +} + +function xmlEscape(value) { + return value.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); +} + +function xmlUnescape(value) { + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity) => + ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[entity]); +} + +function elementsXml(options) { + return ` + + + +`; +} + +function packageFingerprint(buffer) { + const entries = unzipSync(buffer); + const hash = createHash('sha256'); + for (const name of Object.keys(entries).sort()) { + let data = entries[name]; + // SPFx regenerates these packaging-only IDs on every build, even for identical inputs. + if (name.endsWith('.xml.config.xml')) { + data = strFromU8(data).replace(/[0-9a-f-]{36}<\/Id>/gi, 'generated'); + } else if (name === 'ClientSideAssets.xml') { + data = strFromU8(data).replace(/ Id="[0-9a-f-]{36}"/i, ' Id="generated"'); + } + hash.update(name).update('\0').update(data).update('\0'); + } + return hash.digest('hex'); +} + +function inspectPackage(buffer, options) { + const entries = unzipSync(buffer); + const xml = Object.entries(entries).filter(([name]) => /\.xml$/i.test(name)).map(([, data]) => strFromU8(data)).join('\n'); + if (/]*>/g)]; + if (actions.length !== 1) throw new Error('Package must contain exactly one customizer activation.'); + if (/\sName="/.test(actions[0][0])) throw new Error('CustomAction Name is not supported by the SharePoint feature XML schema; use Title.'); + const properties = actions[0][0].match(/ClientSideComponentProperties="([^"]*)"/); + if (!properties || !isDeepStrictEqual(JSON.parse(xmlUnescape(properties[1])), options.properties) || + !actions[0][0].includes(`ClientSideComponentId="${options.componentId}"`)) { + throw new Error('Packaged customizer properties or component ID do not match the requested configuration.'); + } + const appManifest = strFromU8(entries['AppManifest.xml'] || new Uint8Array()); + const feature = strFromU8(entries[`feature_${options.featureId}.xml`] || new Uint8Array()); + if (!appManifest.includes(`ProductID="${options.productId}"`) || !appManifest.includes(`Version="${options.version}"`) || + !feature.includes(`Id="${options.featureId}"`) || !feature.includes(`Version="${options.version}"`) || !feature.includes('Scope="Web"')) { + throw new Error('Packaged solution identity/version does not match the source metadata.'); + } + const manifests = [...xml.matchAll(/ComponentManifest="([^"]*)"/g)].map(match => JSON.parse(xmlUnescape(match[1]))); + if (manifests.length !== 1 || manifests[0].id !== options.componentId || + !Object.keys(entries).some(name => /ClientSideAssets\/.*\.js$/i.test(name))) { + throw new Error('Production client assets are missing from the package.'); + } + for (const manifest of manifests) { + if (!isDeepStrictEqual(manifest.loaderConfig?.internalModuleBaseUrls, ['HTTPS://SPCLIENTSIDEASSETLIBRARY/'])) { + throw new Error('Package must load its included SharePoint assets, not localhost or an external CDN.'); + } + for (const resource of Object.values(manifest.loaderConfig.scriptResources)) { + if (resource.type === 'path' && !entries[`ClientSideAssets/${resource.path}`]) throw new Error(`Missing packaged script: ${resource.path}`); + } + } + return { fingerprint: packageFingerprint(buffer), entries: Object.keys(entries).length }; +} + +function runNode(entry, args, { cwd = ROOT, inherit = false, timeout = 120000 } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [entry, ...args], { + cwd, shell: false, windowsHide: true, stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'] + }); + let stdout = '', stderr = '', timedOut = false, forceKill; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + forceKill = setTimeout(() => child.kill('SIGKILL'), 5000); + }, timeout); + child.stdout?.on('data', data => { stdout += data; }); + child.stderr?.on('data', data => { stderr += data; }); + child.once('error', error => { clearTimeout(timer); clearTimeout(forceKill); reject(error); }); + child.once('close', code => { + clearTimeout(timer); + clearTimeout(forceKill); + if (timedOut) reject(new Error(`${path.basename(entry)} timed out. Check remote state before retrying.`)); + else if (code !== 0) reject(new Error(`${path.basename(entry)} exited ${code}: ${(stderr || stdout).trim().slice(-8000)}`)); + else resolve(stdout); + }); + }); +} + +async function cli(args, run = runNode) { + const entry = path.join(path.dirname(require.resolve('@pnp/cli-microsoft365/package.json')), 'dist/index.js'); + const output = await run(entry, [...args, '--output', 'json']); + if (!output.trim()) return undefined; + try { return JSON.parse(output); } + catch { throw new Error(`CLI returned invalid JSON for ${args.slice(0, 4).join(' ')}.`); } +} + +async function buildPackage(options, { run = runNode, log = console.log } = {}) { + const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'sidepane-deploy-')); + try { + for (const name of [ + 'src', 'config/config.json', 'config/package-solution.json', 'config/sass.json', 'config/write-manifests.json', + 'sharepoint/assets/elements.xml', 'package.json', 'tsconfig.json', 'gulpfile.js', '.eslintrc.js' + ]) { + await fs.cp(path.join(options.root, name), path.join(staging, name), { recursive: true }); + } + await fs.symlink(path.join(options.root, 'node_modules'), path.join(staging, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir'); + await fs.writeFile(path.join(staging, 'sharepoint/assets/elements.xml'), elementsXml(options), 'utf8'); + const gulp = require.resolve('gulp/bin/gulp.js'); + log('Building an isolated, configured production package...'); + await run(gulp, ['bundle', '--ship'], { cwd: staging, inherit: true, timeout: 600000 }); + await run(gulp, ['package-solution', '--ship'], { cwd: staging, inherit: true, timeout: 180000 }); + const generated = path.join(staging, path.relative(options.root, options.packagePath)); + const buffer = await fs.readFile(generated); + const inspection = inspectPackage(buffer, options); + await fs.mkdir(path.dirname(options.packagePath), { recursive: true }); + const pending = `${options.packagePath}.${randomUUID()}.tmp`; + try { + await fs.copyFile(generated, pending); + await fs.rename(pending, options.packagePath); + } finally { await fs.rm(pending, { force: true }); } + log(`Verified package: ${options.packagePath}`); + return inspection; + } finally { + // Remove the dependency link first; never recursively walk the shared dependency directory. + await fs.rm(path.join(staging, 'node_modules'), { force: true }); + await fs.rm(staging, { recursive: true, force: true }); + } +} + +function array(value, label) { + if (!Array.isArray(value)) throw new Error(`Invalid ${label} response; expected an array.`); + return value; +} + +function object(value, label) { + if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`Invalid ${label} response; expected an object.`); + return value; +} + +function matchingActions(actions, options) { + const matches = array(actions, 'custom actions').filter(action => + String(action.ClientSideComponentId).toLowerCase() === options.componentId && action.Location === LOCATION); + if (matches.length > 1 || matches.some(action => action.Scope !== 3)) { + throw new Error('Conflicting customizer registrations found. Resolve duplicate/Site-scoped actions explicitly; nothing was removed.'); + } + return matches; +} + +async function poll(read, ready, label, { timeout = 300000, interval = 5000, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { + const deadline = Date.now() + timeout; + do { + const value = await read(); + if (ready(value)) return value; + if (Date.now() >= deadline) break; + await sleep(Math.min(interval, Math.max(0, deadline - Date.now()))); + } while (Date.now() <= deadline); + throw new Error(`Timed out waiting for ${label}. Inspect remote state before retrying.`); +} + +async function deploy(options, { command = cli, build = buildPackage, log = console.log, polling } = {}) { + log(`Target: ${options.siteUrl}\nCatalog: ${options.appCatalogUrl || '(not needed)'}\nPackage: ${options.productId} v${options.version}`); + log(`Sidepane app client ID: ${options.properties.appClientId}\nRequired SPA redirect: ${options.siteUrl}`); + if (options.dryRun) { + log('Dry run: validate existing deployer -> build isolated package -> upload/deploy -> install/upgrade on this site -> verify one customizer. No actions performed.'); + return { dryRun: true }; + } + if (options.packageOnly) return build(options, { log }); + let stage = 'preflight'; + const completed = []; + const catalogArgs = ['--appCatalogUrl', options.appCatalogUrl, '--appCatalogScope', 'tenant']; + const request = url => command(['request', '--url', url, '--method', 'get', '--accept', 'application/json;odata=nometadata']); + const getActions = () => command(['spo', 'customaction', 'list', '--webUrl', options.siteUrl, '--scope', 'All']); + const getApps = async () => array(await command(['spo', 'app', 'list', ...catalogArgs]), 'catalog apps'); + const findApp = apps => { + const matches = apps.filter(app => String(app.ProductId).toLowerCase() === options.productId); + if (matches.length > 1) throw new Error('Multiple catalog apps have the solution ProductId.'); + return matches[0]; + }; + const checkApp = (app, allowLegacyDefaults = false) => { + object(app, 'app metadata'); + guid(app.ID, 'catalog app ID'); + if (String(app.ProductId).toLowerCase() !== options.productId || app.IsClientSideSolution !== true || + app.SkipDeploymentFeature !== false || typeof app.ContainsTenantWideExtension !== 'boolean' || + typeof app.IsPackageDefaultSkipFeatureDeployment !== 'boolean' || + (!allowLegacyDefaults && (app.ContainsTenantWideExtension || app.IsPackageDefaultSkipFeatureDeployment))) { + throw new Error('Catalog app identity or activation scope conflicts with this site-only deployment.'); + } + if (app.IsValidAppPackage !== true) throw new Error(`Invalid catalog package: ${app.ErrorMessage || 'SharePoint rejected the package'}`); + if (typeof app.InstalledVersion !== 'string' || typeof app.Deployed !== 'boolean' || typeof app.CurrentVersionDeployed !== 'boolean') { + throw new Error('Incomplete app installation/deployment metadata.'); + } + return app; + }; + try { + const status = await command(['status']); + if (!status || typeof status !== 'object' || !status.connectedAs) { + throw new Error('CLI is logged out. Run npm exec -- m365 login with your separate deployment app and explicit tenant GUID, then retry.'); + } + if (String(status.appTenant).toLowerCase() !== options.properties.tenantId) { + throw new Error('CLI login tenant does not match tenantId. Sign in explicitly to the intended tenant; authentication was not changed.'); + } + const actualCatalog = await command(['spo', 'tenant', 'appcatalogurl', 'get']); + if (typeof actualCatalog !== 'string' || siteUrl(actualCatalog, 'Discovered catalog').toLowerCase() !== options.appCatalogUrl.toLowerCase()) { + throw new Error('The supplied app catalog does not match the authenticated tenant app catalog.'); + } + const web = object(await request(`${options.siteUrl}/_api/web?$select=Url`), 'target site'); + if (siteUrl(web.Url, 'Resolved site').toLowerCase() !== options.siteUrl.toLowerCase()) throw new Error('The target resolves to a different SharePoint site.'); + const tenantActions = array(await command(['spo', 'tenant', 'applicationcustomizer', 'list']), 'tenant-wide customizers'); + if (tenantActions.some(action => String(action.TenantWideExtensionComponentId).toLowerCase() === options.componentId)) { + throw new Error('A tenant-wide registration already exists for this component. Resolve it explicitly before site-only deployment.'); + } + const initialActions = matchingActions(await getActions(), options); + const existing = findApp(await getApps()); + if (existing) { + if (compareVersions(options.version, existing.AppCatalogVersion) < 0) throw new Error('Refusing to downgrade the catalog package.'); + if (compareVersions(options.version, existing.AppCatalogVersion) > 0 && !options.replace) { + throw new Error('A catalog package already exists. Use --replace to authorize its upgrade; other installations can be affected.'); + } + const replacingOlderPackage = options.replace && compareVersions(options.version, existing.AppCatalogVersion) > 0; + checkApp(existing, replacingOlderPackage); + const installed = checkApp(await request(`${options.siteUrl}/_api/web/tenantappcatalog/AvailableApps/GetById('${existing.ID}')`), replacingOlderPackage); + if (installed.InstalledVersion && compareVersions(installed.InstalledVersion, options.version) > 0) throw new Error('Refusing to downgrade the site app.'); + if (initialActions.length && !installed.InstalledVersion) throw new Error('An orphaned customizer already exists without an installed app. Resolve it explicitly before installation.'); + } else { + if (initialActions.length) throw new Error('A standalone customizer already exists. Resolve it explicitly before installing another registration.'); + const filename = path.basename(options.packagePath).replace(/'/g, "''"); + const files = object(await request(`${options.appCatalogUrl}/_api/web/GetFolderByServerRelativePath(DecodedUrl='AppCatalog')/Files?$select=Name&$filter=Name eq '${encodeURIComponent(filename)}'`), 'catalog files'); + if (array(files.value, 'catalog files').length) throw new Error('The package filename is already occupied by a different or unprocessed app. Resolve it before deployment.'); + } + stage = 'package'; + const inspection = await build(options, { log }); + const upload = !existing || compareVersions(options.version, existing.AppCatalogVersion) > 0; + if (existing && !upload) { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'sidepane-catalog-')); + try { + const file = path.join(temporary, 'existing.sppkg'); + await command(['request', '--url', `${options.appCatalogUrl}/_api/web/GetFileById('${existing.ID}')/$value`, + '--method', 'get', '--accept', 'application/octet-stream', '--filePath', file]); + if (packageFingerprint(await fs.readFile(file)) !== inspection.fingerprint) { + throw new Error('Catalog content differs at the same version. Bump solution/feature versions and use --replace; nothing was overwritten.'); + } + } finally { await fs.rm(temporary, { recursive: true, force: true }); } + log('Identical catalog package found; reusing it.'); + } + if (upload) { + const latest = findApp(await getApps()); + if (latest?.ID !== existing?.ID || latest?.AppCatalogVersion !== existing?.AppCatalogVersion) { + throw new Error('The catalog changed during packaging. Inspect the current app and retry; nothing was overwritten.'); + } + stage = 'upload'; + log(`${existing ? 'Replacing' : 'Uploading'} catalog package, then enabling v${options.version} on ${options.siteUrl}.`); + await command(['spo', 'app', 'add', '--filePath', options.packagePath, ...catalogArgs, ...(existing ? ['--overwrite'] : [])]); + completed.push('uploaded package'); + } + const app = await poll(async () => findApp(await getApps()), value => { + if (!value || value.AppCatalogVersion !== options.version) return false; + checkApp(value); + return true; + }, 'catalog package processing', polling); + if (upload || !app.Deployed || !app.CurrentVersionDeployed) { + stage = 'deploy catalog package'; + await command(['spo', 'app', 'deploy', '--id', app.ID, ...catalogArgs]); + completed.push('deployed catalog package'); + } + await poll(async () => findApp(await getApps()), value => { + if (!value) return false; + checkApp(value); + return value.Deployed === true && value.CurrentVersionDeployed === true && value.AppCatalogVersion === options.version; + }, 'catalog deployment', polling); + const getSiteApp = async () => { + const state = checkApp(await request(`${options.siteUrl}/_api/web/tenantappcatalog/AvailableApps/GetById('${app.ID}')`)); + if (state.InstalledVersion !== options.version) { + const instances = array(await command(['spo', 'app', 'instance', 'list', '--siteUrl', options.siteUrl]), 'site app instances'); + for (const instance of instances.filter(value => String(value.ProductId).toLowerCase() === options.productId)) { + // AppTiles uses AppStatus: 6 = Initialized, 9 = Disabled (not AppInstanceStatus). + if (instance.AppStatus === 6 || instance.AppStatus === 9 || instance.AppStatus === 0) { + const instanceId = guid(instance.AppId, 'app instance ID'); + const details = object(await command(['request', '--url', + `${options.siteUrl}/_api/web/GetAppInstanceById('${instanceId}')/GetErrorDetails()`, + '--method', 'post', '--accept', 'application/json;odata=nometadata']), 'installation errors'); + const errors = array(details.value, 'installation errors'); + if (errors.length) { + throw new Error(`SharePoint installation failed: ${errors.map(error => error.ExceptionMessage || error.ErrorDetail).join('\n')}`); + } + } + } + } + return state; + }; + let siteApp = await getSiteApp(); + if (siteApp.InstalledVersion && compareVersions(siteApp.InstalledVersion, options.version) > 0) throw new Error('Refusing to downgrade the site app.'); + if (!siteApp.InstalledVersion || compareVersions(siteApp.InstalledVersion, options.version) < 0) { + const operation = siteApp.InstalledVersion ? 'upgrade' : 'install'; + stage = `${operation} site app`; + await command(['spo', 'app', operation, '--id', app.ID, '--siteUrl', options.siteUrl, '--appCatalogScope', 'tenant']); + completed.push(`${operation} site app`); + } + stage = 'verify site installation'; + siteApp = await poll(getSiteApp, value => value.InstalledVersion === options.version, 'site app installation', polling); + let [action] = await poll(async () => matchingActions(await getActions(), options), + matches => matches.length === 1, 'feature-created Web customizer', polling); + stage = 'verify site properties'; + let currentProperties; + try { currentProperties = JSON.parse(action.ClientSideComponentProperties); } + catch { throw new Error('The installed customizer has invalid JSON properties. Resolve it before retrying.'); } + if (!isDeepStrictEqual(currentProperties, options.properties)) { + await command(['spo', 'customaction', 'set', '--webUrl', options.siteUrl, '--scope', 'Web', '--id', guid(action.Id, 'custom action ID'), + '--clientSideComponentProperties', JSON.stringify(options.properties)]); + completed.push('updated site customizer properties'); + } + [action] = await poll(async () => matchingActions(await getActions(), options), matches => { + if (matches.length !== 1) return false; + return isDeepStrictEqual(JSON.parse(matches[0].ClientSideComponentProperties), options.properties); + }, 'customizer property readback', polling); + const report = { + siteUrl: options.siteUrl, appCatalogUrl: options.appCatalogUrl, productId: options.productId, + catalogAppId: app.ID, version: siteApp.InstalledVersion, customActionId: action.Id, + componentId: options.componentId, properties: options.properties, verifiedAt: new Date().toISOString(), ...inspection, completed + }; + await fs.writeFile(path.join(path.dirname(options.packagePath), 'deployment.local.json'), JSON.stringify(report, null, 2), { mode: 0o600 }); + log(`Verified one site-scoped customizer at ${options.siteUrl}. Open a normal page without debug query parameters.`); + return report; + } catch (error) { + throw new Error(`Deployment stopped at "${stage}": ${error.message}\nCompleted remote operations: ${completed.join(', ') || 'none'}. No automatic rollback was attempted.`); + } +} + +if (require.main === module) { + loadOptions(process.argv.slice(2)).then(options => options.help ? console.log(HELP) : deploy(options)) + .catch(error => { console.error(error.message); process.exitCode = 1; }); +} + +module.exports = { + loadOptions, elementsXml, inspectPackage, packageFingerprint, runNode, buildPackage, cli, + deploy, matchingActions, compareVersions, poll, siteUrl +}; diff --git a/ui/embed/sharepoint-customizer/scripts/properties.cjs b/ui/embed/sharepoint-customizer/scripts/properties.cjs new file mode 100644 index 00000000..3c2c1707 --- /dev/null +++ b/ui/embed/sharepoint-customizer/scripts/properties.cjs @@ -0,0 +1,41 @@ +function validateProperties(properties) { + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) { + throw new Error('Properties must be a JSON object.'); + } + const strings = [ + 'appClientId', 'tenantId', 'environmentId', 'schemaName', 'agentIdentifier', 'directConnectUrl', + 'agentTitle', 'headerBackgroundColor' + ]; + const flags = ['startConversation', 'showTyping', 'enableFileUpload', 'showReasoning', 'showToolCalls']; + const allowed = [...strings, ...flags, 'starterPrompts']; + for (const key of Object.keys(properties)) { + if (!allowed.includes(key)) throw new Error(`Unknown property "${key}". Never put credentials in extension properties.`); + } + for (const key of strings) { + if (properties[key] !== undefined && typeof properties[key] !== 'string') throw new Error(`${key} must be a string.`); + } + if (!properties.appClientId?.trim() || !properties.tenantId?.trim() || + !(properties.directConnectUrl?.trim() || + (properties.environmentId?.trim() && (properties.schemaName?.trim() || properties.agentIdentifier?.trim())))) { + throw new Error('Provide appClientId, tenantId, and environmentId/schemaName (or an explicit directConnectUrl).'); + } + if (properties.directConnectUrl) { + const url = new URL(properties.directConnectUrl); + if (url.protocol !== 'https:' || url.username || url.password || url.hash) { + throw new Error('directConnectUrl must be an HTTPS URL without credentials or a fragment.'); + } + } + for (const flag of flags) { + if (properties[flag] !== undefined && typeof properties[flag] !== 'boolean') throw new Error(`${flag} must be a boolean.`); + } + if (properties.starterPrompts !== undefined) { + if (!Array.isArray(properties.starterPrompts) || properties.starterPrompts.some(prompt => + !prompt || typeof prompt !== 'object' || Array.isArray(prompt) || + Object.keys(prompt).some(key => key !== 'title' && key !== 'text') || + typeof prompt.title !== 'string' || !prompt.title.trim() || typeof prompt.text !== 'string' || !prompt.text.trim() + )) throw new Error('starterPrompts must be an array of objects containing only a non-empty title and text.'); + } + return properties; +} + +module.exports = { validateProperties }; diff --git a/ui/embed/sharepoint-customizer/scripts/sideload.cjs b/ui/embed/sharepoint-customizer/scripts/sideload.cjs new file mode 100644 index 00000000..dc58ad91 --- /dev/null +++ b/ui/embed/sharepoint-customizer/scripts/sideload.cjs @@ -0,0 +1,35 @@ +const { readFileSync } = require('node:fs'); +const { validateProperties } = require('./properties.cjs'); + +const COMPONENT_ID = '4c6e29f2-7eee-4f9f-bbd2-20c8859d0ba2'; + +function createSideloadUrl(pageUrl, properties, manifestUrl = 'https://localhost:4321/temp/build/manifests.js') { + const page = new URL(pageUrl); + const manifest = new URL(manifestUrl); + if (page.protocol !== 'https:' || page.username || page.password) { + throw new Error('The SharePoint page must be an HTTPS URL without credentials.'); + } + if (manifest.protocol !== 'https:' || manifest.username || manifest.password) { + throw new Error('The development manifest must be an HTTPS URL without credentials.'); + } + validateProperties(properties); + page.searchParams.set('loadSPFX', 'true'); + page.searchParams.set('debugManifestsFile', manifest.href); + page.searchParams.set('customActions', JSON.stringify({ + [COMPONENT_ID]: { location: 'ClientSideExtension.ApplicationCustomizer', properties } + })); + return page.href; +} + +if (require.main === module) { + try { + const [page, file, manifest] = process.argv.slice(2); + if (!page || !file) throw new Error('Usage: npm run sideload -- [HTTPS-manifest-URL]'); + console.log(createSideloadUrl(page, JSON.parse(readFileSync(file, 'utf8')), manifest)); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { createSideloadUrl, COMPONENT_ID }; diff --git a/ui/embed/sharepoint-customizer/sharepoint/assets/ClientSideInstance.xml b/ui/embed/sharepoint-customizer/sharepoint/assets/ClientSideInstance.xml index 04b50040..f1f82156 100644 --- a/ui/embed/sharepoint-customizer/sharepoint/assets/ClientSideInstance.xml +++ b/ui/embed/sharepoint-customizer/sharepoint/assets/ClientSideInstance.xml @@ -4,6 +4,17 @@ Title="SidebarAgent" Location="ClientSideExtension.ApplicationCustomizer" ComponentId="4c6e29f2-7eee-4f9f-bbd2-20c8859d0ba2" - Properties=""> + Properties="{ + "appClientId": "YOUR_APP_CLIENT_ID", + "tenantId": "YOUR_TENANT_ID", + "environmentId": "YOUR_ENVIRONMENT_ID", + "schemaName": "YOUR_AGENT_SCHEMA_NAME", + "startConversation": false, + "showTyping": true, + "enableFileUpload": true, + "showReasoning": false, + "showToolCalls": false, + "starterPrompts": [{ "title": "Get started", "text": "What can you help me with?" }] + }"> \ No newline at end of file diff --git a/ui/embed/sharepoint-customizer/sharepoint/assets/elements.xml b/ui/embed/sharepoint-customizer/sharepoint/assets/elements.xml index 70f90775..5c19c586 100644 --- a/ui/embed/sharepoint-customizer/sharepoint/assets/elements.xml +++ b/ui/embed/sharepoint-customizer/sharepoint/assets/elements.xml @@ -8,10 +8,13 @@ "appClientId": "YOUR_APP_CLIENT_ID", "tenantId": "YOUR_TENANT_ID", "environmentId": "YOUR_ENVIRONMENT_ID", - "agentIdentifier": "YOUR_AGENT_IDENTIFIER", - "directConnectUrl": "YOUR_DIRECT_CONNECT_URL", + "schemaName": "YOUR_AGENT_SCHEMA_NAME", + "startConversation": false, "showTyping": true, - "headerBackgroundColor": "white", + "enableFileUpload": true, + "showReasoning": false, + "showToolCalls": false, + "starterPrompts": [{ "title": "Get started", "text": "What can you help me with?" }], "agentTitle": "Copilot Studio Agent" }"> diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/SidebarAgentApplicationCustomizer.tsx b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/SidebarAgentApplicationCustomizer.tsx index 20a33873..8ea69522 100644 --- a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/SidebarAgentApplicationCustomizer.tsx +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/SidebarAgentApplicationCustomizer.tsx @@ -1,277 +1,128 @@ import { Log } from '@microsoft/sp-core-library'; -import { - BaseApplicationCustomizer, - PlaceholderContent, - PlaceholderName, - ApplicationCustomizerContext -} from '@microsoft/sp-application-base'; +import { BaseApplicationCustomizer, PlaceholderContent, PlaceholderName, ApplicationCustomizerContext } from '@microsoft/sp-application-base'; +import { IconButton, initializeIcons, MessageBar, MessageBarType, ThemeProvider as FluentThemeProvider } from '@fluentui/react'; +import { IReadonlyTheme, ThemeChangedEventArgs, ThemeProvider } from '@microsoft/sp-component-base'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import * as strings from 'SidebarAgentApplicationCustomizerStrings'; import SidePanel from './components/SidePanel/SidePanel'; -import { - ISidebarAgentApplicationCustomizerProperties, - ISidebarAgentState -} from './models/ISidebarAgentProperties'; +import styles from './components/SidePanel/SidePanel.module.scss'; +import { ISidebarAgentApplicationCustomizerProperties, ISidebarAgentState } from './models/ISidebarAgentProperties'; +import { normalizeConfiguration } from './configuration'; +import copilotStudioIcon from './assets/copilot-studio.svg'; -const LOG_SOURCE: string = 'SidebarAgentApplicationCustomizer'; +const LOG_SOURCE = 'SidebarAgentApplicationCustomizer'; -class SidebarAgentComponent extends React.Component< - { properties: ISidebarAgentApplicationCustomizerProperties; context: ApplicationCustomizerContext }, +export class SidebarAgentComponent extends React.Component< + { properties: ISidebarAgentApplicationCustomizerProperties; context: ApplicationCustomizerContext }, ISidebarAgentState > { - constructor(props: { properties: ISidebarAgentApplicationCustomizerProperties; context: ApplicationCustomizerContext }) { + public constructor(props: { properties: ISidebarAgentApplicationCustomizerProperties; context: ApplicationCustomizerContext }) { super(props); - const user = props.context?.pageContext?.user; + const user = props.context.pageContext.user; this.state = { - isPanelOpen: false, - currentUserLogin: user ? (user.loginName || user.email) : undefined, - chatKey: 0 + isPanelOpen: false, hasOpened: false, chatKey: 0, + currentUserLogin: user.email || user.loginName.split('|').pop() }; } - - private _togglePanel = (): void => { - this.setState({ isPanelOpen: !this.state.isPanelOpen }); - }; - - private _onPanelDismiss = (): void => { - this.setState({ isPanelOpen: false }); - }; - - private _startNewConversation = (): void => { - this.setState(prevState => ({ - chatKey: prevState.chatKey + 1 - })); - }; - - private _getBaseUrl = (): string => { - // Use SPFx context to get the base site URL - const context = this.props.context; - if (context?.pageContext?.web?.absoluteUrl) { - return context.pageContext.web.absoluteUrl; - } - // Fallback to origin if context is not available - return window.location.origin; - }; - public render(): React.ReactElement { - const { isPanelOpen } = this.state; - const { properties } = this.props; - - const headerColor = properties.headerBackgroundColor || 'white'; + const { properties, context } = this.props; + const { isPanelOpen, hasOpened, chatKey, currentUserLogin } = this.state; const agentTitle = properties.agentTitle || 'Copilot Studio Agent'; - - const hasRequiredProps = properties.appClientId && - properties.tenantId && - (properties.directConnectUrl || - (properties.agentIdentifier && properties.environmentId)); - - if (!hasRequiredProps) { - return ( -
- Configuration Error: Missing required properties. Please configure appClientId, tenantId, and either directConnectUrl OR both agentIdentifier and environmentId. -
- ); + try { + normalizeConfiguration(properties); + } catch (error) { + return + {error instanceof Error ? error.message : 'Check the agent configuration.'} + ; } - return ( -
-
- + <> +
+ } + ariaLabel={`Open ${agentTitle}`} + aria-expanded={isPanelOpen} + aria-haspopup="dialog" + title={`Open ${agentTitle} (Microsoft Copilot Studio)`} + onClick={() => this.setState({ isPanelOpen: true, hasOpened: true })} + />
- - -
+ {hasOpened && ( + this.setState({ isPanelOpen: false })} + onNewConversation={() => this.setState(previous => ({ chatKey: previous.chatKey + 1 }))} + chatKey={chatKey} + /> + )} + ); } } -function CopilotIcon(): JSX.Element { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -export default class SidebarAgentApplicationCustomizer - extends BaseApplicationCustomizer { - +export default class SidebarAgentApplicationCustomizer extends BaseApplicationCustomizer { private _topPlaceholder?: PlaceholderContent; private _reactContainer?: HTMLDivElement; + private _themeProvider?: ThemeProvider; + private _theme?: IReadonlyTheme; public onInit(): Promise { + initializeIcons(); + this._themeProvider = this.context.serviceScope.consume(ThemeProvider.serviceKey); + this._theme = this._themeProvider.tryGetTheme(); + this._themeProvider.themeChangedEvent.add(this, this._onThemeChanged); Log.info(LOG_SOURCE, `Initialized ${strings.Title}`); - - if (!this.properties.appClientId || !this.properties.tenantId) { - Log.error(LOG_SOURCE, new Error('appClientId and tenantId are required properties.')); - return Promise.reject('Missing required properties: appClientId and tenantId'); - } - - if (!this.properties.directConnectUrl && (!this.properties.agentIdentifier || !this.properties.environmentId)) { - Log.error(LOG_SOURCE, new Error('Either directConnectUrl OR both agentIdentifier and environmentId must be provided.')); - return Promise.reject('Missing required properties: Either provide directConnectUrl OR both agentIdentifier and environmentId'); - } - this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceholders); this._renderPlaceholders(); return Promise.resolve(); } - private _renderPlaceholders(): void { + private _renderPlaceholders = (): void => { + if (this._topPlaceholder) return; + this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top, { onDispose: this._unmount }); if (!this._topPlaceholder) { - this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top, { onDispose: this._onDispose }); - - if (!this._topPlaceholder) { - Log.warn(LOG_SOURCE, 'Top placeholder not available.'); - return; - } - - if (this._topPlaceholder.domElement) { - this._topPlaceholder.domElement.innerHTML = ''; - this._reactContainer = document.createElement('div'); - this._topPlaceholder.domElement.appendChild(this._reactContainer); - - const componentElement: React.ReactElement = React.createElement( - SidebarAgentComponent, - { properties: this.properties, context: this.context } - ); + Log.warn(LOG_SOURCE, 'Top placeholder is not available on this page.'); + return; + } + this._reactContainer = document.createElement('div'); + this._topPlaceholder.domElement.appendChild(this._reactContainer); + this._renderComponent(); + }; - ReactDOM.render(componentElement, this._reactContainer); - } + private _renderComponent = (): void => { + if (this._reactContainer) { + ReactDOM.render( + + + , + this._reactContainer + ); } - } + }; - private _onDispose = (): void => { + private _onThemeChanged = (event: ThemeChangedEventArgs): void => { + this._theme = event.theme; + this._renderComponent(); + }; + + private _unmount = (): void => { if (this._reactContainer) { - try { - ReactDOM.unmountComponentAtNode(this._reactContainer); - } catch { /* noop */ } + ReactDOM.unmountComponentAtNode(this._reactContainer); + this._reactContainer.remove(); this._reactContainer = undefined; } - Log.info(LOG_SOURCE, 'Disposed Top placeholder content.'); + this._topPlaceholder = undefined; }; + + public onDispose(): void { + this.context.placeholderProvider.changedEvent.remove(this, this._renderPlaceholders); + this._themeProvider?.themeChangedEvent.remove(this, this._onThemeChanged); + this._topPlaceholder?.dispose(); + this._unmount(); + } } diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/assets/copilot-studio.svg b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/assets/copilot-studio.svg new file mode 100644 index 00000000..01d6824b --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/assets/copilot-studio.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/AttachmentValidation.tsx b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/AttachmentValidation.tsx new file mode 100644 index 00000000..feb134b0 --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/AttachmentValidation.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { hooks } from 'botframework-webchat'; +import { validateFile } from './attachments'; + +export function AttachmentValidation({ + enabled, onError +}: { enabled: boolean; onError: (message: string) => void }): React.ReactElement { + const [attachments, setAttachments] = hooks.useSendBoxAttachments(); + React.useEffect(() => { + const errors: string[] = []; + const accepted = attachments.filter(({ blob }: { blob: Blob }) => { + try { + if (!enabled) throw new Error('File uploads are disabled for this agent.'); + validateFile(blob instanceof File ? blob.name : '', blob.size); + return true; + } catch (error) { + errors.push(error instanceof Error ? error.message : 'Could not read the selected file.'); + return false; + } + }); + if (errors.length) { + setAttachments(accepted); + onError(errors.join(' ')); + } + }, [attachments, enabled, onError, setAttachments]); + return <>; +} diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/Chat.module.scss b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/Chat.module.scss new file mode 100644 index 00000000..fb4da1c4 --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/Chat.module.scss @@ -0,0 +1,43 @@ +.chat { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.webChat { + display: flex; + flex: 1; + min-height: 0; + + > * { flex: 1; min-width: 0; } +} + +.loading { + display: grid; + place-items: center; + flex: 1; + padding: 24px; + + p { max-width: 320px; text-align: center; } +} + +.errorMessage { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 160px; + overflow-y: auto; +} + +.authDetails { + margin-top: 12px; + overflow-wrap: anywhere; +} + +.reconnect { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/Chat.tsx b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/Chat.tsx index e8f829d8..364c3ba0 100644 --- a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/Chat.tsx +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/Chat.tsx @@ -1,130 +1,202 @@ import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Components } from 'botframework-webchat'; +import { Components, createStore } from 'botframework-webchat'; import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; -import { ConnectionSettings, CopilotStudioClient, CopilotStudioWebChat, CopilotStudioWebChatConnection } from '@microsoft/agents-copilotstudio-client'; -import { acquireToken } from './acquireToken'; +import { ConnectionSettings, CopilotStudioClient, CopilotStudioWebChatConnection, ScopeHelper } from '@microsoft/agents-copilotstudio-client'; +import { DefaultButton, MessageBar, MessageBarType, Spinner, SpinnerSize, useTheme } from '@fluentui/react'; +import { ChatConfiguration } from '../../configuration'; +import { acquireToken, SignInRequiredError } from './acquireToken'; +import { createConnection } from './createConnection'; +import { AttachmentValidation } from './AttachmentValidation'; +import { UPLOAD_ACCEPT } from './attachments'; +import styles from './Chat.module.scss'; const { BasicWebChat, Composer } = Components; export interface IChatProps { - appClientId: string; - tenantId: string; - environmentId?: string; - agentIdentifier?: string; - directConnectUrl?: string; - showTyping?: boolean; + configuration: ChatConfiguration; currentUserLogin?: string; - baseUrl?: string; // Add this line + baseUrl: string; } -const Chat: React.FC = ({ - appClientId, - tenantId, - environmentId, - agentIdentifier, - directConnectUrl, - showTyping = true, - currentUserLogin, - baseUrl // Add this parameter -}) => { - const [connection, setConnection] = useState(null); - const [error, setError] = useState(); - - const isConfigured = appClientId && tenantId && (directConnectUrl || (environmentId && agentIdentifier)); - - useEffect(() => { - if (!isConfigured) return; - +const Chat: React.FC = ({ configuration, currentUserLogin, baseUrl }) => { + const theme = useTheme(); + // Recreating these options when busy changes resets Web Chat's transcript scroll position. + const styleOptions = React.useMemo['styleOptions']>(() => ({ + rootHeight: '100%', + rootWidth: '100%', + backgroundColor: theme.semanticColors.bodyBackground, + bubbleBackground: theme.semanticColors.bodyBackground, + bubbleTextColor: theme.semanticColors.bodyText, + bubbleFromUserBackground: theme.palette.neutralLighter, + bubbleFromUserTextColor: theme.semanticColors.bodyText, + bubbleBorderRadius: 8, + bubbleFromUserBorderRadius: 8, + primaryFont: theme.fonts.medium.fontFamily, + accent: theme.palette.themePrimary, + disableFileUpload: !configuration.enableFileUpload, + uploadAccept: UPLOAD_ACCEPT, + uploadMultiple: true, + hideTelephoneKeypadButton: true, + partGroupDefaultOpen: false + }), [theme, configuration.enableFileUpload]); + const themeVariables: React.CSSProperties & Record<`--${string}`, string> = { + '--colorNeutralForeground1': theme.semanticColors.bodyText, + '--colorNeutralForeground2': theme.semanticColors.bodyText, + '--colorNeutralForeground3': theme.semanticColors.bodySubtext, + '--colorNeutralForegroundDisabled': theme.semanticColors.disabledText, + '--colorNeutralBackground1': theme.semanticColors.bodyBackground, + '--colorNeutralBackground1Hover': theme.palette.neutralLighter, + '--colorNeutralBackground1Pressed': theme.palette.neutralLight, + '--colorNeutralBackground3': theme.palette.neutralLighter, + '--colorNeutralBackground4': theme.palette.neutralLighterAlt, + '--colorNeutralBackground5': theme.palette.neutralLight, + '--colorNeutralBackgroundDisabled': theme.semanticColors.disabledBackground, + '--colorNeutralStroke1': theme.palette.neutralTertiaryAlt, + '--colorNeutralStroke2': theme.palette.neutralLight, + '--colorStrokeFocus2': theme.palette.themePrimary, + '--colorBrandForeground1': theme.palette.themePrimary, + '--colorBrandForegroundLink': theme.palette.themePrimary, + '--colorBrandForegroundLinkHover': theme.palette.themeDark, + '--colorBrandBackground': theme.palette.themePrimary, + '--colorBrandBackgroundHover': theme.palette.themeDarkAlt, + '--colorBrandBackgroundPressed': theme.palette.themeDark, + '--colorBrandBackground2': theme.palette.themeLighterAlt, + '--colorStatusDangerForeground1': theme.semanticColors.errorText, + '--fontFamilyBase': theme.fonts.medium.fontFamily || '"Segoe UI", sans-serif' + }; + const [connection, setConnection] = React.useState(); + const [error, setError] = React.useState(); + const [authenticationFailed, setAuthenticationFailed] = React.useState(false); + const [signInRequired, setSignInRequired] = React.useState(false); + const signIn = React.useRef<() => void>(); + const [uploadError, setUploadError] = React.useState(); + const [attempt, setAttempt] = React.useState(0); + const [busy, setBusy] = React.useState(false); + const store = React.useMemo(() => createStore(), [attempt]); + + React.useEffect(() => { let cancelled = false; - - const initializeConnection = async (): Promise => { + let activeConnection: CopilotStudioWebChatConnection | undefined; + setError(undefined); + setAuthenticationFailed(false); + setSignInRequired(false); + setUploadError(undefined); + setConnection(undefined); + setBusy(false); + + const initialize = async (interactive = false): Promise => { + setError(undefined); + let acquiringToken = false; try { - const token = await acquireToken({ - appClientId, - tenantId, - currentUserLogin, - redirectUri: baseUrl // Pass baseUrl as redirectUri - }); - - if (!token) { - setError('Unable to acquire token.'); - return; - } - const settings = new ConnectionSettings({ - appClientId, - tenantId, - environmentId: environmentId || '', - agentIdentifier: agentIdentifier || '', - directConnectUrl: directConnectUrl || '' + environmentId: configuration.environmentId, + schemaName: configuration.schemaName, + directConnectUrl: configuration.directConnectUrl }); - - const client = new CopilotStudioClient(settings, token); - const webchatSettings = { showTyping }; - - if (!cancelled) { - setConnection(CopilotStudioWebChat.createConnection(client, webchatSettings)); - } - } catch (e) { + acquiringToken = true; + const token = await acquireToken({ + appClientId: configuration.appClientId, tenantId: configuration.tenantId, + currentUserLogin, redirectUri: baseUrl, + scopes: [ScopeHelper.getScopeFromSettings(settings)] + }, interactive); + acquiringToken = false; + if (cancelled) return; + setAuthenticationFailed(false); + setSignInRequired(false); + activeConnection = createConnection(new CopilotStudioClient(settings, token), configuration, { + onTurn: () => { + setError(undefined); + setBusy(true); + }, + onTurnEnd: () => { if (!cancelled) setBusy(false); }, + onError: failure => { + if (!cancelled) { + console.error('Copilot connection failed:', failure.name); + setError(failure.message); + } + } + }); + setConnection(activeConnection); + } catch (failure) { if (!cancelled) { - console.error('Chat initialization error:', e); - setError(e instanceof Error ? e.message : 'Unknown error initializing chat'); + setAuthenticationFailed(acquiringToken); + if (failure instanceof SignInRequiredError) setSignInRequired(true); + console.error('Copilot initialization failed:', failure instanceof Error ? failure.name : 'Unknown error'); + setError(failure instanceof Error ? failure.message : 'Could not connect to the agent.'); } } }; - - initializeConnection(); - - return () => { - cancelled = true; + const connect = (interactive = false): void => { + initialize(interactive).catch(failure => { + if (!cancelled) setError(failure instanceof Error ? failure.message : 'Could not connect to the agent.'); + }); }; - }, [isConfigured, appClientId, tenantId, environmentId, agentIdentifier, directConnectUrl, showTyping, currentUserLogin, baseUrl]); // Add baseUrl to dependencies - - if (!isConfigured) { - return ( - - Configure appClientId, tenantId, and either directConnectUrl or (environmentId and agentIdentifier) in the manifest properties. - - ); - } - - if (error) { - return Error: {error}; - } - - if (!connection) { - return Connecting to Copilot Studio...; - } + signIn.current = () => connect(true); + connect(); + return () => { + cancelled = true; + signIn.current = undefined; + activeConnection?.end(); + }; + }, [configuration, currentUserLogin, baseUrl, attempt]); return ( - - - - - +
+ {uploadError && setUploadError(undefined)}> + {uploadError} + } + {error && ( + +
{error}
+ {authenticationFailed && ( +
+ {signInRequired &&

+ Select Sign in to open Microsoft sign-in. If Microsoft shows an error without redirecting back, + its detailed message stays in that window and cannot be read by this page. +

} +
Application ID: {configuration.appClientId}
+
Redirect URI (Single-page application): {baseUrl}
+
+ )} +
+ {signInRequired ? ( + signIn.current?.()}>Sign in + ) : ( + <> + setAttempt(previous => previous + 1)}>Reconnect + Reconnecting starts a new conversation. A failed request may already have reached the agent. + + )} +
+
+ )} + {!connection && !error && ( +
+
+ + {signInRequired &&

+ Complete sign-in in the pop-up. If Microsoft displays an error, that window contains the detailed sign-in message. +

} +
+
+ )} + {connection && ( + + + +
+ +
+
+
+ )} +
); }; -interface ChatMessageProps { - children: React.ReactNode; - type?: 'info' | 'warning' | 'error'; -} - -const ChatMessage: React.FC = ({ children, type = 'info' }) => { - const styles = { - info: { padding: 16 }, - warning: { padding: 16, color: '#8a6d3b', backgroundColor: '#fcf8e3' }, - error: { padding: 16, color: '#a94442', backgroundColor: '#f2dede' } - }; - - return
{children}
; -}; - export default Chat; diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/acquireToken.ts b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/acquireToken.ts index 2fd488b2..9305c147 100644 --- a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/acquireToken.ts +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/acquireToken.ts @@ -1,89 +1,63 @@ -import { PublicClientApplication, InteractionRequiredAuthError } from '@azure/msal-browser'; +import { PublicClientApplication, InteractionRequiredAuthError, BrowserAuthError, BrowserAuthErrorCodes } from '@azure/msal-browser'; export interface IAuthSettings { appClientId: string; tenantId: string; currentUserLogin?: string; - redirectUri?: string; + redirectUri: string; + scopes: string[]; } -export async function acquireToken(settings: IAuthSettings): Promise { - if (!settings?.appClientId || !settings?.tenantId) { - return undefined; +export class SignInRequiredError extends Error { + constructor(public readonly cause?: InteractionRequiredAuthError | BrowserAuthError) { + super(cause?.message || 'Sign in to connect to your agent.'); + this.name = 'SignInRequiredError'; } +} - console.log('using this as a redirect URI: ', settings.redirectUri || window.location.origin); - - const msalInstance = new PublicClientApplication({ - auth: { - clientId: settings.appClientId, - authority: `https://login.microsoftonline.com/${settings.tenantId}`, - redirectUri: settings.redirectUri || window.location.origin - }, - cache: { - cacheLocation: "localStorage" - } - }); - - await msalInstance.initialize(); - const scopes = ['https://api.powerplatform.com/.default']; +const applications = new Map>(); + +function getApplication(settings: IAuthSettings): Promise { + const key = `${settings.appClientId}|${settings.tenantId}|${settings.redirectUri}`; + let application = applications.get(key); + if (!application) { + const instance = new PublicClientApplication({ + auth: { + clientId: settings.appClientId, + authority: `https://login.microsoftonline.com/${settings.tenantId}`, + redirectUri: settings.redirectUri + }, + cache: { cacheLocation: 'sessionStorage' } + }); + application = instance.initialize().then(() => instance).catch(error => { + applications.delete(key); + throw error; + }); + applications.set(key, application); + } + return application; +} +export async function acquireToken(settings: IAuthSettings, interactive = false): Promise { + const instance = await getApplication(settings); + const accounts = instance.getAllAccounts(); + const account = settings.currentUserLogin + ? accounts.find(candidate => candidate.username.toLowerCase() === settings.currentUserLogin?.toLowerCase()) + : accounts.length === 1 ? accounts[0] : undefined; + const request = { scopes: settings.scopes, loginHint: settings.currentUserLogin }; + if (interactive) return (await instance.acquireTokenPopup(request)).accessToken; + if (!account && !settings.currentUserLogin) throw new SignInRequiredError(); try { - const accounts = await msalInstance.getAllAccounts(); - let userAccount = null; - - if (!accounts || accounts.length === 0) { - console.log("No users are signed in"); - } else if (accounts.length > 1 && settings.currentUserLogin) { - userAccount = accounts.find(account => - account.username.toLowerCase() === settings.currentUserLogin?.toLowerCase() - ) || null; - } else { - userAccount = accounts[0]; - } - - if (userAccount) { - const accessTokenRequest = { - scopes: scopes, - account: userAccount - }; - - try { - const response = await msalInstance.acquireTokenSilent(accessTokenRequest); - console.log('Token acquired via acquireTokenSilent'); - return response.accessToken; - } catch (errorInternal) { - console.log('acquireTokenSilent failed:', errorInternal); - // Fall through to try SSO - } + if (account) { + return (await instance.acquireTokenSilent({ ...request, account })).accessToken; } - - // Only reach here if no account or acquireTokenSilent failed - const ssoRequest = { - scopes: scopes, - loginHint: settings.currentUserLogin - }; - - try { - const response = await msalInstance.ssoSilent(ssoRequest); - console.log('Token acquired via ssoSilent'); - return response.accessToken; - } catch (silentError) { - console.log('ssoSilent failed:', silentError); - if (silentError instanceof InteractionRequiredAuthError) { - try { - const response = await msalInstance.loginPopup(ssoRequest); - console.log('Token acquired via loginPopup'); - return response.accessToken; - } catch (popupError) { - console.log('loginPopup failed:', popupError); - return undefined; - } - } - return undefined; + return (await instance.ssoSilent(request)).accessToken; + } catch (error) { + if (error instanceof InteractionRequiredAuthError || + (error instanceof BrowserAuthError && error.errorCode === BrowserAuthErrorCodes.monitorWindowTimeout)) { + console.warn('Copilot silent sign-in requires interaction:', error.errorCode); + throw new SignInRequiredError(error); } - } catch (e) { - console.error('Token acquisition error', e); - return undefined; + throw error; } } diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/activityDetails.ts b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/activityDetails.ts new file mode 100644 index 00000000..52538942 --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/activityDetails.ts @@ -0,0 +1,153 @@ +export interface ActivityVisibility { + showReasoning: boolean; + showToolCalls: boolean; +} + +export interface ActivityDetail { + id: string; + kind: 'reasoning' | 'tool'; + text: string; + status?: 'running' | 'finished' | 'failed'; + sequence?: number; +} + +export interface ActivityDetailsState { + entries: ActivityDetail[]; + toolNames: Record; +} + +export function emptyActivityDetails(): ActivityDetailsState { + return { entries: [], toolNames: {} }; +} + +export function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function identifier(value: unknown): string { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : text(value); +} + +export function isInformative(activity: unknown): boolean { + const item = record(activity); + return item.type === 'typing' && streamMetadata(item).streamType === 'informative'; +} + +function entities(activity: Record): Record[] { + return Array.isArray(activity.entities) ? activity.entities.map(record) : []; +} + +function streamMetadata(activity: Record): Record { + return { ...entities(activity).find(entity => entity.type === 'streaminfo'), ...record(activity.channelData) }; +} + +export function hasDetailEntities(activity: unknown): boolean { + return entities(record(activity)).some(entity => entity.type === 'thought' || entity.type === 'toolCall'); +} + +function planEventType(activity: Record): string { + return activity.type === 'event' ? text(activity.valueType) || text(activity.name) : ''; +} + +export function isPlanEvent(activity: unknown): boolean { + return [ + 'DynamicPlanReceived', 'DynamicPlanStepTriggered', 'DynamicPlanStepBindUpdate', 'DynamicPlanStepFinished' + ].indexOf(planEventType(record(activity))) !== -1; +} + +export function updateActivityDetails( + state: ActivityDetailsState, activity: unknown, visibility: ActivityVisibility +): ActivityDetailsState { + const item = record(activity); + if (hasDetailEntities(item)) return updateAgenticDetails(state, item, visibility); + if (isPlanEvent(item)) return updateStandardDetails(state, item, visibility); + return state; +} + +function updateAgenticDetails( + state: ActivityDetailsState, item: Record, visibility: ActivityVisibility +): ActivityDetailsState { + const stream = streamMetadata(item); + const sequence = typeof stream.streamSequence === 'number' ? stream.streamSequence : undefined; + let next = state; + for (const entity of entities(item)) { + if (entity.type === 'thought' && visibility.showReasoning) { + const content = text(entity.text); + if (!content) continue; + const chain = identifier(entity.chainOfThoughtId) || text(stream.streamId) || text(item.id) || 'current'; + const id = `reasoning:${chain}:${identifier(entity.sequenceNumber) || '0'}`; + const previous = next.entries.find(entry => entry.id === id); + if (previous?.sequence !== undefined && sequence !== undefined && sequence <= previous.sequence) continue; + // The SDK accumulates activity.text, not thought.text; thought deltas still need assembly. + const nextText = stream.chunkType === 'delta' ? (previous?.text || '') + content : content; + const entry: ActivityDetail = { id, kind: 'reasoning', text: nextText, sequence }; + next = { ...next, entries: upsert(next.entries, entry) }; + } else if (entity.type === 'toolCall' && visibility.showToolCalls) { + const callId = identifier(entity.toolCallId); + const status = entity.status === 'completed' ? 'finished' + : entity.status === 'started' ? 'running' : entity.status === 'failed' ? 'failed' : undefined; + if (!callId || !status) continue; + const id = `tool:${callId}`; + const previous = next.entries.find(entry => entry.id === id); + if (previous?.sequence !== undefined && sequence !== undefined && sequence <= previous.sequence) continue; + if (previous?.status && previous.status !== 'running' && status === 'running') continue; + const entry: ActivityDetail = { + id, kind: 'tool', status, sequence, + text: text(entity.toolDisplayName) || text(entity.toolName) || text(entity.operationId) || previous?.text || 'Agent action' + }; + next = { ...next, entries: upsert(next.entries, entry) }; + } + } + return next; +} + +function updateStandardDetails( + state: ActivityDetailsState, item: Record, visibility: ActivityVisibility +): ActivityDetailsState { + const event = planEventType(item); + const value = record(item.value); + if (event === 'DynamicPlanReceived') { + if (!visibility.showToolCalls || !Array.isArray(value.toolDefinitions)) return state; + const toolNames = { ...state.toolNames }; + value.toolDefinitions.forEach(definition => { + const tool = record(definition); + const schema = text(tool.schemaName); + if (schema && text(tool.displayName)) toolNames[schema] = text(tool.displayName); + }); + return { ...state, toolNames }; + } + const schema = text(value.taskDialogId); + const step = identifier(value.stepId) || identifier(value.id) || schema; + if (!step) return state; + const scope = JSON.stringify([identifier(value.planIdentifier), step]); + let next = state; + const thought = text(value.thought); + if (visibility.showReasoning && event === 'DynamicPlanStepTriggered' && thought.trim()) { + const entry: ActivityDetail = { id: `reasoning:standard:${scope}`, kind: 'reasoning', text: thought }; + if (next.entries.find(candidate => candidate.id === entry.id)?.text !== thought) { + next = { ...next, entries: upsert(next.entries, entry) }; + } + } + if (!visibility.showToolCalls) return next; + const id = `tool:standard:${scope}`; + const previous = next.entries.find(entry => entry.id === id); + const status = event === 'DynamicPlanStepFinished' ? 'finished' : 'running'; + if (previous?.status === 'finished' && status === 'running') return next; + const entry: ActivityDetail = { + id, kind: 'tool', + text: text(next.toolNames[schema]) || previous?.text || schema || 'Agent action', + status + }; + if (previous?.text === entry.text && previous.status === status) return next; + return { ...next, entries: upsert(next.entries, entry) }; +} + +function upsert(entries: ActivityDetail[], entry: ActivityDetail): ActivityDetail[] { + return entries.some(existing => existing.id === entry.id) + ? entries.map(existing => existing.id === entry.id ? entry : existing) + : [...entries, entry]; +} diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/attachments.ts b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/attachments.ts new file mode 100644 index 00000000..55e9e027 --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/attachments.ts @@ -0,0 +1,49 @@ +export const MAX_FILE_BYTES = 15 * 1024 * 1024; +const contentTypes: Record = { + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + csv: 'text/csv', + pdf: 'application/pdf', + txt: 'text/plain', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif' +}; +export const UPLOAD_ACCEPT = Object.keys(contentTypes).map(extension => `.${extension}`).join(','); + +export function validateFile(name: string, size: number): string { + const extension = name.split('.').pop()?.toLowerCase() || ''; + const contentType = Object.prototype.hasOwnProperty.call(contentTypes, extension) ? contentTypes[extension] : undefined; + if (!contentType) throw new Error(`${name || 'This file'} is not supported. Use DOCX, CSV, PDF, TXT, JPG, PNG, WebP, or a nonanimated GIF.`); + if (!Number.isFinite(size) || size <= 0) throw new Error(`${name} is empty or could not be read.`); + if (size > MAX_FILE_BYTES) throw new Error(`${name} exceeds the 15 MiB file limit. Choose a smaller file.`); + return contentType; +} + +export interface LocalAttachment { + name?: string; + contentType: string; + contentUrl?: string; +} + +export async function prepareAttachments( + attachments: T[], enabled: boolean, read: typeof fetch = fetch +): Promise { + if (attachments.length && !enabled) throw new Error('File uploads are disabled for this agent.'); + return Promise.all(attachments.map(async attachment => { + if (!attachment.contentUrl?.startsWith('blob:')) { + throw new Error('Choose a local file to attach. Remote attachment URLs are not supported.'); + } + const response = await read(attachment.contentUrl); + if (!response.ok) throw new Error(`Could not read ${attachment.name || 'the attachment'}. Remove it and attach it again.`); + const blob = await response.blob(); + const contentType = validateFile(attachment.name || '', blob.size); + const bytes = new Uint8Array(await blob.arrayBuffer()); + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + } + return { ...attachment, contentType, contentUrl: `data:${contentType};base64,${btoa(binary)}` }; + })); +} diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/createConnection.ts b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/createConnection.ts new file mode 100644 index 00000000..f64e9f25 --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/createConnection.ts @@ -0,0 +1,145 @@ +import { CopilotStudioClient, CopilotStudioWebChat, CopilotStudioWebChatConnection } from '@microsoft/agents-copilotstudio-client'; +import { Observable, ReplaySubject, share, Subscription } from 'rxjs'; +import { Activity } from '@microsoft/agents-activity'; +import { ChatConfiguration } from '../../configuration'; +import { createPreChatActivity, NativeActivityAdapter } from './nativeActivities'; +import { prepareAttachments } from './attachments'; + +export interface ConnectionCallbacks { + onTurn: () => void; + onTurnEnd: () => void; + onError: (error: Error) => void; +} + +export function createConnection( + client: CopilotStudioClient, configuration: ChatConfiguration, callbacks: ConnectionCallbacks +): CopilotStudioWebChatConnection { + const connection = CopilotStudioWebChat.createConnection(client, { + showTyping: configuration.showTyping, startConversation: configuration.startConversation + }); + let initialization: Promise | undefined; + const initializeConversation = (): Promise => { + if (!initialization) { + initialization = (async () => { + const activities = client.startConversationStreaming(false); + while (!(await activities.next()).done) { + // Drain startup without a greeting; the SDK retains the ID from response headers. + } + })().catch(error => { + initialization = undefined; + throw error; + }); + } + return initialization; + }; + return wrapConnection( + connection, configuration, callbacks, configuration.startConversation ? undefined : initializeConversation + ); +} + +export function wrapConnection( + connection: CopilotStudioWebChatConnection, configuration: ChatConfiguration, callbacks: ConnectionCallbacks, + initializeConversation?: () => Promise +): CopilotStudioWebChatConnection { + let ended = false; + let sequence = 0; + const native = new NativeActivityAdapter(configuration); + let emitUpdates: ((activities: Partial[]) => void) | undefined; + const requests = new Set(); + const report = (error: unknown): Error => { + const failure = error instanceof Error ? error : new Error('The agent connection failed. Try reconnecting.'); + if (!ended) { + emitUpdates?.(native.finishTurn(true)); + callbacks.onError(failure); + } + return failure; + }; + // WebChat first subscribes to connect, then subscribes again to render the transcript. + // Share the SDK's single subscriber and replay activities that arrived during startup. + const activity$ = new Observable>(subscriber => { + const emit = (activities: Partial[]): void => { + activities.forEach(activity => { + const incoming = { ...activity, id: activity.id || `sidepane-activity-${++sequence}` }; + if (incoming.type === 'typing' && !incoming.text && !configuration.showTyping) return; + subscriber.next(incoming); + }); + }; + emitUpdates = emit; + if (!configuration.startConversation) emit([createPreChatActivity(configuration)]); + const subscription = connection.activity$.subscribe({ + next: activity => { + if (ended) return; + emit(native.transform(activity)); + }, + error: error => { + report(error); + connection.connectionStatus$.next(4); + subscriber.complete(); + }, + complete: () => subscriber.complete() + }); + return () => { + if (emitUpdates === emit) emitUpdates = undefined; + subscription.unsubscribe(); + }; + }).pipe(share({ + connector: () => new ReplaySubject>(), + resetOnComplete: true, + resetOnError: true, + resetOnRefCountZero: true + })); + return { + connectionStatus$: connection.connectionStatus$, + get conversationId() { return connection.conversationId; }, + activity$, + postActivity: activity => new Observable(subscriber => { + let cancelled = false; + let acknowledged = false; + const subscription = new Subscription(); + const finish = (interrupted = false): void => { + if (!requests.delete(subscription)) return; + if (!ended && activity.type === 'message') { + emitUpdates?.(native.finishTurn(interrupted)); + callbacks.onTurnEnd(); + } + }; + const send = async (): Promise => { + try { + if (ended || cancelled) return; + requests.add(subscription); + if (activity.type === 'message') { + native.startTurn(); + callbacks.onTurn(); + } + const attachments = await prepareAttachments(activity.attachments || [], configuration.enableFileUpload); + if (ended || cancelled) return; + if (activity.type === 'message') await initializeConversation?.(); + if (ended || cancelled) return; + subscription.add(connection.postActivity(Activity.fromObject({ ...activity, attachments })).subscribe({ + next: id => { acknowledged = true; subscriber.next(id); }, + error: error => { finish(true); subscriber.error(report(error)); }, + complete: () => { finish(); subscriber.complete(); } + })); + } catch (error) { + finish(true); + if (!ended && !cancelled) subscriber.error(report(error)); + } + }; + send().catch(error => subscriber.error(report(error))); + return () => { + cancelled = true; + // WebChat unsubscribes after the send acknowledgement, before the response stream finishes. + if (!acknowledged) { + subscription.unsubscribe(); + finish(true); + } + }; + }), + end: () => { + ended = true; + requests.forEach(subscription => subscription.unsubscribe()); + requests.clear(); + connection.end(); + } + }; +} diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/nativeActivities.ts b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/nativeActivities.ts new file mode 100644 index 00000000..73e05ff2 --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/Chat/nativeActivities.ts @@ -0,0 +1,117 @@ +import { Activity } from '@microsoft/agents-activity'; +import { ChatConfiguration } from '../../configuration'; +import { ActivityDetail, emptyActivityDetails, hasDetailEntities, isInformative, isPlanEvent, record, updateActivityDetails } from './activityDetails'; + +export function createPreChatActivity(configuration: ChatConfiguration): Partial { + return { + type: 'message', + id: 'sidepane:welcome', + from: { id: 'agent', role: 'bot' }, + timestamp: new Date().toISOString(), + channelData: { 'webchat:sequence-id': -1 }, + text: '', + entities: [{ + type: 'https://schema.org/Message', '@context': 'https://schema.org', '@id': '', '@type': 'Message', + keywords: ['PreChatMessage'], + author: { + '@type': 'Person', + name: 'How can I help?', + // Fluent 4.19 renders an avatar even when no author image is provided. + image: `data:image/svg+xml,${encodeURIComponent( + '' + )}`, + description: configuration.enableFileUpload + ? 'Ask a question or attach a document to get started.' + : 'Ask a question to get started.' + } + }], + suggestedActions: { + to: [], + actions: configuration.starterPrompts.map(prompt => ({ + type: 'messageBack', title: prompt.title, text: prompt.text + })) + } + }; +} + +function isNativeAnalysis(activity: Partial): boolean { + return !!activity.entities?.some(entity => { + const message = record(entity); + return message.type === 'https://schema.org/Message' && + Array.isArray(message.keywords) && message.keywords.indexOf('AnalysisMessage') !== -1; + }); +} + +export class NativeActivityAdapter { + private state = emptyActivityDetails(); + private turn = 0; + private outcome: 'finished' | 'interrupted' | undefined; + private sequenceIds = new Map(); + private timestamp = new Date().toISOString(); + private author = { id: 'agent', role: 'bot' }; + + constructor(private readonly configuration: ChatConfiguration) {} + + startTurn(): void { + // Standard plans can resume after a clarification or connector sign-in on a later turn. + this.state = { ...emptyActivityDetails(), toolNames: this.state.toolNames }; + this.sequenceIds.clear(); + this.timestamp = new Date().toISOString(); + this.outcome = undefined; + this.turn++; + } + + transform(activity: Partial): Partial[] { + if (activity.type === 'event' && activity.name === 'turn.complete') return this.finishTurn(); + if (hasDetailEntities(activity) || isInformative(activity) || isPlanEvent(activity)) { + const previous = this.state; + this.state = updateActivityDetails(previous, activity, this.configuration); + if (typeof activity.from?.id === 'string') this.author = { id: activity.from.id, role: 'bot' }; + return this.state.entries + .filter(entry => previous.entries.find(candidate => candidate.id === entry.id) !== entry) + .map(entry => { + const sequenceId = record(activity.channelData)['webchat:sequence-id']; + if (!this.sequenceIds.has(entry.id) && typeof sequenceId === 'number') { + this.sequenceIds.set(entry.id, sequenceId); + } + return this.render(entry); + }); + } + if (isNativeAnalysis(activity) && !this.configuration.showReasoning) return []; + return [activity]; + } + + finishTurn(interrupted = false): Partial[] { + if (this.outcome) return []; + this.outcome = interrupted ? 'interrupted' : 'finished'; + return this.state.entries.map(entry => this.render(entry)); + } + + private render(entry: ActivityDetail): Partial { + const finished = entry.kind === 'tool' ? entry.status === 'finished' : this.outcome === 'finished'; + const failed = entry.status === 'failed'; + return { + type: 'message', + id: `sidepane:turn:${this.turn}:${entry.id}`, + from: this.author, + timestamp: this.timestamp, + // Keep the SDK's sequence scale and the step's original position on updates. + channelData: { 'webchat:sequence-id': this.sequenceIds.get(entry.id) }, + textFormat: 'plain', + text: entry.kind === 'reasoning' ? entry.text : failed ? 'Failed' : finished ? 'Finished' : this.outcome ? 'No finish event received.' : 'Started', + entities: [{ + type: 'https://schema.org/Message', '@context': 'https://schema.org', '@id': '', '@type': 'Message', + abstract: entry.kind === 'reasoning' ? 'Reasoning' : entry.text, + keywords: ['AnalysisMessage', 'Collapsible'], + position: this.state.entries.indexOf(entry) + 1, + creativeWorkStatus: failed ? 'Failed' : finished ? 'Published' : this.outcome ? undefined : 'Incomplete', + isPartOf: { + '@id': `sidepane:turn:${this.turn}`, + '@type': 'HowTo', + abstract: this.outcome === 'interrupted' ? 'Agent activity interrupted' : 'Agent activity', + creativeWorkStatus: this.outcome === 'interrupted' ? 'Failed' : this.outcome === 'finished' ? 'Published' : 'Incomplete' + } + }] + }; + } +} diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.module.scss b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.module.scss index 103c70de..5e17e360 100644 --- a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.module.scss +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.module.scss @@ -1,95 +1,36 @@ - - .sidePanel { - position: fixed; - top: 0; - right: -30vw; - width: 30vw; - height: 100vh; - background-color: white; - box-shadow: none; - transition: right 0.3s ease-in-out, box-shadow 0.3s ease-in-out; - z-index: 1000; - display: flex; - flex-direction: column; + font-family: "Segoe UI", Aptos, Calibri, sans-serif; - &.open { - right: 0; - box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + @media (prefers-reduced-motion: reduce) { + &, * { animation-duration: 0s !important; transition-duration: 0s !important; } } } -.chatContainer { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - // Use SharePoint theme colors for gradient - background: linear-gradient(135deg, - "[theme:themeLighterAlt, default: #f3f2f1]" 0%, - "[theme:themeLighter, default: #d2d0ce]" 100%); - padding: 20px; - gap: 16px; -} - -.titleBar { - background: white; - border-radius: 12px; - padding: 16px 24px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +.header { display: flex; align-items: center; - justify-content: space-between; + gap: 12px; + flex: 0 0 auto; + min-width: 0; + padding: 16px; } .title { - margin: 0; - font-size: 16px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0 0 0 8px; + font-size: 20px; font-weight: 600; - color: "[theme:bodyText, default: #323130]"; + line-height: 1.4; } -.buttonGroup { +.launcher { display: flex; - gap: 8px; - align-items: center; + justify-content: flex-end; + padding: 4px 16px; + border-bottom: 1px solid "[theme:neutralLight, default: #edebe9]"; + background: "[theme:bodyBackground, default: #ffffff]"; } - -.iconButton { - background: none; - border: none; - cursor: pointer; - padding: 8px; - display: flex; - align-items: center; - justify-content: center; - color: "[theme:neutralSecondary, default: #605e5c]"; - border-radius: 4px; - transition: all 0.2s ease; - - &:hover { - background-color: "[theme:neutralLighter, default: #f3f2f1]"; - color: "[theme:neutralPrimary, default: #323130]"; - } -} - -.chatWrapper { - background: white; - border-radius: 12px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.4); - z-index: 999; -} \ No newline at end of file diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.tsx b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.tsx index 91ae6c20..d3213f37 100644 --- a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.tsx +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/components/SidePanel/SidePanel.tsx @@ -1,69 +1,61 @@ import * as React from 'react'; +import { IconButton, Panel, PanelType, useTheme } from '@fluentui/react'; import styles from './SidePanel.module.scss'; import Chat from '../Chat/Chat'; import { ISidePanelProps } from '../../models/ISidebarAgentProperties'; +import { normalizeConfiguration } from '../../configuration'; -export default class SidePanel extends React.Component { - public render(): React.ReactElement { - const { isOpen, properties, currentUserLogin, baseUrl, onDismiss, onNewConversation, chatKey } = this.props; - const agentTitle = properties.agentTitle || 'Copilot Studio Agent'; - - return ( - <> -
-
-
-

{agentTitle}

-
- - -
-
- -
- -
-
+const SidePanel: React.FC = ({ + isOpen, properties, currentUserLogin, baseUrl, onDismiss, onNewConversation, chatKey +}) => { + const theme = useTheme(); + const configuration = React.useMemo(() => normalizeConfiguration(properties), [properties]); + return ( + ( +
+

+ {properties.agentTitle || 'Copilot Studio Agent'} +

+ +
- - {isOpen &&
} - - ); - } -} - -const NewChatIcon: React.FC = () => ( - - - -); + )} + > + + + ); +}; -const ChevronRightIcon: React.FC = () => ( - - - -); \ No newline at end of file +export default SidePanel; diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/configuration.ts b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/configuration.ts new file mode 100644 index 00000000..d852127c --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/configuration.ts @@ -0,0 +1,72 @@ +import { ISidebarAgentApplicationCustomizerProperties, IStarterPrompt } from './models/ISidebarAgentProperties'; + +export const DEFAULT_STARTER_PROMPTS: IStarterPrompt[] = [ + { title: 'Get started', text: 'What can you help me with?' } +]; + +export interface ChatConfiguration { + appClientId: string; + tenantId: string; + environmentId?: string; + schemaName?: string; + directConnectUrl?: string; + startConversation: boolean; + showTyping: boolean; + enableFileUpload: boolean; + showReasoning: boolean; + showToolCalls: boolean; + starterPrompts: IStarterPrompt[]; +} + +function optionalText(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string') throw new Error('Connection settings must be strings.'); + return value.trim() || undefined; +} + +function flag(value: unknown, fallback: boolean, name: string): boolean { + if (value === undefined) return fallback; + if (typeof value !== 'boolean') throw new Error(`${name} must be true or false (not a string).`); + return value; +} + +function starterPrompts(value: unknown): IStarterPrompt[] { + if (value === undefined) return DEFAULT_STARTER_PROMPTS.map(prompt => ({ ...prompt })); + if (!Array.isArray(value)) throw new Error('starterPrompts must be an array of { title, text } objects.'); + return value.map(prompt => { + if (!prompt || typeof prompt !== 'object' || Array.isArray(prompt) || + Object.keys(prompt).some(key => key !== 'title' && key !== 'text') || + typeof prompt.title !== 'string' || !prompt.title.trim() || + typeof prompt.text !== 'string' || !prompt.text.trim()) { + throw new Error('Each starter prompt must contain only a non-empty title and text.'); + } + return { title: prompt.title.trim(), text: prompt.text.trim() }; + }); +} + +export function normalizeConfiguration(properties: ISidebarAgentApplicationCustomizerProperties): ChatConfiguration { + const appClientId = optionalText(properties.appClientId); + const tenantId = optionalText(properties.tenantId); + const environmentId = optionalText(properties.environmentId); + const schemaName = optionalText(properties.schemaName) || optionalText(properties.agentIdentifier); + const directConnectUrl = optionalText(properties.directConnectUrl); + if (!appClientId || !tenantId) throw new Error('Configure appClientId and tenantId for Microsoft sign-in.'); + if (!directConnectUrl && (!environmentId || !schemaName)) { + throw new Error('Configure environmentId and schemaName for endpoint discovery, or an explicit directConnectUrl.'); + } + if (directConnectUrl) { + const url = new URL(directConnectUrl); + if (url.protocol !== 'https:' || url.username || url.password || url.hash) { + throw new Error('directConnectUrl must be an HTTPS URL without credentials or a fragment.'); + } + } + return { + appClientId, tenantId, environmentId, schemaName, directConnectUrl, + startConversation: flag(properties.startConversation, false, 'startConversation'), + showTyping: flag(properties.showTyping, true, 'showTyping'), + enableFileUpload: flag(properties.enableFileUpload, true, 'enableFileUpload'), + showReasoning: flag(properties.showReasoning, false, 'showReasoning'), + showToolCalls: flag(properties.showToolCalls, false, 'showToolCalls'), + starterPrompts: starterPrompts(properties.starterPrompts) + }; +} diff --git a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/models/ISidebarAgentProperties.ts b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/models/ISidebarAgentProperties.ts index fa932070..6ddcd952 100644 --- a/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/models/ISidebarAgentProperties.ts +++ b/ui/embed/sharepoint-customizer/src/extensions/sidebarAgent/models/ISidebarAgentProperties.ts @@ -1,10 +1,22 @@ +export interface IStarterPrompt { + title: string; + text: string; +} + export interface ISidebarAgentApplicationCustomizerProperties { appClientId: string; tenantId: string; environmentId?: string; + schemaName?: string; + /** Compatibility alias for schemaName. */ agentIdentifier?: string; directConnectUrl?: string; + startConversation?: boolean; showTyping?: boolean; + enableFileUpload?: boolean; + showReasoning?: boolean; + showToolCalls?: boolean; + starterPrompts?: IStarterPrompt[]; headerBackgroundColor?: string; agentTitle?: string; } @@ -13,6 +25,7 @@ export interface ISidebarAgentState { isPanelOpen: boolean; currentUserLogin?: string; chatKey: number; + hasOpened: boolean; } export interface ISidePanelProps { diff --git a/ui/embed/sharepoint-customizer/src/svg.d.ts b/ui/embed/sharepoint-customizer/src/svg.d.ts new file mode 100644 index 00000000..788ae0d8 --- /dev/null +++ b/ui/embed/sharepoint-customizer/src/svg.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const url: string; + export default url; +} diff --git a/ui/embed/sharepoint-customizer/tests/activityDetails.test.cjs b/ui/embed/sharepoint-customizer/tests/activityDetails.test.cjs new file mode 100644 index 00000000..93183b07 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/activityDetails.test.cjs @@ -0,0 +1,191 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { emptyActivityDetails, updateActivityDetails } = require('../temp/tests/components/Chat/activityDetails'); +const { thought, toolCall, standardPlan, standardStep } = require('./fixtures/sdkActivities.cjs'); +const tool = (valueType, value = {}) => ({ type: 'event', valueType, value: { taskDialogId: 'lookup', stepId: 'call1', ...value } }); +const visible = { showReasoning: true, showToolCalls: true }; + +for (const showReasoning of [false, true]) { + for (const showToolCalls of [false, true]) { + test(`independent visibility: reasoning=${showReasoning}, tools=${showToolCalls}`, () => { + const flags = { showReasoning, showToolCalls }; + let state = updateActivityDetails(emptyActivityDetails(), thought('Checking sources', 1), flags); + state = updateActivityDetails(state, tool('DynamicPlanStepTriggered'), flags); + assert.equal(state.entries.filter(e => e.kind === 'reasoning').length, Number(showReasoning)); + assert.equal(state.entries.filter(e => e.kind === 'tool').length, Number(showToolCalls)); + }); + } +} +test('cumulative text replaces, explicit deltas append, duplicate/old sequences are ignored', () => { + let state = emptyActivityDetails(); + for (const activity of [ + thought('Checking', 1), thought('Checking sources', 2, 'replace'), + thought(' now', 3, 'delta'), thought(' duplicate', 3, 'delta'), thought('old', 1) + ]) state = updateActivityDetails(state, activity, visible); + assert.equal(state.entries.length, 1); + assert.equal(state.entries[0].text, 'Checking sources now'); +}); +test('thought deltas use entity text and the initial activity ID, then a replacement snapshot replaces the accumulated text', () => { + let state = emptyActivityDetails(); + for (const activity of [thought('Check ', 1), thought('the list', 2), thought('Check the list', 3, 'replace')]) { + state = updateActivityDetails(state, activity, visible); + } + assert.equal(state.entries.length, 1); + assert.equal(state.entries[0].id, 'reasoning:answer-stream:0'); + assert.equal(state.entries[0].text, 'Check the list'); +}); +test('separate thought chains and sequence numbers do not share accumulated text', () => { + let state = emptyActivityDetails(); + for (const properties of [ + { chainOfThoughtId: 'chain-a', sequenceNumber: 0 }, + { chainOfThoughtId: 'chain-a', sequenceNumber: 1 }, + { chainOfThoughtId: 'chain-b', sequenceNumber: 0 } + ]) state = updateActivityDetails(state, thought('Check', 1, 'delta', properties), visible); + assert.equal(state.entries.length, 3); + assert.ok(state.entries.every(entry => entry.text === 'Check')); +}); +test('toolCall entities correlate by call ID even when multiple calls share a stream and tool name', () => { + let state = emptyActivityDetails(); + for (const activity of [ + toolCall('started', { toolCallId: 'first', streamSequence: 1 }), + toolCall('started', { toolCallId: 'second', streamSequence: 2 }), + toolCall('completed', { toolCallId: 'first', streamSequence: 3 }), + toolCall('started', { toolCallId: 'first', streamSequence: 4 }), + toolCall('started', { toolCallId: 'second', streamSequence: 1 }) + ]) state = updateActivityDetails(state, activity, visible); + assert.deepEqual(state.entries.map(({ id, kind, text, status }) => ({ id, kind, text, status })), [ + { id: 'tool:first', kind: 'tool', text: 'Get items', status: 'finished' }, + { id: 'tool:second', kind: 'tool', text: 'Get items', status: 'running' } + ]); + assert.ok(!JSON.stringify(state).includes('PRIVATE_')); +}); +test('untyped informative messages, malformed tool entities and unknown tool status do not become reasoning or tool calls', () => { + const state = emptyActivityDetails(); + for (const activity of [ + { type: 'typing', text: 'Get items completed', channelData: { streamType: 'informative' } }, + { type: 'typing', text: 'Working', entities: [{ type: 'streaminfo', streamType: 'informative' }] }, + { type: 'typing', entities: [{ type: 'thought', text: { private: true } }] }, + toolCall('started', { toolCallId: '' }), toolCall('unknown') + ]) assert.strictEqual(updateActivityDetails(state, activity, visible), state); +}); +test('entity-only stream metadata retains ordering and correlates the thought stream', () => { + const first = thought('Check', 1, 'replace'); + const second = thought('Check the list', 2, 'replace'); + delete first.channelData; + delete second.channelData; + let state = updateActivityDetails(emptyActivityDetails(), first, visible); + state = updateActivityDetails(state, second, visible); + assert.equal(state.entries.length, 1); + assert.equal(state.entries[0].text, 'Check the list'); + assert.equal(state.entries[0].sequence, 2); +}); +test('tool names and lifecycle exclude arguments and outputs while step reasoning has its own entry', () => { + let state = updateActivityDetails(emptyActivityDetails(), tool('DynamicPlanReceived', { + toolDefinitions: [{ schemaName: 'lookup', displayName: 'Look up a record' }] + }), visible); + state = updateActivityDetails(state, tool('DynamicPlanStepTriggered', { thought: 'Look up the requested record.', arguments: { secret: 'hidden' } }), visible); + state = updateActivityDetails(state, tool('DynamicPlanStepFinished', { output: 'hidden' }), visible); + state = updateActivityDetails(state, tool('DynamicPlanStepTriggered'), visible); + assert.equal(state.entries.find(entry => entry.kind === 'tool').text, 'Look up a record'); + assert.equal(state.entries.find(entry => entry.kind === 'tool').status, 'finished'); + assert.equal(state.entries.find(entry => entry.kind === 'reasoning').text, 'Look up the requested record.'); + assert.ok(!JSON.stringify(state).includes('hidden')); + assert.ok(!JSON.stringify(state).includes('private')); +}); +test('ordinary answer streaming, malformed, and unknown events do not become reasoning or tools', () => { + const state = emptyActivityDetails(); + for (const activity of [null, {}, 'bad', { type: 'typing', text: 'answer', channelData: { streamType: 'streaming' } }, + tool('unknown'), { type: 'message', text: 'Final answer' }]) { + assert.strictEqual(updateActivityDetails(state, activity, visible), state); + } + assert.deepEqual(emptyActivityDetails().entries, []); +}); + +test('numeric step IDs preserve separate invocations of the same tool', () => { + let state = emptyActivityDetails(); + for (const stepId of [0, 1]) { + state = updateActivityDetails(state, tool('DynamicPlanStepTriggered', { stepId }), visible); + } + state = updateActivityDetails(state, tool('DynamicPlanStepFinished', { stepId: 0 }), visible); + assert.deepEqual(state.entries.map(({ id, status }) => ({ id, status })), [ + { id: 'tool:standard:["","0"]', status: 'finished' }, { id: 'tool:standard:["","1"]', status: 'running' } + ]); +}); + +test('unknown tool names cannot resolve to inherited object properties', () => { + const state = updateActivityDetails(emptyActivityDetails(), tool('DynamicPlanStepTriggered', { + taskDialogId: 'toString' + }), visible); + assert.equal(state.entries[0].text, 'toString'); +}); + +for (const showReasoning of [false, true]) { + for (const showToolCalls of [false, true]) { + test(`standard mapping is independent: reasoning=${showReasoning}, tools=${showToolCalls}`, () => { + const flags = { showReasoning, showToolCalls }; + let state = emptyActivityDetails(); + for (const activity of [ + standardPlan(), + standardStep('DynamicPlanStepTriggered', { thought: 'Check the weather for the requested city.', state: 'inProgress' }), + standardStep('DynamicPlanStepBindUpdate', { arguments: { Location: 'PRIVATE_CITY' }, thought: 'PRIVATE_BIND_THOUGHT' }), + standardStep('DynamicPlanStepFinished', { result: 'PRIVATE_RESULT', thought: 'PRIVATE_FINISH_THOUGHT' }) + ]) state = updateActivityDetails(state, activity, flags); + assert.equal(state.entries.filter(entry => entry.kind === 'reasoning').length, Number(showReasoning)); + assert.equal(state.entries.filter(entry => entry.kind === 'tool').length, Number(showToolCalls)); + if (showReasoning) assert.equal(state.entries.find(entry => entry.kind === 'reasoning').text, 'Check the weather for the requested city.'); + if (showToolCalls) { + assert.equal(state.entries.find(entry => entry.kind === 'tool').text, 'Get current weather'); + assert.equal(state.entries.find(entry => entry.kind === 'tool').status, 'finished'); + } + assert.ok(!JSON.stringify(state).includes('PRIVATE_')); + }); + } +} + +test('standard step snapshots replace, deduplicate, and stay separate across steps and plans', () => { + let state = updateActivityDetails(emptyActivityDetails(), standardStep('DynamicPlanStepTriggered', { thought: 'Check Seattle.' }), visible); + const same = updateActivityDetails(state, standardStep('DynamicPlanStepTriggered', { thought: 'Check Seattle.' }), visible); + assert.strictEqual(same, state); + state = updateActivityDetails(state, standardStep('DynamicPlanStepTriggered', { thought: 'Check Seattle in metric units.' }), visible); + state = updateActivityDetails(state, standardStep('DynamicPlanStepTriggered', { stepId: 'second', thought: 'Check London.' }), visible); + state = updateActivityDetails(state, standardStep('DynamicPlanStepTriggered', { planIdentifier: 'another-plan', thought: 'Check Paris.' }), visible); + assert.deepEqual(state.entries.filter(entry => entry.kind === 'reasoning').map(entry => entry.text), + ['Check Seattle in metric units.', 'Check London.', 'Check Paris.']); + assert.equal(new Set(state.entries.map(entry => entry.id)).size, 6); +}); + +test('standard event names are accepted when valueType is absent, but arbitrary thought fields are not', () => { + const event = standardStep('DynamicPlanStepTriggered', { thought: 'Check the weather.' }); + delete event.valueType; + const state = updateActivityDetails(emptyActivityDetails(), event, visible); + assert.equal(state.entries.length, 2); + for (const activity of [ + { type: 'message', name: event.name, value: event.value }, + { type: 'event', name: 'UnknownEvent', value: event.value }, + standardStep('DynamicPlanStepTriggered', { thought: { text: 'PRIVATE_NESTED' } }), + standardStep('DynamicPlanStepTriggered', { thought: ' ' }), + standardStep('DynamicPlanStepTriggered', { thought: 'Uncorrelated', stepId: '', taskDialogId: '' }) + ]) { + const next = updateActivityDetails(emptyActivityDetails(), activity, { showReasoning: true, showToolCalls: false }); + assert.deepEqual(next.entries, []); + } +}); + +test('standard binding updates identify an ongoing tool step without exposing inputs or inventing completion', () => { + let state = updateActivityDetails(emptyActivityDetails(), standardPlan(), visible); + state = updateActivityDetails(state, standardStep('DynamicPlanStepBindUpdate', { arguments: { Location: 'PRIVATE_CITY' } }), visible); + assert.deepEqual(state.entries.map(({ kind, text, status }) => ({ kind, text, status })), + [{ kind: 'tool', text: 'Get current weather', status: 'running' }]); + state = updateActivityDetails(state, standardStep('DynamicPlanStepFinished'), visible); + const late = updateActivityDetails(state, standardStep('DynamicPlanStepBindUpdate'), visible); + assert.strictEqual(late, state); + assert.equal(state.entries[0].status, 'finished'); +}); + +test('standard correlation keeps opaque plan and step identifiers distinct', () => { + let state = emptyActivityDetails(); + for (const [planIdentifier, stepId] of [['a:b', 'c'], ['a', 'b:c'], ['plan', '\uD800']]) { + state = updateActivityDetails(state, standardStep('DynamicPlanStepTriggered', { planIdentifier, stepId }), visible); + } + assert.equal(new Set(state.entries.map(entry => entry.id)).size, 3); +}); diff --git a/ui/embed/sharepoint-customizer/tests/attachments.test.cjs b/ui/embed/sharepoint-customizer/tests/attachments.test.cjs new file mode 100644 index 00000000..5d5280a2 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/attachments.test.cjs @@ -0,0 +1,36 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { MAX_FILE_BYTES, validateFile, prepareAttachments } = require('../temp/tests/components/Chat/attachments'); + +test('supported files include the exact byte limit but not one byte over', () => { + assert.equal(validateFile('REPORT.PDF', MAX_FILE_BYTES), 'application/pdf'); + assert.throws(() => validateFile('report.pdf', MAX_FILE_BYTES + 1), /15 MiB/); + for (const size of [0, -1, NaN, Infinity]) assert.throws(() => validateFile('empty.txt', size), /empty|read/); + assert.throws(() => validateFile('script.exe', 1), /not supported/); +}); +test('local bytes retain name and get a meaningful MIME type instead of the blob transport type', async () => { + const result = await prepareAttachments( + [{ name: 'notes.txt', contentType: 'application/octet-stream', contentUrl: 'blob:local' }], + true, + async () => new Response('a small fixture') + ); + assert.deepEqual(result, [{ + name: 'notes.txt', contentType: 'text/plain', + contentUrl: `data:text/plain;base64,${Buffer.from('a small fixture').toString('base64')}` + }]); +}); +test('multiple local attachments and attachment-only messages need no text', async () => { + const input = ['one.txt', 'two.csv'].map(name => ({ name, contentType: '', contentUrl: `blob:${name}` })); + const result = await prepareAttachments(input, true, async () => new Response('fixture')); + assert.equal(result.length, 2); + assert.equal(result[1].contentType, 'text/csv'); + assert.equal(input[0].contentUrl, 'blob:one.txt'); +}); +test('disabled uploads, remote URLs and unreadable files fail before sending', async () => { + const attachment = { name: 'file.txt', contentType: 'text/plain', contentUrl: 'blob:file' }; + await assert.rejects(prepareAttachments([attachment], false), /disabled/); + await assert.rejects(prepareAttachments([{ ...attachment, contentUrl: 'https://example.com/file' }], true), /local file/); + await assert.rejects(prepareAttachments([attachment], true, async () => new Response('', { status: 404 })), /Could not read/); + await assert.rejects(prepareAttachments([attachment], true, async () => { throw new Error('read failed'); }), /read failed/); + assert.deepEqual(await prepareAttachments([], false), []); +}); diff --git a/ui/embed/sharepoint-customizer/tests/auth.test.cjs b/ui/embed/sharepoint-customizer/tests/auth.test.cjs new file mode 100644 index 00000000..034a5ce4 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/auth.test.cjs @@ -0,0 +1,143 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const msal = require('@azure/msal-browser'); +const { + InteractionRequiredAuthError, BrowserAuthError, BrowserAuthErrorCodes, ServerError +} = msal; +const { acquireToken, SignInRequiredError } = require('../temp/tests/components/Chat/acquireToken'); + +let application = 0; + +function fixture(t, handlers = {}, accounts = []) { + const calls = []; + const settings = { + appClientId: `00000000-0000-4000-8000-${String(++application).padStart(12, '0')}`, + tenantId: '00000000-0000-4000-8000-000000000000', + currentUserLogin: 'user@example.com', + redirectUri: 'https://example.com/sites/review', + scopes: ['https://api.powerplatform.com/CopilotStudio.Copilots.Invoke'] + }; + t.mock.method(console, 'warn', () => {}); + const instance = { getAllAccounts: () => accounts }; + for (const method of ['initialize', 'acquireTokenSilent', 'ssoSilent', 'acquireTokenPopup']) { + instance[method] = async request => { + calls.push({ method, request }); + if (handlers[method]) return handlers[method](request); + if (method === 'initialize') return; + throw new Error(`Unexpected ${method}`); + }; + } + t.mock.method(msal, 'PublicClientApplication', function () { return instance; }); + return { settings, calls, methods: () => calls.map(call => call.method) }; +} + +const timeout = () => new BrowserAuthError(BrowserAuthErrorCodes.monitorWindowTimeout); +const interaction = () => new InteractionRequiredAuthError('interaction_required'); + +test('cached token uses the matching SharePoint user and requested SDK scope', async t => { + const account = { username: 'USER@example.com' }; + const f = fixture(t, { acquireTokenSilent: () => ({ accessToken: 'cached-token' }) }, [ + { username: 'someone-else@example.com' }, account + ]); + assert.equal(await acquireToken(f.settings), 'cached-token'); + assert.deepEqual(f.methods(), ['initialize', 'acquireTokenSilent']); + assert.equal(f.calls[1].request.account, account); + assert.deepEqual(f.calls[1].request.scopes, f.settings.scopes); +}); + +test('uncached user can still sign in silently with the SharePoint login hint', async t => { + const f = fixture(t, { ssoSilent: () => ({ accessToken: 'sso-token' }) }); + assert.equal(await acquireToken(f.settings), 'sso-token'); + assert.deepEqual(f.methods(), ['initialize', 'ssoSilent']); + assert.equal(f.calls[1].request.loginHint, f.settings.currentUserLogin); +}); + +for (const failure of [timeout, interaction]) { + for (const cached of [false, true]) { + test(`${failure().errorCode} offers sign-in without another iframe or automatic popup (cached=${cached})`, async t => { + const method = cached ? 'acquireTokenSilent' : 'ssoSilent'; + const f = fixture(t, { [method]: () => { throw failure(); } }, + cached ? [{ username: 'user@example.com' }] : []); + await assert.rejects(acquireToken(f.settings), SignInRequiredError); + assert.deepEqual(f.methods(), ['initialize', method]); + }); + } +} + +test('explicit sign-in goes directly to the popup after an iframe timeout', async t => { + const f = fixture(t, { + ssoSilent: () => { throw timeout(); }, + acquireTokenPopup: () => ({ accessToken: 'interactive-token' }) + }); + await assert.rejects(acquireToken(f.settings), SignInRequiredError); + assert.equal(await acquireToken(f.settings, true), 'interactive-token'); + assert.deepEqual(f.methods(), ['initialize', 'ssoSilent', 'acquireTokenPopup']); + assert.deepEqual(f.calls[2].request, { + scopes: f.settings.scopes, loginHint: f.settings.currentUserLogin + }); +}); + +test('popup cancellation is surfaced and an explicit retry does not repeat silent sign-in', async t => { + const failure = new BrowserAuthError(BrowserAuthErrorCodes.userCancelled); + let cancelled = true; + const f = fixture(t, { + acquireTokenPopup: () => { + if (cancelled) throw failure; + return { accessToken: 'retry-token' }; + } + }); + await assert.rejects(acquireToken(f.settings, true), error => error === failure); + cancelled = false; + assert.equal(await acquireToken(f.settings, true), 'retry-token'); + assert.deepEqual(f.methods(), ['initialize', 'acquireTokenPopup', 'acquireTokenPopup']); +}); + +test('unexpected silent errors are preserved instead of disguised as sign-in requests', async t => { + const failure = new Error('Authority configuration is invalid'); + const f = fixture(t, { ssoSilent: () => { throw failure; } }); + await assert.rejects(acquireToken(f.settings), error => error === failure); + assert.deepEqual(f.methods(), ['initialize', 'ssoSilent']); +}); + +test('Entra server errors retain their full AADSTS message', async t => { + const failure = new ServerError('invalid_request', + 'AADSTS50011: The redirect URI https://example.com/sites/review does not match the configured redirect URIs.'); + const f = fixture(t, { ssoSilent: () => { throw failure; } }); + await assert.rejects(acquireToken(f.settings), error => error === failure && error.message.includes('AADSTS50011')); +}); + +test('interaction-required failures retain the original Entra details when offering sign-in', async t => { + const failure = new InteractionRequiredAuthError('consent_required', + 'AADSTS65001: The user or administrator has not consented to use the application.'); + const f = fixture(t, { ssoSilent: () => { throw failure; } }); + await assert.rejects(acquireToken(f.settings), error => + error instanceof SignInRequiredError && error.cause === failure && error.message === failure.message); +}); + +test('an unrelated cached account is not used for the current SharePoint user', async t => { + const f = fixture(t, { ssoSilent: () => ({ accessToken: 'current-user-token' }) }, [ + { username: 'someone-else@example.com' } + ]); + assert.equal(await acquireToken(f.settings), 'current-user-token'); + assert.deepEqual(f.methods(), ['initialize', 'ssoSilent']); +}); + +test('no account or login hint offers sign-in without an invalid silent SSO request', async t => { + const f = fixture(t); + delete f.settings.currentUserLogin; + await assert.rejects(acquireToken(f.settings), SignInRequiredError); + assert.deepEqual(f.methods(), ['initialize']); +}); + +test('failed MSAL initialization can be retried', async t => { + const failure = new Error('Initialization unavailable'); + let failed = true; + const f = fixture(t, { + initialize: () => { if (failed) throw failure; }, + ssoSilent: () => ({ accessToken: 'retry-token' }) + }); + await assert.rejects(acquireToken(f.settings), error => error === failure); + failed = false; + assert.equal(await acquireToken(f.settings), 'retry-token'); + assert.deepEqual(f.methods(), ['initialize', 'initialize', 'ssoSilent']); +}); diff --git a/ui/embed/sharepoint-customizer/tests/configuration.test.cjs b/ui/embed/sharepoint-customizer/tests/configuration.test.cjs new file mode 100644 index 00000000..6b6b32ad --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/configuration.test.cjs @@ -0,0 +1,63 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeConfiguration } = require('../temp/tests/configuration'); +const { createSideloadUrl, COMPONENT_ID } = require('../scripts/sideload.cjs'); +const properties = { appClientId: 'client', tenantId: 'tenant', environmentId: 'environment', schemaName: 'agent' }; + +test('discovery settings default uploads and typing on, details off', () => { + assert.deepEqual(normalizeConfiguration(properties), { + ...properties, directConnectUrl: undefined, startConversation: false, showTyping: true, enableFileUpload: true, + showReasoning: false, showToolCalls: false, + starterPrompts: [{ title: 'Get started', text: 'What can you help me with?' }] + }); +}); +test('legacy identifier remains supported, canonical schema takes precedence', () => { + assert.equal(normalizeConfiguration({ ...properties, schemaName: undefined, agentIdentifier: 'legacy' }).schemaName, 'legacy'); + assert.equal(normalizeConfiguration({ ...properties, agentIdentifier: 'legacy' }).schemaName, 'agent'); +}); +test('explicit direct URLs remain supported without environment or schema', () => { + assert.equal(normalizeConfiguration({ appClientId: 'c', tenantId: 't', directConnectUrl: 'https://example.com/bot' }).directConnectUrl, 'https://example.com/bot'); + assert.throws(() => normalizeConfiguration({ ...properties, directConnectUrl: 'http://example.com' }), /HTTPS/); +}); +test('missing properties and malformed flags fail visibly', () => { + assert.throws(() => normalizeConfiguration({ ...properties, tenantId: ' ' }), /tenantId/); + assert.throws(() => normalizeConfiguration({ ...properties, schemaName: '' }), /schemaName/); + assert.throws(() => normalizeConfiguration({ ...properties, showReasoning: 'false' }), /boolean|true or false/); +}); +test('debug URL preserves page parameters and round-trips JSON exactly', () => { + const config = { ...properties, agentTitle: 'Help & files #1', startConversation: true, showReasoning: true, showToolCalls: false, + starterPrompts: [{ title: 'A & B', text: 'Explain A & B.' }] }; + const url = new URL(createSideloadUrl('https://example.sharepoint.com/sites/dev/?view=1', config)); + assert.equal(url.searchParams.get('view'), '1'); + assert.equal(url.searchParams.get('loadSPFX'), 'true'); + assert.equal(url.searchParams.get('debugManifestsFile'), 'https://localhost:4321/temp/build/manifests.js'); + assert.deepEqual(JSON.parse(url.searchParams.get('customActions'))[COMPONENT_ID], { + location: 'ClientSideExtension.ApplicationCustomizer', properties: config + }); + assert.equal(JSON.parse(url.searchParams.get('customActions'))[COMPONENT_ID].properties.directConnectUrl, undefined); +}); +test('automatic conversation start is opt-in and requires a boolean', () => { + assert.equal(normalizeConfiguration(properties).startConversation, false); + assert.equal(normalizeConfiguration({ ...properties, startConversation: true }).startConversation, true); + assert.equal(normalizeConfiguration({ ...properties, startConversation: false }).startConversation, false); + for (const startConversation of [null, 'false', 0, {}]) { + assert.throws(() => normalizeConfiguration({ ...properties, startConversation }), /startConversation/); + assert.throws(() => createSideloadUrl('https://example.com', { ...properties, startConversation }), /startConversation/); + } +}); +test('starter prompts accept custom text or an empty list, and reject malformed or extra fields', () => { + assert.deepEqual(normalizeConfiguration({ ...properties, starterPrompts: [] }).starterPrompts, []); + assert.deepEqual(normalizeConfiguration({ ...properties, starterPrompts: [{ title: ' Start ', text: ' Help me ' }] }).starterPrompts, + [{ title: 'Start', text: 'Help me' }]); + for (const starterPrompts of [null, 'text', [{}], [{ title: 'Start', text: '' }], + [{ title: 'Start', text: 'Hello', clientSecret: 'not-allowed' }]]) { + assert.throws(() => normalizeConfiguration({ ...properties, starterPrompts }), /starter prompt|starterPrompts/); + assert.throws(() => createSideloadUrl('https://example.com', { ...properties, starterPrompts }), /starterPrompts/); + } +}); +test('debug URL rejects credentials, unknown properties, missing settings, and non-HTTPS manifests', () => { + assert.throws(() => createSideloadUrl('https://example.com', { ...properties, accessToken: 'secret' }), /Unknown property/); + assert.throws(() => createSideloadUrl('https://example.com', {}), /Provide/); + assert.throws(() => createSideloadUrl('https://user:password@example.com', properties), /credentials/); + assert.throws(() => createSideloadUrl('https://example.com', properties, 'http://localhost:4321/manifest.js'), /HTTPS/); +}); diff --git a/ui/embed/sharepoint-customizer/tests/connection.test.cjs b/ui/embed/sharepoint-customizer/tests/connection.test.cjs new file mode 100644 index 00000000..f3a53917 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/connection.test.cjs @@ -0,0 +1,271 @@ +const { test, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const { Observable, BehaviorSubject, Subject, take } = require('rxjs'); +const { Activity } = require('@microsoft/agents-activity'); +const { CopilotStudioWebChat } = require('@microsoft/agents-copilotstudio-client'); +const { createConnection, wrapConnection } = require('../temp/tests/components/Chat/createConnection'); +const { normalizeConfiguration } = require('../temp/tests/configuration'); + +function fixture(postActivity, visibility = {}, initializeConversation) { + const received = new Subject(); + const failures = []; + let starts = 0; + let ends = 0; + let subscriptions = 0; + const base = { + connectionStatus$: new BehaviorSubject(2), + activity$: new Observable(subscriber => { + subscriptions++; + return received.subscribe(subscriber); + }), + postActivity, + end() { received.complete(); }, + conversationId: undefined + }; + const connection = wrapConnection(base, { + startConversation: false, enableFileUpload: true, showTyping: false, + showReasoning: false, showToolCalls: false, starterPrompts: [], ...visibility + }, { + onError(error) { failures.push(error.message); }, + onTurn() { starts++; }, + onTurnEnd() { ends++; } + }, initializeConversation); + return { connection, received, failures, counts: () => ({ starts, ends, subscriptions }) }; +} + +test('both opening modes pass their startConversation setting to the SDK', () => { + const base = { + activity$: new Subject(), connectionStatus$: new BehaviorSubject(2), + postActivity: () => new Observable(), end() {} + }; + const create = mock.method(CopilotStudioWebChat, 'createConnection', () => base); + try { + for (const startConversation of [undefined, true, false]) { + const configuration = normalizeConfiguration({ + appClientId: 'client', tenantId: 'tenant', environmentId: 'environment', schemaName: 'agent', startConversation + }); + const connection = createConnection({}, configuration, { onTurn() {}, onTurnEnd() {}, onError() {} }); + assert.deepEqual(create.mock.calls.at(-1).arguments[1], { showTyping: true, startConversation: startConversation === true }); + connection.end(); + } + } finally { + create.mock.restore(); + } +}); + +test('automatic start hides local starter cards without dropping the agent greeting', () => { + const f = fixture(() => new Observable(), { startConversation: true }); + const visible = []; + const subscription = f.connection.activity$.subscribe(activity => visible.push(activity)); + assert.deepEqual(visible, []); + f.received.next({ type: 'message', id: 'greeting', text: 'Welcome from the agent' }); + assert.deepEqual(visible.map(activity => activity.text), ['Welcome from the agent']); + subscription.unsubscribe(); + f.connection.end(); +}); + +test('response errors are still observed after WebChat takes the first send acknowledgement', async () => { + let response; + const f = fixture(() => new Observable(subscriber => { + response = subscriber; + subscriber.next('sent-id'); + })); + const acknowledgements = []; + f.connection.postActivity(new Activity('message')).pipe(take(1)).subscribe(id => acknowledgements.push(id)); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(acknowledgements, ['sent-id']); + assert.equal(response.closed, false); + response.error(new Error('Service response failed')); + assert.deepEqual(f.failures, ['Service response failed']); + assert.equal(f.counts().ends, 1); + f.connection.end(); +}); + +test('single incoming subscription filters private detail events but preserves answer streaming', () => { + const f = fixture(() => new Observable()); + const visible = []; + const subscription = f.connection.activity$.subscribe(activity => visible.push(activity)); + f.received.next({ type: 'typing', text: 'private progress', channelData: { streamType: 'informative' } }); + f.received.next({ type: 'event', valueType: 'DynamicPlanStepTriggered', value: { arguments: 'private' } }); + f.received.next({ type: 'typing' }); + f.received.next({ type: 'typing', text: 'Partial answer', channelData: { streamType: 'streaming' } }); + f.received.next({ type: 'message', id: 'final', text: 'Final answer' }); + assert.equal(f.counts().subscriptions, 1); + assert.deepEqual(visible.map(activity => activity.text), ['', 'Partial answer', 'Final answer']); + subscription.unsubscribe(); + f.connection.end(); +}); + +test('ending the connection prevents late activity and releases outstanding response subscriptions', async () => { + let response; + const f = fixture(() => new Observable(subscriber => { + response = subscriber; + subscriber.next('sent'); + })); + f.connection.postActivity(new Activity('message')).pipe(take(1)).subscribe(); + await new Promise(resolve => setImmediate(resolve)); + f.connection.end(); + assert.equal(response.closed, true); + response.error(new Error('Late failure')); + assert.deepEqual(f.failures, []); +}); + +test('startup messages are replayed to the transcript with one SDK subscription and no private replay', () => { + const f = fixture(() => new Observable()); + const startup = f.connection.activity$.subscribe(); + f.received.next({ type: 'message', text: 'Welcome' }); + f.received.next({ type: 'typing', text: 'Progress', channelData: { streamType: 'informative' } }); + f.received.next({ type: 'event', name: 'turn.complete' }); + const transcript = []; + const subscription = f.connection.activity$.subscribe(activity => transcript.push(activity)); + assert.equal(f.counts().subscriptions, 1); + assert.deepEqual(transcript.map(activity => activity.text), ['', 'Welcome']); + assert.ok(transcript[0].id); + assert.ok(transcript[1].id); + startup.unsubscribe(); + f.received.next({ type: 'message', text: 'Still connected' }); + assert.equal(transcript.length, 3); + subscription.unsubscribe(); + f.connection.end(); +}); + +test('native activity groups finish when the SDK response completes without a turn.complete event', async () => { + let response; + const f = fixture(() => new Observable(subscriber => { + response = subscriber; + subscriber.next('sent-id'); + }), { showReasoning: true }); + const visible = []; + const subscription = f.connection.activity$.subscribe(activity => visible.push(activity)); + f.connection.postActivity(new Activity('message')).pipe(take(1)).subscribe(); + await new Promise(resolve => setImmediate(resolve)); + f.received.next({ + type: 'typing', entities: [{ type: 'thought', text: 'Checking the request', sequenceNumber: 0 }], + channelData: { streamType: 'streaming', streamId: 'thought-stream', streamSequence: 1, chunkType: 'delta' } + }); + response.complete(); + const details = visible.filter(activity => activity.id.includes(':reasoning:')); + assert.equal(details.length, 2); + assert.equal(details[0].entities[0].isPartOf.creativeWorkStatus, 'Incomplete'); + assert.equal(details[1].entities[0].isPartOf.creativeWorkStatus, 'Published'); + assert.equal(details[0].id, details[1].id); + subscription.unsubscribe(); + f.connection.end(); +}); + +test('response failure stops native progress without claiming success', async () => { + let response; + const f = fixture(() => new Observable(subscriber => { + response = subscriber; + subscriber.next('sent-id'); + }), { showToolCalls: true }); + const visible = []; + const subscription = f.connection.activity$.subscribe(activity => visible.push(activity)); + f.connection.postActivity(new Activity('message')).pipe(take(1)).subscribe(); + await new Promise(resolve => setImmediate(resolve)); + f.received.next({ type: 'event', valueType: 'DynamicPlanStepTriggered', value: { taskDialogId: 'lookup', stepId: 1 } }); + response.error(new Error('Response interrupted')); + const last = visible[visible.length - 1]; + assert.equal(last.entities[0].isPartOf.creativeWorkStatus, 'Failed'); + assert.equal(last.entities[0].creativeWorkStatus, undefined); + assert.equal(last.text, 'No finish event received.'); + assert.deepEqual(f.failures, ['Response interrupted']); + subscription.unsubscribe(); + f.connection.end(); +}); + +test('cancellation before acknowledgment releases the busy state and response subscription', async () => { + let response; + const f = fixture(() => new Observable(subscriber => { response = subscriber; })); + const subscription = f.connection.postActivity(new Activity('message')).subscribe(); + await new Promise(resolve => setImmediate(resolve)); + subscription.unsubscribe(); + assert.equal(response.closed, true); + assert.deepEqual(f.counts(), { starts: 1, ends: 1, subscriptions: 0 }); + f.connection.end(); +}); + +test('synchronous send failures are surfaced and release the busy state', async () => { + const f = fixture(() => { throw new Error('Cannot send'); }); + const failures = []; + f.connection.postActivity(new Activity('message')).subscribe({ error: error => failures.push(error.message) }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(failures, ['Cannot send']); + assert.deepEqual(f.failures, ['Cannot send']); + assert.equal(f.counts().ends, 1); + f.connection.end(); +}); + +test('startup failures use DirectLine failed status instead of an unhandled observable error', () => { + const f = fixture(() => new Observable()); + let completed = false; + f.connection.activity$.subscribe({ complete: () => { completed = true; } }); + f.received.error(new Error('Discovery unavailable')); + assert.deepEqual(f.failures, ['Discovery unavailable']); + assert.equal(f.connection.connectionStatus$.value, 4); + assert.equal(completed, true); + f.connection.end(); +}); + +test('attachment preparation holds busy state and cancellation prevents a late send', async t => { + let resolveRead; + t.mock.method(globalThis, 'fetch', () => new Promise(resolve => { resolveRead = resolve; })); + let sends = 0; + const f = fixture(() => { sends++; return new Observable(); }); + const subscription = f.connection.postActivity({ + type: 'message', attachments: [{ name: 'notes.txt', contentType: 'text/plain', contentUrl: 'blob:pending' }] + }).subscribe(); + assert.equal(f.counts().starts, 1); + subscription.unsubscribe(); + assert.equal(f.counts().ends, 1); + resolveRead(new Response('fixture')); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(sends, 0); + assert.deepEqual(f.failures, []); + f.connection.end(); +}); + +test('attachment validation failure is reported and releases busy state without sending', async () => { + let initialized = false; + const f = fixture(() => { throw new Error('Must not send'); }, {}, async () => { initialized = true; }); + f.connection.postActivity({ + type: 'message', attachments: [{ name: 'notes.txt', contentType: 'text/plain', contentUrl: 'https://example.com/file' }] + }).subscribe({ error() {} }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.counts().ends, 1); + assert.equal(initialized, false); + assert.match(f.failures[0], /local file/); + f.connection.end(); +}); + +test('lazy initialization failure is reported and releases busy state without sending the message', async () => { + let sends = 0; + const f = fixture(() => { sends++; return new Observable(); }, {}, async () => { throw new Error('Cannot create conversation'); }); + const failures = []; + f.connection.postActivity(new Activity('message')).subscribe({ error: error => failures.push(error.message) }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(failures, ['Cannot create conversation']); + assert.deepEqual(f.failures, failures); + assert.equal(f.counts().ends, 1); + assert.equal(sends, 0); + f.connection.end(); +}); + +for (const closeConnection of [false, true]) { + test(`cancellation during lazy initialization prevents a late send (closeConnection=${closeConnection})`, async () => { + let initialize; + let sends = 0; + const f = fixture(() => { sends++; return new Observable(); }, {}, () => new Promise(resolve => { initialize = resolve; })); + const subscription = f.connection.postActivity(new Activity('message')).subscribe(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.counts().starts, 1); + if (closeConnection) f.connection.end(); + else subscription.unsubscribe(); + initialize(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(sends, 0); + assert.deepEqual(f.failures, []); + subscription.unsubscribe(); + f.connection.end(); + }); +} diff --git a/ui/embed/sharepoint-customizer/tests/deploy.test.cjs b/ui/embed/sharepoint-customizer/tests/deploy.test.cjs new file mode 100644 index 00000000..47c008c3 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/deploy.test.cjs @@ -0,0 +1,438 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { zipSync, strToU8 } = require('fflate'); +const { + loadOptions, elementsXml, inspectPackage, packageFingerprint, runNode, + buildPackage, cli, deploy, matchingActions, compareVersions, poll +} = require('../scripts/deploy.cjs'); +const { validateProperties } = require('../scripts/properties.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const CURRENT_VERSION = require('../config/package-solution.json').solution.version; +const CLIENT = '11111111-1111-4111-8111-111111111111'; +const CATALOG_ID = '44444444-4444-4444-8444-444444444444'; +const ACTION_ID = '55555555-5555-4555-8555-555555555555'; +const SITE = 'https://contoso.sharepoint.com/sites/team'; +const CATALOG = 'https://contoso.sharepoint.com/sites/apps'; +const args = [ + '--site-url', SITE, '--app-catalog-url', CATALOG, '--app-client-id', CLIENT, + '--properties', 'tests/fixtures/deployment.json' +]; +const options = extra => loadOptions([...args, ...extra], ROOT); +const quiet = () => {}; + +async function temporary(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "sidepane tests ' & é-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + return directory; +} + +function packageEntries(config) { + const manifest = JSON.stringify({ + id: config.componentId, + loaderConfig: { + internalModuleBaseUrls: ['HTTPS://SPCLIENTSIDEASSETLIBRARY/'], + scriptResources: { main: { type: 'path', path: 'main.js' } } + } + }).replace(/"/g, '"'); + return { + 'AppManifest.xml': ``, + [`feature_${config.featureId}.xml`]: ``, + [`${config.featureId}/elements.xml`]: elementsXml(config), + [`${config.featureId}/Extension.xml`]: ``, + 'ClientSideAssets.xml': ``, + 'ClientSideAssets.xml.config.xml': `${randomUUID()}`, + 'ClientSideAssets/main.js': 'console.log("synthetic fixture");' + }; +} + +function archive(entries) { + return zipSync(Object.fromEntries(Object.entries(entries).map(([name, value]) => [name, strToU8(value)]))); +} + +function metadata(config, extra = {}) { + return { + ID: CATALOG_ID, ProductId: config.productId, AppCatalogVersion: config.version, + InstalledVersion: '', Deployed: true, CurrentVersionDeployed: true, + ContainsTenantWideExtension: false, SkipDeploymentFeature: false, + IsPackageDefaultSkipFeatureDeployment: false, IsValidAppPackage: true, + IsClientSideSolution: true, ...extra + }; +} + +function action(config, extra = {}) { + return { + Id: ACTION_ID, ClientSideComponentId: config.componentId, + Location: 'ClientSideExtension.ApplicationCustomizer', Scope: 3, + ClientSideComponentProperties: JSON.stringify(config.properties), ...extra + }; +} + +async function scenario(t, settings = {}) { + const config = { ...await options([]), ...settings.options }; + config.packagePath = path.join(await temporary(t), 'sidebar-agent.sppkg'); + const buffer = archive(packageEntries(config)); + const calls = []; + const state = { + app: settings.existing ? metadata(config, settings.existing) : undefined, + installed: settings.installed || '', + actions: settings.actions || (settings.installed ? [action(config)] : []), + builds: 0, siteReads: 0 + }; + const command = async commandArgs => { + calls.push(commandArgs); + const name = commandArgs.slice(0, commandArgs.indexOf('--output') > -1 ? commandArgs.indexOf('--output') : undefined).join(' '); + if (settings.fail && name.startsWith(settings.fail)) throw new Error('403 Access denied (fixture)'); + if (name === 'status') return settings.status === undefined ? { connectedAs: 'fixture', appTenant: config.properties.tenantId } : settings.status; + if (name === 'spo tenant appcatalogurl get') return settings.catalog || CATALOG; + if (name === 'spo tenant applicationcustomizer list') return settings.tenantActions || []; + if (name.startsWith('spo customaction list')) return state.actions; + if (name.startsWith('spo app instance list')) { + return state.pendingInstall && settings.installFailure + ? [{ ProductId: config.productId, AppId: ACTION_ID, AppStatus: 6 }] + : []; + } + if (name.startsWith('spo app list')) return state.app ? [state.app] : []; + if (name.startsWith('request')) { + const url = commandArgs[commandArgs.indexOf('--url') + 1]; + if (url.endsWith('/_api/web?$select=Url')) return { Url: SITE }; + if (url.includes('/GetErrorDetails()')) return { value: [{ ExceptionMessage: 'Feature XML validation failed (fixture).' }] }; + if (url.includes('/Files?')) return { value: settings.files || [] }; + if (url.includes('/GetFileById(')) { + await fs.writeFile(commandArgs[commandArgs.indexOf('--filePath') + 1], settings.remotePackage || buffer); + return undefined; + } + if (url.includes('/AvailableApps/GetById(')) { + state.siteReads++; + if (state.pendingInstall && state.siteReads >= state.pendingInstall && !settings.installFailure) { + state.installed = config.version; + state.actions = [action(config)]; + } + return metadata(config, { ...state.app, InstalledVersion: state.installed }); + } + } + if (name.startsWith('spo app add')) { + state.app = metadata(config, { Deployed: false, CurrentVersionDeployed: false }); + // The upload response is NOT the product ID and is deliberately ignored. + return { UniqueId: randomUUID(), Name: 'sidebar-agent.sppkg' }; + } + if (name.startsWith('spo app deploy')) { + state.app.Deployed = state.app.CurrentVersionDeployed = true; + return undefined; + } + if (name.startsWith('spo app install') || name.startsWith('spo app upgrade')) { + state.pendingInstall = state.siteReads + (settings.installDelay || 1); + return undefined; + } + if (name.startsWith('spo customaction set')) { + state.actions = [action(config, { ClientSideComponentProperties: commandArgs[commandArgs.indexOf('--clientSideComponentProperties') + 1] })]; + return undefined; + } + throw new Error(`Unexpected fixture command: ${name}`); + }; + const build = async () => { + state.builds++; + if (settings.buildFailure) throw new Error('Build failed (fixture)'); + await fs.writeFile(config.packagePath, buffer); + return inspectPackage(buffer, config); + }; + const run = () => deploy(config, { command, build, log: quiet, polling: { timeout: 200, interval: 0 } }); + const mutations = () => calls.filter(call => + ['spo app add', 'spo app deploy', 'spo app install', 'spo app upgrade', 'spo customaction set'].some(prefix => call.join(' ').startsWith(prefix))); + return { config, state, calls, run, mutations }; +} + +test('deployment loads supported settings and explicitly overrides the runtime app ID', async () => { + const config = await options([]); + assert.equal(config.properties.appClientId, CLIENT); + assert.equal(config.properties.showReasoning, true); + assert.equal(config.properties.showToolCalls, false); + assert.equal(config.siteUrl, SITE); + assert.equal(config.componentId, '4c6e29f2-7eee-4f9f-bbd2-20c8859d0ba2'); + assert.equal((await loadOptions(['--help'])).help, true); + assert.equal((await options(['--site-url', `${SITE}/`])).siteUrl, SITE); +}); + +for (const [name, extra, error] of [ + ['unknown option', ['--secret', 'bad'], /Unknown option/], + ['runtime client placeholder', ['--app-client-id', 'YOUR_APP_ID'], /GUID/], + ['HTTP site', ['--site-url', 'http://contoso.sharepoint.com/sites/team'], /HTTPS/], + ['non-SharePoint site', ['--site-url', 'https://example.com'], /SharePoint/], + ['URL credentials', ['--site-url', 'https://user:password@contoso.sharepoint.com'], /credentials/], + ['debug URL', ['--site-url', `${SITE}?loadSPFX=true`], /debug/], + ['page URL', ['--site-url', `${SITE}/SitePages/Home.aspx`], /page/], + ['other tenant catalog', ['--app-catalog-url', 'https://other.sharepoint.com/sites/apps'], /same SharePoint host/], + ['mutually exclusive modes', ['--dry-run', '--package-only'], /not both/] +]) { + test(`deployment rejects ${name}`, async () => assert.rejects(options(extra), error)); +} + +test('deployment requires explicit client ID/catalog but offline modes need no catalog', async () => { + const without = flag => args.filter((_, index) => args[index] !== flag && args[index - 1] !== flag); + await assert.rejects(loadOptions(without('--app-client-id'), ROOT), /--app-client-id must be a GUID/); + await assert.rejects(loadOptions(without('--app-catalog-url'), ROOT), /required for live/); + assert.equal((await loadOptions([...without('--app-catalog-url'), '--package-only'], ROOT)).packageOnly, true); + assert.equal((await loadOptions([...without('--app-catalog-url'), '--dry-run'], ROOT)).dryRun, true); +}); + +test('properties validator rejects credentials, bad types, malformed prompts and unsafe URLs', async () => { + const config = await options([]); + for (const extra of [ + { clientSecret: 'not-a-real-secret' }, { showReasoning: 'false' }, { agentTitle: 4 }, + { starterPrompts: [{ title: '', text: 'hello' }] }, { directConnectUrl: 'http://example.com' }, + { directConnectUrl: 'https://user:password@example.com' } + ]) assert.throws(() => validateProperties({ ...config.properties, ...extra })); +}); + +test('deployment rejects malformed JSON, placeholders, arrays and unknown properties from disk', async t => { + const file = path.join(await temporary(t), 'settings.json'); + for (const value of ['{', '[]', '{"tenantId":"YOUR_TENANT_ID"}', '{"token":"never"}']) { + await fs.writeFile(file, value); + await assert.rejects(options(['--properties', file])); + } +}); + +test('XML/package inspection round-trips special characters and rejects bad content', async () => { + const config = await options([]); + const entries = packageEntries(config); + assert.match(elementsXml(config), /&/); + assert.match(elementsXml(config), /</); + assert.match(elementsXml(config), /'/); + assert.doesNotMatch(elementsXml(config), /\sName="/); + assert.equal(inspectPackage(archive(entries), config).entries, Object.keys(entries).length); + const rejects = (changes, expected) => assert.throws(() => inspectPackage(archive({ ...entries, ...changes }), config), expected); + rejects({ 'tenant.xml': '' }, /tenant-wide/); + rejects({ [`${config.featureId}/elements.xml`]: elementsXml(config).replace('' }, /identity/); + rejects({ [`${config.featureId}/elements.xml`]: elementsXml({ ...config, properties: { ...config.properties, showToolCalls: true } }) }, /properties/); + rejects({ [`${config.featureId}/Extension.xml`]: entries[`${config.featureId}/Extension.xml`].replace('HTTPS://SPCLIENTSIDEASSETLIBRARY/', 'https://localhost:4321/') }, /localhost/); + rejects({ [`feature_${config.featureId}.xml`]: entries[`feature_${config.featureId}.xml`].replace('Scope="Web"', 'Scope="Site"') }, /identity/); + delete entries['ClientSideAssets/main.js']; + assert.throws(() => inspectPackage(archive(entries), config), /assets/); +}); + +test('package reuse ignores ZIP timestamps and generated packaging IDs, not code or properties', async () => { + const config = await options([]); + const first = archive(packageEntries(config)); + const nextEntries = packageEntries(config); + assert.equal(packageFingerprint(first), packageFingerprint(archive(nextEntries))); + nextEntries['ClientSideAssets/main.js'] += '\n// changed'; + assert.notEqual(packageFingerprint(first), packageFingerprint(archive(nextEntries))); + const changed = packageEntries({ ...config, properties: { ...config.properties, showReasoning: false } }); + assert.notEqual(packageFingerprint(first), packageFingerprint(archive(changed))); +}); + +test('version comparison is numeric and refuses malformed/out-of-range versions', () => { + assert.equal(compareVersions('1.10.0.0', '1.9.0.0'), 1); + assert.equal(compareVersions('1.0.0.0', '1.0.0.0'), 0); + assert.equal(compareVersions('1.0.0.0', '2.0.0.0'), -1); + assert.throws(() => compareVersions('1.0', '1.0.0.0'), /Invalid/); + assert.throws(() => compareVersions('65536.0.0.0', '1.0.0.0'), /Invalid/); +}); + +test('Node subprocess arguments preserve quotes, Unicode and shell metacharacters literally', async t => { + const directory = await temporary(t); + const file = path.join(directory, "runner ' & é.cjs"); + await fs.writeFile(file, 'console.log(JSON.stringify(process.argv.slice(2)));'); + const inputs = ['spaces here', '"quotes"', 'é', '$HOME', '$(echo injected)', '& echo injected', '{"title":"A & B"}']; + assert.deepEqual(JSON.parse(await runNode(file, inputs, { cwd: directory })), inputs); + await assert.rejects(runNode(file, [], { cwd: path.join(directory, 'missing') })); + await fs.writeFile(file, 'console.error("intentional failure"); process.exit(2);'); + await assert.rejects(runNode(file, []), /exited 2.*intentional failure/); + await fs.writeFile(file, 'setTimeout(() => {}, 10000);'); + await assert.rejects(runNode(file, [], { timeout: 50 }), /timed out/); +}); + +test('CLI wrapper uses the local JS entry, JSON output, and fails on invalid JSON', async () => { + const value = await cli(['status'], async (entry, commandArgs) => { + assert.match(entry.replace(/\\/g, '/'), /@pnp\/cli-microsoft365\/dist\/index\.js$/); + assert.deepEqual(commandArgs, ['status', '--output', 'json']); + return '"Logged out"'; + }); + assert.equal(value, 'Logged out'); + assert.equal(await cli(['spo', 'app', 'deploy'], async () => ''), undefined); + await assert.rejects(cli(['status'], async () => 'not JSON'), /invalid JSON/); + await assert.rejects(cli(['status'], async () => { throw new Error('denied'); }), /denied/); +}); + +for (const fail of [false, true]) { + test(`isolated staging preserves source and dependencies and cleans up on ${fail ? 'failure' : 'success'}`, async t => { + const root = await temporary(t); + const config = { ...await options([]), root, packagePath: path.join(root, 'sharepoint/solution/sidebar-agent.sppkg') }; + for (const name of ['src', 'config', 'sharepoint/assets', 'node_modules']) await fs.mkdir(path.join(root, name), { recursive: true }); + for (const name of [ + 'package.json', 'tsconfig.json', 'gulpfile.js', '.eslintrc.js', 'sharepoint/assets/elements.xml', 'node_modules/keep.txt', + 'config/config.json', 'config/package-solution.json', 'config/sass.json', 'config/write-manifests.json', 'config/deploy-azure-storage.json' + ]) { + await fs.writeFile(path.join(root, name), 'original'); + } + await fs.writeFile(path.join(root, 'private.local.json'), 'must not be staged'); + let staging; + const run = async (entry, commandArgs, settings) => { + staging = settings.cwd; + assert.notEqual(staging, root); + assert.equal(await fs.readFile(path.join(staging, 'sharepoint/assets/elements.xml'), 'utf8'), elementsXml(config)); + await assert.rejects(fs.access(path.join(staging, 'private.local.json')), { code: 'ENOENT' }); + await assert.rejects(fs.access(path.join(staging, 'config/deploy-azure-storage.json')), { code: 'ENOENT' }); + assert.equal(await fs.readFile(path.join(staging, 'node_modules/keep.txt'), 'utf8'), 'original'); + assert.ok(['bundle', 'package-solution'].includes(commandArgs[0])); + assert.equal(commandArgs[1], '--ship'); + if (fail) throw new Error('intentional build failure'); + if (commandArgs[0] === 'package-solution') { + await fs.mkdir(path.join(staging, 'sharepoint/solution'), { recursive: true }); + await fs.writeFile(path.join(staging, 'sharepoint/solution/sidebar-agent.sppkg'), archive(packageEntries(config))); + } + }; + if (fail) await assert.rejects(buildPackage(config, { run, log: quiet }), /intentional/); + else await buildPackage(config, { run, log: quiet }); + await assert.rejects(fs.access(staging), { code: 'ENOENT' }); + assert.equal(await fs.readFile(path.join(root, 'sharepoint/assets/elements.xml'), 'utf8'), 'original'); + assert.equal(await fs.readFile(path.join(root, 'node_modules/keep.txt'), 'utf8'), 'original'); + if (fail) await assert.rejects(fs.access(config.packagePath), { code: 'ENOENT' }); + else inspectPackage(await fs.readFile(config.packagePath), config); + }); +} + +test('dry run does not build or call CLI; package-only does not call CLI', async t => { + const dry = await scenario(t, { options: { dryRun: true } }); + await dry.run(); + assert.equal(dry.state.builds, 0); + assert.deepEqual(dry.calls, []); + const offline = await scenario(t, { options: { packageOnly: true } }); + await offline.run(); + assert.equal(offline.state.builds, 1); + assert.deepEqual(offline.calls, []); +}); + +test('first install uses the catalog ID, feature activation and verified asynchronous readback', async t => { + const fixture = await scenario(t, { installDelay: 3 }); + const report = await fixture.run(); + assert.equal(report.customActionId, ACTION_ID); + assert.equal(report.catalogAppId, CATALOG_ID); + assert.equal(report.version, fixture.config.version); + assert.deepEqual(report.properties, fixture.config.properties); + assert.deepEqual(fixture.mutations().map(call => call.slice(0, 3).join(' ')), ['spo app add', 'spo app deploy', 'spo app install']); + assert.ok(fixture.state.siteReads >= 4); + for (const call of fixture.mutations()) { + assert.ok(!call.includes('--skipFeatureDeployment')); + assert.ok(!call.includes('--overwrite')); + if (call.includes('--id')) assert.equal(call[call.indexOf('--id') + 1], CATALOG_ID); + } + const persisted = JSON.parse(await fs.readFile(path.join(path.dirname(fixture.config.packagePath), 'deployment.local.json'), 'utf8')); + assert.deepEqual(persisted, report); +}); + +test('identical rerun does not upload, install or duplicate the customizer', async t => { + const fixture = await scenario(t, { existing: {}, installed: CURRENT_VERSION }); + await fixture.run(); + assert.deepEqual(fixture.mutations(), []); +}); + +test('rerun updates only drifted site properties using one literal JSON argument', async t => { + const config = await options([]); + const fixture = await scenario(t, { + existing: {}, installed: CURRENT_VERSION, + actions: [action(config, { ClientSideComponentProperties: '{"old":"settings"}' })] + }); + await fixture.run(); + const writes = fixture.mutations(); + assert.equal(writes.length, 1); + assert.deepEqual(writes[0].slice(0, 3), ['spo', 'customaction', 'set']); + assert.deepEqual(JSON.parse(writes[0].at(-1)), config.properties); +}); + +test('explicit newer-version replacement uploads, deploys and upgrades only the target', async t => { + const fixture = await scenario(t, { existing: { AppCatalogVersion: '1.0.0.0' }, installed: '1.0.0.0', options: { replace: true } }); + await fixture.run(); + const writes = fixture.mutations(); + assert.deepEqual(writes.map(call => call.slice(0, 3).join(' ')), ['spo app add', 'spo app deploy', 'spo app upgrade']); + assert.ok(writes[0].includes('--overwrite')); + assert.equal(writes[2][writes[2].indexOf('--siteUrl') + 1], SITE); +}); + +test('explicit upgrade accepts an older unused tenant-capable package, not active tenant deployment', async t => { + const existing = { + AppCatalogVersion: '1.0.0.0', ContainsTenantWideExtension: true, IsPackageDefaultSkipFeatureDeployment: true + }; + const fixture = await scenario(t, { existing, options: { replace: true } }); + await fixture.run(); + assert.deepEqual(fixture.mutations().map(call => call.slice(0, 3).join(' ')), ['spo app add', 'spo app deploy', 'spo app install']); + const active = await scenario(t, { existing: { ...existing, SkipDeploymentFeature: true }, options: { replace: true } }); + await assert.rejects(active.run(), /activation scope/); + assert.deepEqual(active.mutations(), []); + const unapproved = await scenario(t, { existing }); + await assert.rejects(unapproved.run(), /--replace/); + assert.deepEqual(unapproved.mutations(), []); +}); + +for (const [name, settings, error] of [ + ['logged out', { status: 'Logged out' }, /logged out/], + ['wrong tenant', { status: { connectedAs: 'fixture', appTenant: 'wrong' } }, /tenant/], + ['wrong catalog', { catalog: 'https://contoso.sharepoint.com/sites/wrong' }, /does not match/], + ['permission denial', { fail: 'spo app list' }, /403/], + ['filename collision', { files: [{ Name: 'sidebar-agent.sppkg' }] }, /filename/], + ['unapproved replacement', { existing: { AppCatalogVersion: '1.0.0.0' } }, /--replace/], + ['downgrade', { existing: { AppCatalogVersion: '9.0.0.0' }, options: { replace: true } }, /downgrade/], + ['tenant-wide package', { existing: { SkipDeploymentFeature: true } }, /activation scope/], + ['invalid package', { existing: { IsValidAppPackage: false, ErrorMessage: 'invalid XML' } }, /invalid XML/], + ['incomplete metadata', { existing: { InstalledVersion: undefined } }, /Incomplete/], + ['build failure', { buildFailure: true }, /Build failed/] +]) { + test(`deployment stops without remote writes on ${name}`, async t => { + const fixture = await scenario(t, settings); + await assert.rejects(fixture.run(), error); + assert.deepEqual(fixture.mutations(), []); + }); +} + +test('different content at the same version cannot be overwritten even with --replace', async t => { + const config = await options([]); + const entries = packageEntries(config); + entries['ClientSideAssets/main.js'] = 'different code'; + const fixture = await scenario(t, { existing: {}, installed: CURRENT_VERSION, remotePackage: archive(entries), options: { replace: true } }); + await assert.rejects(fixture.run(), /same version/); + assert.deepEqual(fixture.mutations(), []); +}); + +test('duplicate, Site-scoped and tenant-wide registrations are never silently removed', async t => { + const config = await options([]); + assert.throws(() => matchingActions([action(config), action(config)], config), /Conflicting/); + assert.throws(() => matchingActions([action(config, { Scope: 2 })], config), /Conflicting/); + const fixture = await scenario(t, { tenantActions: [{ TenantWideExtensionComponentId: config.componentId }] }); + await assert.rejects(fixture.run(), /tenant-wide registration/); + assert.deepEqual(fixture.mutations(), []); +}); + +test('standalone and orphaned actions stop installation before creating a duplicate', async t => { + const config = await options([]); + for (const existing of [undefined, {}]) { + const fixture = await scenario(t, { existing, actions: [action(config)] }); + await assert.rejects(fixture.run(), /standalone|orphaned/); + assert.deepEqual(fixture.mutations(), []); + } +}); + +test('failed install reports earlier mutations and does not write customizer properties', async t => { + const fixture = await scenario(t, { fail: 'spo app install' }); + await assert.rejects(fixture.run(), /stopped at "install site app".*403[\s\S]*uploaded package, deployed catalog package/); + assert.ok(!fixture.calls.some(call => call.join(' ').startsWith('spo customaction set'))); + await assert.rejects(fs.access(path.join(path.dirname(fixture.config.packagePath), 'deployment.local.json')), { code: 'ENOENT' }); +}); + +test('SharePoint feature activation errors are reported immediately rather than timing out', async t => { + const fixture = await scenario(t, { installFailure: true }); + await assert.rejects(fixture.run(), /SharePoint installation failed: Feature XML validation failed/); + assert.ok(fixture.calls.some(call => call.includes('--method') && call.includes('post') && call.some(value => value.includes('/GetErrorDetails()')))); + assert.ok(!fixture.calls.some(call => call.join(' ').startsWith('spo customaction set'))); +}); + +test('polling is bounded and does not swallow access/response errors', async () => { + await assert.rejects(poll(async () => false, Boolean, 'fixture', { timeout: 0, interval: 0 }), /Timed out.*fixture/); + await assert.rejects(poll(async () => { throw new Error('403'); }, Boolean, 'fixture'), /403/); + let reads = 0; + assert.equal(await poll(async () => ++reads, value => value === 3, 'fixture', { interval: 0 }), 3); +}); diff --git a/ui/embed/sharepoint-customizer/tests/fixtures/deployment.json b/ui/embed/sharepoint-customizer/tests/fixtures/deployment.json new file mode 100644 index 00000000..46a9f2f3 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/fixtures/deployment.json @@ -0,0 +1,14 @@ +{ + "tenantId": "22222222-2222-4222-8222-222222222222", + "environmentId": "33333333-3333-4333-8333-333333333333", + "schemaName": "contoso_agent", + "agentTitle": "Team & \"Support\" 'Équipe'", + "startConversation": false, + "showTyping": true, + "enableFileUpload": true, + "showReasoning": true, + "showToolCalls": false, + "starterPrompts": [ + { "title": "Get started", "text": "How can you help R&D?" } + ] +} diff --git a/ui/embed/sharepoint-customizer/tests/fixtures/sdkActivities.cjs b/ui/embed/sharepoint-customizer/tests/fixtures/sdkActivities.cjs new file mode 100644 index 00000000..cd19941e --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/fixtures/sdkActivities.cjs @@ -0,0 +1,59 @@ +// Synthetic content using the entity/chunk shapes observed from the published SDK stream. +function thought(text, streamSequence = 1, chunkType = 'delta', properties = {}) { + const stream = { + streamType: 'streaming', streamSequence, + ...(streamSequence === 1 ? {} : { streamId: 'answer-stream' }) + }; + return { + type: 'typing', id: streamSequence === 1 ? 'answer-stream' : `answer-stream-${streamSequence - 1}`, + from: { id: 'bot', role: 'bot' }, text: '', + entities: [{ + type: 'thought', title: '', sequenceNumber: 0, status: 'complete', text, + reasonedForSeconds: 0, chainOfThoughtId: '', ...properties + }, { type: 'streaminfo', ...stream }], + channelData: { ...stream, chunkType, 'webchat:sequence-id': streamSequence } + }; +} + +function toolCall(status, { toolCallId = 'call', streamSequence = status === 'started' ? 12 : 13, ...properties } = {}) { + const stream = { streamType: 'informative', streamId: 'answer-stream', streamSequence }; + return { + type: 'typing', id: `answer-stream-${streamSequence - 1}`, + from: { id: 'bot', role: 'bot' }, text: status === 'started' ? 'Calling Get items...' : 'Get items completed', + entities: [{ + type: 'toolCall', toolCallId, toolName: 'get_items', toolDisplayName: 'Get items', + operationId: 'GetItems', connectorId: '/providers/Microsoft.PowerApps/apis/shared_sharepointonline', + componentSchemaName: 'sample.tool.GetItems', toolCategory: 'Connector', toolKind: 'tool', status, + description: 'PRIVATE_DESCRIPTION', + filledParameters: { value: 'PRIVATE_INPUT' }, hiddenFilledParameters: { value: 'PRIVATE_HIDDEN_INPUT' }, + result: { value: 'PRIVATE_RESULT' }, thought: 'PRIVATE_PLANNER_THOUGHT', + ...properties + }, { type: 'streaminfo', ...stream }], + channelData: { ...stream, 'webchat:sequence-id': streamSequence } + }; +} + +// Standard-harness event envelopes observed on the published SDK route; values are synthetic. +function standardPlan(value = {}) { + return { + type: 'event', name: 'DynamicPlanReceived', valueType: 'DynamicPlanReceived', + from: { id: 'bot', role: 'bot' }, + value: { + planIdentifier: 'weather-plan', steps: ['sample.CurrentWeather'], isFinalPlan: false, + toolDefinitions: [{ + schemaName: 'sample.CurrentWeather', displayName: 'Get current weather', + description: 'PRIVATE_DESCRIPTION', inputs: [{ name: 'PRIVATE_INPUT' }], outputs: [{ name: 'PRIVATE_OUTPUT' }] + }], + ...value + } + }; +} + +function standardStep(valueType, value = {}) { + return { + type: 'event', name: valueType, valueType, from: { id: 'bot', role: 'bot' }, + value: { planIdentifier: 'weather-plan', stepId: 'weather-step', taskDialogId: 'sample.CurrentWeather', ...value } + }; +} + +module.exports = { thought, toolCall, standardPlan, standardStep }; diff --git a/ui/embed/sharepoint-customizer/tests/lazyStart.test.cjs b/ui/embed/sharepoint-customizer/tests/lazyStart.test.cjs new file mode 100644 index 00000000..325675f2 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/lazyStart.test.cjs @@ -0,0 +1,61 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { filter, firstValueFrom, lastValueFrom, timeout } = require('rxjs'); +const { Activity } = require('@microsoft/agents-activity'); +const { ConnectionSettings, CopilotStudioClient } = require('@microsoft/agents-copilotstudio-client'); +const { normalizeConfiguration } = require('../temp/tests/configuration'); +const { createConnection } = require('../temp/tests/components/Chat/createConnection'); + +test('lazy start creates a conversation silently before executing the first message, then reuses its header-only ID', async t => { + const requests = []; + const host = 'https://000000000000000000000000000000.01.environment.api.powerplatform.com'; + t.mock.method(globalThis, 'fetch', async (url, options) => { + const path = new URL(url).pathname; + if (path.endsWith('/conversation-endpoints')) { + requests.push({ operation: 'discovery' }); + return Response.json({ + createConversation: `${host}/conversations`, + executeTurn: `${host}/conversations/{conversationId}`, + continueTurn: `${host}/conversations/{conversationId}/continue` + }); + } + const body = JSON.parse(options.body); + const create = path === '/conversations'; + assert.ok(create || path === '/conversations/header-only-id', `Unexpected URL: ${url}`); + requests.push({ operation: create ? 'create' : 'execute', body }); + // A create endpoint establishes a session; it does not execute an activity in the request. + const activity = create + ? { type: 'typing', from: { id: 'agent', role: 'bot' } } + : { type: 'message', id: `answer-${requests.length}`, from: { id: 'agent', role: 'bot' }, text: `Reply: ${body.activity.text}` }; + return new Response(`event: activity\ndata: ${JSON.stringify(activity)}\n\nevent: end\ndata: done\n\n`, { + headers: { 'content-type': 'text/event-stream', 'x-ms-conversationid': 'header-only-id' } + }); + }); + const configuration = normalizeConfiguration({ + appClientId: 'client', tenantId: 'tenant', + environmentId: '00000000-0000-0000-0000-000000000001', schemaName: 'fixture' + }); + const client = new CopilotStudioClient(new ConnectionSettings(configuration), 'fixture-token'); + const failures = []; + const connection = createConnection(client, configuration, { + onTurn() {}, onTurnEnd() {}, onError(error) { failures.push(error.message); } + }); + t.after(() => connection.end()); + const received = []; + const subscription = connection.activity$.subscribe(activity => received.push(activity)); + t.after(() => subscription.unsubscribe()); + await firstValueFrom(connection.connectionStatus$.pipe(filter(status => status === 2), timeout(2000))); + assert.deepEqual(requests, [], 'Opening the panel must not start a remote conversation'); + const send = text => lastValueFrom(connection.postActivity(Activity.fromObject({ + type: 'message', text, from: { id: 'user', role: 'user' } + })).pipe(timeout(2000))); + await send('First question'); + assert.deepEqual(requests.map(request => request.operation), ['discovery', 'create', 'execute']); + assert.deepEqual(requests[1].body, { emitStartConversationEvent: false }); + assert.equal(requests[2].body.activity.text, 'First question'); + assert.ok(received.some(activity => activity.text === 'Reply: First question')); + await send('Follow-up'); + assert.deepEqual(requests.map(request => request.operation), ['discovery', 'create', 'execute', 'execute']); + assert.ok(received.some(activity => activity.text === 'Reply: Follow-up')); + assert.deepEqual(failures, []); +}); diff --git a/ui/embed/sharepoint-customizer/tests/nativeActivities.test.cjs b/ui/embed/sharepoint-customizer/tests/nativeActivities.test.cjs new file mode 100644 index 00000000..99198c69 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/nativeActivities.test.cjs @@ -0,0 +1,175 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeConfiguration } = require('../temp/tests/configuration'); +const { createPreChatActivity, NativeActivityAdapter } = require('../temp/tests/components/Chat/nativeActivities'); +const { thought, toolCall, standardPlan, standardStep } = require('./fixtures/sdkActivities.cjs'); + +const configuration = overrides => normalizeConfiguration({ + appClientId: 'client', tenantId: 'tenant', environmentId: 'environment', schemaName: 'agent', ...overrides +}); +const tool = (valueType, value = {}) => ({ + type: 'event', valueType, value: { taskDialogId: 'lookup', stepId: 'call', ...value } +}); + +test('welcome uses the native Fluent PreChatMessage and editable messageBack starter contract', () => { + const activity = createPreChatActivity(configuration()); + assert.equal(activity.type, 'message'); + assert.equal(activity.entities[0].type, 'https://schema.org/Message'); + assert.deepEqual(activity.entities[0].keywords, ['PreChatMessage']); + assert.equal(activity.entities[0].author.name, 'How can I help?'); + assert.ok(activity.entities[0].author.image.startsWith('data:image/svg+xml,')); + assert.equal(activity.channelData['webchat:sequence-id'], -1); + assert.deepEqual(activity.suggestedActions.to, []); + assert.deepEqual(activity.suggestedActions.actions, [ + { type: 'messageBack', title: 'Get started', text: 'What can you help me with?' } + ]); + const withoutFiles = createPreChatActivity(configuration({ enableFileUpload: false, starterPrompts: [] })); + assert.equal(withoutFiles.entities[0].author.description, 'Ask a question to get started.'); + assert.deepEqual(withoutFiles.suggestedActions.actions, []); +}); + +for (const showReasoning of [false, true]) { + for (const showToolCalls of [false, true]) { + test(`native groups respect reasoning=${showReasoning}, tools=${showToolCalls} without exposing raw events`, () => { + const adapter = new NativeActivityAdapter(configuration({ showReasoning, showToolCalls })); + adapter.startTurn(); + const activities = [ + ...adapter.transform(thought('Checking sources')), + ...adapter.transform(tool('DynamicPlanReceived', { + toolDefinitions: [{ schemaName: 'lookup', displayName: 'Search reference material' }] + })), + ...adapter.transform(tool('DynamicPlanStepTriggered', { arguments: 'PRIVATE_ARGUMENT' })), + ...adapter.transform(tool('DynamicPlanStepBindUpdate', { arguments: 'PRIVATE_BINDING' })), + ...adapter.transform(tool('DynamicPlanStepFinished', { result: 'PRIVATE_RESULT' })) + ]; + assert.equal(activities.length, Number(showReasoning) + Number(showToolCalls) * 2); + assert.ok(!JSON.stringify(activities).includes('PRIVATE_')); + for (const activity of activities) { + assert.equal(activity.type, 'message'); + assert.equal(activity.entities[0]['@id'], ''); + assert.equal(activity.entities[0].isPartOf['@type'], 'HowTo'); + assert.ok(activity.entities[0].keywords.includes('Collapsible')); + assert.ok(!('value' in activity)); + } + if (showToolCalls) { + const last = activities[activities.length - 1]; + assert.equal(last.entities[0].abstract, 'Search reference material'); + assert.equal(last.text, 'Finished'); + } + }); + } +} + +test('native progress updates keep their IDs and order, finish once, and do not overwrite another turn', () => { + const adapter = new NativeActivityAdapter(configuration({ showReasoning: true })); + adapter.startTurn(); + const [first] = adapter.transform(thought('Checking')); + const [second] = adapter.transform(thought('Checking sources', 2, 'replace')); + assert.equal(first.id, second.id); + assert.equal(typeof first.timestamp, 'string'); + assert.equal(first.timestamp, second.timestamp); + assert.equal(first.channelData['webchat:sequence-id'], 1); + assert.equal(first.channelData['webchat:sequence-id'], second.channelData['webchat:sequence-id']); + assert.equal(second.text, 'Checking sources'); + assert.deepEqual(adapter.transform(thought('old', 1)), []); + const [finished] = adapter.transform({ type: 'event', name: 'turn.complete' }); + assert.equal(finished.id, first.id); + assert.equal(finished.entities[0].creativeWorkStatus, 'Published'); + assert.equal(finished.entities[0].isPartOf.creativeWorkStatus, 'Published'); + assert.deepEqual(adapter.finishTurn(), []); + adapter.startTurn(); + const [nextTurn] = adapter.transform(thought('Checking again')); + assert.notEqual(nextTurn.id, first.id); + assert.notEqual(nextTurn.entities[0].isPartOf['@id'], first.entities[0].isPartOf['@id']); +}); + +test('ordinary replies remain untouched and native analysis respects the reasoning flag', () => { + const adapter = new NativeActivityAdapter(configuration()); + const reply = { type: 'message', text: 'The answer', from: { id: 'bot', role: 'bot' } }; + assert.strictEqual(adapter.transform(reply)[0], reply); + const analysis = { ...reply, entities: [{ type: 'https://schema.org/Message', keywords: ['AnalysisMessage'] }] }; + assert.deepEqual(adapter.transform(analysis), []); + assert.strictEqual(new NativeActivityAdapter(configuration({ showReasoning: true })).transform(analysis)[0], analysis); +}); + +test('turn completion does not invent a tool-finished event', () => { + const adapter = new NativeActivityAdapter(configuration({ showToolCalls: true })); + adapter.transform(tool('DynamicPlanStepTriggered')); + const [finished] = adapter.finishTurn(); + assert.equal(finished.text, 'No finish event received.'); + assert.equal(finished.entities[0].creativeWorkStatus, undefined); +}); + +test('toolCall metadata produces a named native tool step, not a Reasoning step', () => { + const adapter = new NativeActivityAdapter(configuration({ showToolCalls: true })); + const [started] = adapter.transform(toolCall('started')); + const [finished] = adapter.transform(toolCall('completed')); + assert.equal(started.id, finished.id); + assert.equal(started.channelData['webchat:sequence-id'], finished.channelData['webchat:sequence-id']); + assert.equal(finished.entities[0].abstract, 'Get items'); + assert.equal(finished.text, 'Finished'); + assert.equal(finished.entities[0].creativeWorkStatus, 'Published'); + assert.ok(!JSON.stringify([started, finished]).includes('PRIVATE_')); +}); + +test('failed toolCall entities remain failed when the turn finishes', () => { + const adapter = new NativeActivityAdapter(configuration({ showToolCalls: true })); + adapter.transform(toolCall('started')); + const [failed] = adapter.transform(toolCall('failed')); + const [finished] = adapter.finishTurn(); + assert.equal(failed.text, 'Failed'); + assert.equal(failed.entities[0].creativeWorkStatus, 'Failed'); + assert.equal(finished.text, 'Failed'); + assert.equal(finished.entities[0].creativeWorkStatus, 'Failed'); +}); + +test('unclassified informative updates are not mislabeled or replayed into the transcript', () => { + const adapter = new NativeActivityAdapter(configuration({ showReasoning: true, showToolCalls: true })); + assert.deepEqual(adapter.transform({ type: 'typing', text: 'Working', channelData: { streamType: 'informative' } }), []); + assert.deepEqual(adapter.transform({ + type: 'typing', text: 'Working', entities: [{ type: 'streaminfo', streamType: 'informative' }] + }), []); +}); + +test('standard reasoning and tools share the native group but have separate stable identities', () => { + const adapter = new NativeActivityAdapter(configuration({ showReasoning: true, showToolCalls: true })); + adapter.startTurn(); + adapter.transform(standardPlan()); + const activity = standardStep('DynamicPlanStepTriggered', { thought: 'Check the requested city.' }); + activity.channelData = { 'webchat:sequence-id': 4 }; + const [reasoning, tool] = adapter.transform(activity); + assert.equal(reasoning.entities[0].abstract, 'Reasoning'); + assert.equal(reasoning.text, 'Check the requested city.'); + assert.equal(tool.entities[0].abstract, 'Get current weather'); + assert.notEqual(reasoning.id, tool.id); + assert.equal(reasoning.entities[0].isPartOf['@id'], tool.entities[0].isPartOf['@id']); + assert.equal(reasoning.channelData['webchat:sequence-id'], 4); + assert.equal(tool.channelData['webchat:sequence-id'], 4); + assert.deepEqual([reasoning.entities[0].position, tool.entities[0].position], [1, 2]); + const finished = adapter.transform(standardStep('DynamicPlanStepFinished')); + assert.equal(finished[0].id, tool.id); + assert.equal(finished[0].text, 'Finished'); + assert.equal(finished[0].channelData['webchat:sequence-id'], 4); + const settled = adapter.finishTurn(); + assert.equal(settled[0].id, reasoning.id); + assert.equal(settled[0].entities[0].creativeWorkStatus, 'Published'); +}); + +test('standard tool labels survive a clarification turn without carrying reasoning or leaking into a new conversation', () => { + const adapter = new NativeActivityAdapter(configuration({ showReasoning: true, showToolCalls: true })); + adapter.startTurn(); + adapter.transform(standardPlan()); + const started = adapter.transform(standardStep('DynamicPlanStepTriggered', { thought: 'Check the requested city.' })); + adapter.finishTurn(); + adapter.startTurn(); + const [resumed] = adapter.transform(standardStep('DynamicPlanStepBindUpdate', { arguments: 'PRIVATE_INPUT' })); + const [finished] = adapter.transform(standardStep('DynamicPlanStepFinished', { result: 'PRIVATE_RESULT' })); + assert.notEqual(resumed.id, started[1].id); + assert.equal(resumed.id, finished.id); + assert.equal(finished.entities[0].abstract, 'Get current weather'); + assert.equal(finished.text, 'Finished'); + assert.equal(adapter.finishTurn().length, 1); + assert.ok(!JSON.stringify([resumed, finished]).includes('PRIVATE_')); + const fresh = new NativeActivityAdapter(configuration({ showToolCalls: true })); + assert.equal(fresh.transform(standardStep('DynamicPlanStepFinished'))[0].entities[0].abstract, 'sample.CurrentWeather'); +}); diff --git a/ui/embed/sharepoint-customizer/tests/sdkActivityMapping.test.cjs b/ui/embed/sharepoint-customizer/tests/sdkActivityMapping.test.cjs new file mode 100644 index 00000000..11ab2cb9 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tests/sdkActivityMapping.test.cjs @@ -0,0 +1,167 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { firstValueFrom, lastValueFrom, filter, timeout } = require('rxjs'); +const { Activity } = require('@microsoft/agents-activity'); +const { ConnectionSettings, CopilotStudioClient } = require('@microsoft/agents-copilotstudio-client'); +const { normalizeConfiguration } = require('../temp/tests/configuration'); +const { createConnection } = require('../temp/tests/components/Chat/createConnection'); +const { thought, toolCall, standardPlan, standardStep } = require('./fixtures/sdkActivities.cjs'); + +for (const showReasoning of [false, true]) { + for (const showToolCalls of [false, true]) { + test(`real SDK entity mapping: reasoning=${showReasoning}, tools=${showToolCalls}`, async t => { + const host = 'https://000000000000000000000000000000.01.environment.api.powerplatform.com'; + const stream = { streamType: 'streaming', streamId: 'answer-stream', chunkType: 'delta' }; + t.mock.method(globalThis, 'fetch', async url => { + const path = new URL(url).pathname; + if (path.endsWith('/conversation-endpoints')) { + return Response.json({ + createConversation: `${host}/conversations`, + executeTurn: `${host}/conversations/{conversationId}`, + continueTurn: `${host}/conversations/{conversationId}/continue` + }); + } + + assert.ok(path === '/conversations' || path === '/conversations/header-only-id'); + const activities = path === '/conversations' ? [{ type: 'typing' }] : [ + thought('Check ', 1), thought('the list', 2), thought('Check the list', 3, 'replace'), + toolCall('started', { streamSequence: 4 }), toolCall('completed', { streamSequence: 5 }), + { type: 'typing', id: 'answer-part-1', text: 'Here are ', channelData: { ...stream, streamSequence: 6 } }, + { type: 'typing', id: 'answer-part-2', text: 'the items.', channelData: { ...stream, streamSequence: 7 } }, + { type: 'message', id: 'answer', text: 'Here are the items.', channelData: { streamType: 'final', streamId: 'answer-stream' } }, + { type: 'event', name: 'turn.complete' } + ]; + return new Response(activities.map(activity => `event: activity\ndata: ${JSON.stringify(activity)}\n\n`).join('') + + 'event: end\ndata: done\n\n', { + headers: { 'content-type': 'text/event-stream', 'x-ms-conversationid': 'header-only-id' } + }); + }); + const configuration = normalizeConfiguration({ + appClientId: 'client', tenantId: 'tenant', + environmentId: '00000000-0000-0000-0000-000000000001', schemaName: 'fixture', + showReasoning, showToolCalls + }); + const failures = []; + const connection = createConnection(new CopilotStudioClient(new ConnectionSettings(configuration), 'fixture-token'), configuration, { + onTurn() {}, onTurnEnd() {}, onError(error) { failures.push(error.message); } + }); + const received = []; + const subscription = connection.activity$.subscribe(activity => received.push(activity)); + t.after(() => { subscription.unsubscribe(); connection.end(); }); + await firstValueFrom(connection.connectionStatus$.pipe(filter(status => status === 2), timeout(2000))); + await lastValueFrom(connection.postActivity(Activity.fromObject({ + type: 'message', text: 'List items', from: { id: 'user', role: 'user' } + })).pipe(timeout(2000))); + + const reasoning = received.filter(activity => activity.id?.includes(':reasoning:')); + const tools = received.filter(activity => activity.id?.includes(':tool:')); + assert.equal(new Set(reasoning.map(activity => activity.id)).size, Number(showReasoning)); + assert.equal(new Set(tools.map(activity => activity.id)).size, Number(showToolCalls)); + if (showReasoning) { + assert.equal(reasoning.at(-1).text, 'Check the list'); + assert.equal(reasoning.at(-1).entities[0].abstract, 'Reasoning'); + } + if (showToolCalls) { + assert.equal(tools.at(-1).entities[0].abstract, 'Get items'); + assert.equal(tools.at(-1).text, 'Finished'); + } + assert.ok(received.some(activity => activity.type === 'typing' && activity.text === 'Here are the items.')); + assert.ok(received.some(activity => activity.id === 'answer' && activity.text === 'Here are the items.')); + assert.ok(!received.some(activity => activity.text === 'Get items completed')); + assert.ok(!JSON.stringify(received).includes('PRIVATE_')); + assert.ok(!received.some(activity => activity.entities?.some(entity => ['thought', 'toolCall'].includes(entity.type)))); + assert.deepEqual(failures, []); + }); + } +} + +for (const showReasoning of [false, true]) { + for (const showToolCalls of [false, true]) { + test(`real SDK standard mapping: reasoning=${showReasoning}, tools=${showToolCalls}`, async t => { + const host = 'https://000000000000000000000000000000.01.environment.api.powerplatform.com'; + const base = `${host}/copilotstudio/dataverse-backed/authenticated/bots/fixture/conversations`; + const requests = []; + let turn = 0; + t.mock.method(globalThis, 'fetch', async (url, options) => { + const path = new URL(url).pathname; + requests.push({ path, method: options?.method || 'GET' }); + if (path.endsWith('/conversation-endpoints')) return Response.json({ + createConversation: `${base}?api-version=2022-03-01-preview`, + executeTurn: `${base}/{conversationId}?api-version=2022-03-01-preview`, + continueTurn: `${base}/{conversationId}/continue?api-version=2022-03-01-preview`, + subscribe: `${base}/{conversationId}/subscribe?api-version=2022-03-01-preview` + }); + const start = path.endsWith('/conversations'); + if (!start) { + assert.ok(path.endsWith('/conversations/standard-conversation')); + turn++; + } + const final = { + type: 'message', id: `standard-answer-${turn}`, from: { id: 'bot', role: 'bot' }, + text: turn === 1 ? 'Which units should I use?' : 'The weather is clear.', + entities: [{ type: 'streaminfo', streamType: 'final', streamId: `stream-${turn}` }], + channelData: { streamType: 'final', streamId: `stream-${turn}` } + }; + const activities = start ? [{ type: 'typing' }] : turn === 1 ? [ + { type: 'typing', text: 'Generating plan...', entities: [{ type: 'streaminfo', streamType: 'informative' }] }, + standardPlan(), + standardStep('DynamicPlanStepTriggered', { + thought: 'Check current conditions for the requested city.', state: 'inProgress', type: 'Action' + }), + standardStep('DynamicPlanStepBindUpdate', { arguments: { Location: 'PRIVATE_CITY' } }), + final + ] : [ + standardStep('DynamicPlanStepBindUpdate', { arguments: { units: 'PRIVATE_UNITS' } }), + // Completion is a regression fixture; the live weather test stopped at connector sign-in. + standardStep('DynamicPlanStepFinished', { result: 'PRIVATE_WEATHER_RESULT' }), + final + ]; + return new Response(activities.map(activity => `event: activity\ndata: ${JSON.stringify(activity)}\n\n`).join('') + + 'event: end\ndata: done\n\n', { + headers: { 'content-type': 'text/event-stream', 'x-ms-conversationid': 'standard-conversation' } + }); + }); + const configuration = normalizeConfiguration({ + appClientId: 'client', tenantId: 'tenant', + environmentId: '00000000-0000-0000-0000-000000000001', schemaName: 'fixture', + showReasoning, showToolCalls + }); + const failures = []; + let ended = 0; + const connection = createConnection(new CopilotStudioClient(new ConnectionSettings(configuration), 'fixture-token'), configuration, { + onTurn() {}, onTurnEnd() { ended++; }, onError(error) { failures.push(error.message); } + }); + const received = []; + const subscription = connection.activity$.subscribe(activity => received.push(activity)); + t.after(() => { subscription.unsubscribe(); connection.end(); }); + await firstValueFrom(connection.connectionStatus$.pipe(filter(status => status === 2), timeout(2000))); + for (const text of ['Check Seattle weather', 'Metric']) { + await lastValueFrom(connection.postActivity(Activity.fromObject({ + type: 'message', text, from: { id: 'user', role: 'user' } + })).pipe(timeout(2000))); + } + const reasoning = received.filter(activity => activity.id?.includes(':reasoning:')); + const tools = received.filter(activity => activity.id?.includes(':tool:')); + assert.equal(new Set(reasoning.map(activity => activity.id)).size, Number(showReasoning)); + assert.equal(new Set(tools.map(activity => activity.id)).size, Number(showToolCalls) * 2); + if (showReasoning) { + assert.equal(reasoning.at(-1).text, 'Check current conditions for the requested city.'); + assert.equal(reasoning.at(-1).entities[0].abstract, 'Reasoning'); + assert.equal(reasoning.at(-1).entities[0].creativeWorkStatus, 'Published'); + } + if (showToolCalls) { + assert.ok(tools.every(activity => activity.entities[0].abstract === 'Get current weather')); + assert.ok(tools.some(activity => activity.text === 'No finish event received.')); + assert.equal(tools.at(-1).text, 'Finished'); + } + assert.equal(ended, 2); + assert.equal(received.filter(activity => activity.id?.startsWith('standard-answer-')).length, 2); + assert.ok(!received.some(activity => activity.text === 'Generating plan...')); + assert.ok(!received.some(activity => activity.type === 'event')); + assert.ok(!JSON.stringify(received).includes('PRIVATE_')); + assert.deepEqual(failures, []); + assert.equal(requests.length, 4); + assert.ok(requests.every(request => !request.path.endsWith('/subscribe'))); + }); + } +} diff --git a/ui/embed/sharepoint-customizer/tsconfig.json b/ui/embed/sharepoint-customizer/tsconfig.json index 419cd861..d697c99c 100644 --- a/ui/embed/sharepoint-customizer/tsconfig.json +++ b/ui/embed/sharepoint-customizer/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "./node_modules/@microsoft/rush-stack-compiler-5.3/includes/tsconfig-web.json", "compilerOptions": { - "target": "es5", + "target": "es2019", "forceConsistentCasingInFileNames": true, "module": "esnext", "moduleResolution": "node", @@ -22,7 +22,7 @@ "webpack-env" ], "lib": [ - "es5", + "es2019", "dom", "es2015.collection", "es2015.promise" diff --git a/ui/embed/sharepoint-customizer/tsconfig.test.json b/ui/embed/sharepoint-customizer/tsconfig.test.json new file mode 100644 index 00000000..ba86cf08 --- /dev/null +++ b/ui/embed/sharepoint-customizer/tsconfig.test.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020", "DOM"], + "strict": true, + "skipLibCheck": true, + "outDir": "temp/tests", + "rootDir": "src/extensions/sidebarAgent", + "types": [] + }, + "include": [ + "src/extensions/sidebarAgent/configuration.ts", + "src/extensions/sidebarAgent/components/Chat/acquireToken.ts", + "src/extensions/sidebarAgent/components/Chat/attachments.ts", + "src/extensions/sidebarAgent/components/Chat/activityDetails.ts", + "src/extensions/sidebarAgent/components/Chat/nativeActivities.ts", + "src/extensions/sidebarAgent/components/Chat/createConnection.ts" + ] +}