diff --git a/src/SystemApplication.code-workspace b/src/SystemApplication.code-workspace index f08e9feb23..a645bfcbdc 100644 --- a/src/SystemApplication.code-workspace +++ b/src/SystemApplication.code-workspace @@ -1,5 +1,9 @@ { "folders": [ + { + "name": "LibraryAgent", + "path": "Tools\\Test Framework\\Test Libraries\\LibraryAgent" + }, { "name": "Any", "path": "Tools\\Test Framework\\Test Libraries\\Any" diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/AI-TEST-AUTHORING.md b/src/Tools/Test Framework/Test Libraries/LibraryAgent/AI-TEST-AUTHORING.md new file mode 100644 index 0000000000..49dadde375 --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/AI-TEST-AUTHORING.md @@ -0,0 +1,369 @@ +# Authoring AI Agent Tests — YAML Reference + +AI agent tests evaluate Business Central agent behavior through declarative YAML scenarios. The **AI Test Suite** is the main driver: +it runs each test as a per-turn loop — provide input, run the agent, assert state. **`Library - Agent`** is a helper that structures the YAML input into agent task operations in a reusable way, so individual tests do not have to wire up the agent task framework directly. + +You can find a sample test implementation at +[`SalesValidationAgent3P`](https://github.com/microsoft/BCTech/tree/master/samples/BCAgents/SalesValidationAgent/test). + +This document is the **input contract** for AI agent tests: the YAML structure and the library methods each key flows into. + +What you'll find here: + +- The YAML schema (top-level keys, per-test keys, `turn_setup`, `query`, `expected_data`). +- The placeholder syntax (`$DateFormula-…$`, `$DateTimeFormula-…$`). +- For each YAML key that the framework interprets, the library method that consumes it. +- A quick reference for `Library - Agent`, `AIT Test Context`, and `Test Input Json`. + +--- + +## 1. File kinds + +Two YAML kinds, distinguished by their top-level shape. Conventional locations under your test app's `.resources/`: + +| Kind | Convention path | Top-level shape | Required | +|---|---|---|---| +| Suite-setup file | `.resources/suite_setup/.yaml` | `name:` scalar + `suite_setup:` **object** + `description:` | optional | +| Test dataset file | `.resources/datasets/.yaml` | `suite_setup:` **scalar** (reference) + `tests:` array | required | + +A **suite-setup file** is optional. It holds data that needs to be set up only once for the whole suite — typical examples are master records +that every test relies on (locations, customers, posting groups). The framework runs it before the first test and skips it on subsequent +turns; test code gates re-execution via `IsSuiteSetupDone()` / +`SetEvalSuiteSetupCompleted()`. + +A **test dataset file** is the per-suite input that the test runner iterates over. It usually references a suite-setup by name but can stand alone if no shared setup is needed. +The framework loads `*.yaml` recursively from `.resources/`, so folder names are convention only. + +--- + +## 2. Top-level keys + +| Key | Type | Required | Notes | +|---|---|---|---| +| `name` | scalar | required on setup files | Becomes `Test Input Group."Group Name"`. Other datasets reference a setup by this exact string. | +| `description` | scalar | optional | Free-form. | +| `language` | scalar | optional | Windows language tag (`en-US`, …). Used to pick a language-variant suite-setup. | +| `suite_setup` | scalar **or** object | optional | Scalar = reference to another group's `name:`. Object = inline content (used in setup files only). | +| `tests` | array | required for test datasets | One element per test case. | +| `continue_on_failure` | bool | optional | Per-test flag — if `true`, turn iteration continues after a failed turn. | + +--- + +## 3. Suite-setup file shape + +```yaml +name: -SETUP # required — datasets reference this string +description: Shared master data for the suite. +suite_setup: # OBJECT form = inline content + setup_actions: + - action_type: + action_data: + - "": + "": + - "": +``` + +- `suite_setup:` as an **object** means "this is the setup payload". +- Author **one file per language** for localised suites — e.g. `-SETUP.yaml` and `-SETUP-FR.yaml`. The same one-file-per-language convention applies to test dataset files (§4). +- The shape under `suite_setup` (`setup_actions` / `action_type` / `action_data`) is **convention defined by your test app** — see §6. + +--- + +## 4. Test dataset file shape + +```yaml +suite_setup: -SETUP # SCALAR form = reference to a setup group's name +tests: + - name: # required — becomes Test Input "Code" + description: + turns: + - turn_setup: { ... } # see §6 + query: { ... } # see §7 + expected_data: { ... } # see §8 + - query: { intervention: { ... } } # continuation turn — see §7.2 + expected_data: { ... } +``` + +Always use the `turns:` array. For a single-turn test, write a single-element `turns:` list rather than putting `query:` / `expected_data:` directly under the test entry. The multi-turn syntax is the only supported shape. + +--- + +## 5. Per-test keys + +| Key | Consumed by | Notes | +|---|---|---| +| `name` | Test Runner | Required. Stored as `TestInput.Code`. | +| `description` | Test Runner | Optional. | +| `turns` | AIT Test Toolkit | Detected via `IsMultiTurn`; turns are 1-indexed. | +| `query` | `Library - Agent` via `AITTestContext.GetQuery()` | One per turn. | +| `expected_data` | Validator + `Library - Agent` (for `intervention_request`) via `AITTestContext.GetExpectedData()` | One per turn. Multi-turn aware: returns the current turn's slice. | +| `turn_setup` | Dispatcher via `AITTestContext.GetTurnSetup(var Found)` | One per turn. | +| `continue_on_failure` | AIT Test Toolkit via `AITTestContext.GetCanContinueOnFailure()` | Per-test boolean. | + +--- + +## 6. `turn_setup` — opaque to the framework + +`turn_setup` is **opaque JSON**. The framework hands the entire sub-tree to your test code via: + +```al +AITTestContext.GetTurnSetup(var Found: Boolean): Codeunit "Test Input Json" +``` + +Likewise for the suite-level setup: + +```al +AITTestContext.GetEvalSuiteSetupDataInput(): Codeunit "Test Input Json" +``` + +Your test library walks the JSON and dispatches to handlers. +**The recommended `setup_actions` / `action_type` / `action_data` shape is identical to the suite-setup convention in 3** — the framework imposes no schema, but using the same shape across both means one dispatcher can drive both suite-level and per-turn data setup. + +Example with per-turn data and a nested application-specific block: + +```yaml +turn_setup: + setup_actions: + - action_type: create_sales_order # your dispatcher's known type + action_data: # always an array (so one action_type can create N records) + - "Sell-to Customer No.": SVCUST01 + "Shipping Advice": Complete + "Shipment Date": "$DateFormula-$" + lines: # nested objects are application-specific + - Quantity: 10 + "Location Code": SVLocation + quantity_in_inventory: 10 + reserve: true +``` + +Recommendation is to match the AL field captions exactly, **quoted** when they contain spaces or special characters: `"Sell-to Customer No."`. Dates should be expressed through the placeholders see details under (§9), which calculate values relative to `WorkDate`. Avoid hardcoding dates — tests written that way drift out of date and need frequent maintenance. + +--- + +## 7. `query` — what the agent receives + +`query` is consumed primarily by `Library - Agent.RunTurnAndWait`, which is the recommended high-level driver. It auto-detects the shape from which keys are present and dispatches to the appropriate lower-level call. Authors who need finer control can call the granular methods directly — `CreateTaskAndWait` / `CreateMessageAndWait` for input queries, `CreateUserInterventionAndWait` / `CreateUserInterventionFromSuggestionAndWait` / `ContinueTaskAndWait` for interventions. See §11 for the full list. + +### 7.1 Choosing the input shape + +A `query` is one of two shapes depending on the agent's task state: + +- **Task input** — when starting a new agent task (typically turn 1, or any time the agent is not already paused waiting for the user). Use `from` / `title` / `message` / `attachments`. See §7.2. +- **Intervention** — when the agent is paused waiting for user input (typically turn 2+). Use `intervention.suggestion` or `intervention.instruction`. See §7.3. + +The library detects which shape is present from the YAML keys and dispatches accordingly. Mixing both shapes in one query is an error (see §7.4). + +### 7.2 Task input + +```yaml +query: + from: + title: + message: + attachments: + - file: + - file: +``` + +How keys flow into library calls: + +| YAML key | Flows into | +|---|---| +| `query.title` | `AgentTaskBuilder.Initialize(AgentUserSecurityId, title)` — required, asserted via `Library Assert`. | +| `query.from` | `AgentTaskMessageBuilder.Initialize(from, ...)`. If `from` is missing, no message is added (only the task title). | +| `query.message` | `AgentTaskMessageBuilder.Initialize(..., message)`. Optional. | +| `query.attachments[].file` | `IAgentTestResourceProvider.GetResource(file, ...)` → `AgentTaskMessageBuilder.AddAttachment(...)`. Use the `RunTurnAndWait` overload that accepts a provider when YAML uses attachments. | + +### 7.3 Intervention continuation + +```yaml +query: + intervention: + suggestion: + # OR + instruction: +``` + +How keys flow: + +| YAML key | Flows into | +|---|---| +| `query.intervention.suggestion` | `LibraryAgent.CreateUserInterventionFromSuggestionAndWait(AgentTask, SuggestionCode)` | +| `query.intervention.instruction` | `LibraryAgent.CreateUserInterventionAndWait(AgentTask, UserInput)` | + +### 7.4 Mutual exclusion + +| Condition | Result | +|---|---| +| `query` has both `title` and `intervention` | `InvalidQueryBothErr` | +| `query` has neither `title` nor `intervention` | `InvalidQueryNeitherErr` | +| `intervention` has both `suggestion` and `instruction` | `InvalidInterventionErr` | + +--- + +## 8. `expected_data` — what to validate + +`expected_data` is **opaque JSON** to the framework with **one** recognized sub-key (`intervention_request`). Additional validation keys can be defined per agent test app and implemented in the validator as needed. + +```yaml +expected_data: + intervention_request: # framework-recognized + type: Assistance # required when intervention_request is present + suggestions: # optional — list of suggestion codes that MUST be present + - + - + : 1 # implemented per agent test app + : Released # implemented per agent test app +``` + +### 8.1 Framework-recognized: `intervention_request` + +| YAML key | Flows into | +|---|---| +| `expected_data.intervention_request.type` | `LibraryAgent.ParseUserInterventionRequestType(text)` → `Enum "Agent User Int Request Type"`. Values: `Assistance`, `Review`, `Message` (English ordinal names; no translation). | +| `expected_data.intervention_request.suggestions[]` | Validated by `LibraryAgent.ValidateInterventionRequest` — every expected code must be present on the actual request. | + +Automatic validation in `LibraryAgent.FinalizeTurn`: + +- If `intervention_request` is declared in YAML: the agent must have paused for an intervention with the matching `type` and including every `suggestion` code listed. +- If `intervention_request` is **not** declared: the agent must **not** have paused for an intervention. Unexpected interventions fail the turn. + +So: declare `intervention_request` on every turn where you expect the +agent to pause. Otherwise omit it. + +### 8.2 Agent-specific keys + +Additional validation keys can be defined per agent test app. Read them via `AITTestContext.GetExpectedData()` and implement the validation logic in the test library as needed. The framework does not interpret or enforce these keys. + +--- + +## 9. Placeholders + +Embed in any string YAML value using the `$...$` syntax. Resolution is **automatic** — the framework substitutes placeholders when YAML values are read, so authors never call a resolver explicitly. + +| Form | Resolves to | Example | +|---|---|---| +| `$DateFormula-$` (whole value) | `Date` | `"$DateFormula-$"` | +| `$DateFormula-$` (inside text) | substring → `Format(Date)` | `"Process orders for $DateFormula-$"` | +| `$DateTimeFormula-$` | `DateTime` (time = `0T`) | `"$DateTimeFormula-$"` | +| `$DateTimeFormula--HH:MM:SS$` | `DateTime` with explicit time | `"$DateTimeFormula--13:30:11$"` | +| `$DateTimeFormula--HH:MM:SS.FFFF$` | `DateTime` with milliseconds | `"$DateTimeFormula--13:30:11.1301$"` | + +`` is a standard AL DateFormula evaluated against `WorkDate()` +(``, ``, ``, ``, `<-7D>`, `<+30D>`, …). + +**Always quote** placeholder strings in YAML — the `<` and `>` characters are otherwise interpreted as flow-style delimiters by some parsers. + +The placeholder engine is `SingleInstance` and resets on `OnBeforeTestMethodRun`. On the first resolve call per test it scans the full input JSON once; if no placeholder prefix is present, subsequent calls return the input unchanged with one boolean check — zero overhead for tests that don't use placeholders. + +--- + +## 10. XML suite definition + +Wires datasets to a test codeunit. Lives at `.resources/configuration/.xml`: + +```xml + + + + + + +``` + +| Attribute | Required | Notes | +|---|---|---| +| `Code` | yes | Suite key. Conventional pattern: `-` (`-ACCR`, `-LOAD`, …). | +| `Dataset` | yes | Default dataset for lines that don't override. | +| `` | yes | Your `[Test]` codeunit. | +| `` | yes | YAML filename. Resolved against the test app's resources. | +| `TestRunnerId` | recommended | `130451` (`Test Runner - Isol. Disabled`) — agent tasks span transactions. | +| `TestType` | recommended | `Agent`. | +| `Capability` | no | Free text. | +| `Frequency` | no | `Daily` / `Weekly` / `Manual`. | + +Encoding **must** be `UTF-16`. + +--- + +## 11. API quick reference + +### `AIT Test Context` + +| Procedure | Returns | Use | +|---|---|---| +| `GetEvalSuiteSetupDataInput()` | `Test Input Json` | Suite-setup content. | +| `IsSuiteSetupDone()` / `SetEvalSuiteSetupCompleted()` | bool / void | Idempotent suite-setup gate. | +| `GetTurnSetup(var Found)` | `Test Input Json` | Current turn's `turn_setup:`. | +| `GetQuery()` | `Test Input Json` | Current turn's `query:`. | +| `GetExpectedData()` | `Test Input Json` | Current turn's `expected_data:` (multi-turn aware). | +| `NextTurn()` | bool | Advance turn pointer. Usually called via `LibraryAgent.FinalizeTurn`. | +| `GetCanContinueOnFailure()` | bool | Per-test flag. | + +### `Library - Agent` (codeunit `130560`) + +The recommended high-level driver is `RunTurnAndWait` + `FinalizeTurn`, which are YAML-aware and handle the turn loop end to end. The other procedures in this codeunit are **lower-level alternatives** for tests that need finer control (e.g. building a task message manually, polling intervention state, supplying a custom user input outside the YAML flow). + +#### Recommended high-level driver + +| Procedure | Use | +|---|---| +| `EnsureAgentIsActive(AgentUserSecurityId)` | Activate before first turn. | +| `GetAgentUnderTest(var AgentUserSecurityID)` | Resolve the agent user id used by the test suite. | +| `RunTurnAndWait(AgentUserSecurityId, var AgentTask)` | Reads `query:`, runs the turn, waits. | +| `RunTurnAndWait(..., AgentTestResourceProvider)` | Same, with attachment resolver. Use when YAML has `attachments[].file`. | +| `FinalizeTurn(var AgentTask, TurnSuccessful, ErrorReason): Continue` | Always call after each turn — writes output, validates intervention expectation, advances. | + +#### Alternatives — manual task management + +| Procedure | Use | +|---|---| +| `CreateTaskAndWait(var AgentTaskBuilder)` | Create a task from a manually-built `AgentTaskBuilder` and wait. | +| `CreateTaskAndWait(var AgentTaskBuilder, var CreatedAgentTask)` | Same, returning the created task record. | +| `CreateMessageAndWait(var AgentTaskMessageBuilder)` | Append a message to an existing task and wait. | +| `CreateMessageAndWait(var AgentTaskMessageBuilder, var AgentTask)` | Same, returning the task record. | +| `ContinueTaskAndWait(var AgentTask)` | Continue a paused task (default user input). | +| `ContinueTaskAndWait(var AgentTask, UserInput)` | Continue a paused task with custom free-text input. | +| `WaitForTaskToComplete(var AgentTask)` | Block until a task finishes — for end-to-end scenarios that start the task from product code. | +| `StopTasks(AgentUserSecurityId)` / `StopAllTasks()` | Teardown helpers. | +| `SetAgentTaskTimeout(NewTimeout)` | Override the 30-min default for all wait calls. | + +#### Alternatives — manual intervention handling + +| Procedure | Use | +|---|---| +| `RequiresUserIntervention(AgentTask)` | Poll whether a task is paused awaiting user input. | +| `GetLastUserInterventionRequestDetails(...)` | Read the most recent intervention request (request, annotations, suggestions). | +| `GetUserInterventionRequestDetails(LogEntry, ...)` | Read the intervention request attached to a specific log entry. | +| `CreateUserInterventionAndWait(var AgentTask, UserInput)` | Reply to an intervention with free-text input and wait. | +| `CreateUserInterventionFromSuggestionAndWait(var AgentTask, SuggestionCode)` | Reply to an intervention with a suggestion code and wait. | +| `ParseUserInterventionRequestType(Text)` | Text → `Enum "Agent User Int Request Type"`. | +| `GetExpectedInterventionRequest(var ExpectedIntRequest)` | Read the YAML's expected intervention request. | +| `ValidateInterventionRequest(AgentTask, ExpectedIntRequest)` | Manual intervention assertion (rarely needed; `FinalizeTurn` covers it). | + +#### Output + +| Procedure | Use | +|---|---| +| `WriteTaskToOutput(var AgentTask, var Output)` | Serialise task details + log entries to JSON. | +| `WriteTaskToOutput(var AgentTask, var Output, FromDateTime)` | Same, filtered by timestamp. | +| `WriteTurnToOutput(var AgentTask, TurnSuccessful, ErrorReason)` | Set the answer used for evaluation in the AI Test Context. Called by `FinalizeTurn` automatically. | + +### `Test Input Json` + +| Procedure | Notes | +|---|---| +| `Element(Name)` | Child by name (errors if missing). | +| `ElementExists(Name, var Found)` | Safe lookup. | +| `ElementAt(Index)` | Array element by 0-based index. | +| `GetElementCount()` | Number of elements. | +| `ValueAsText` / `Integer` / `Decimal` / `Boolean` / `Date` / `DateTime` | Typed accessors. Placeholder resolution is automatic on string-valued accessors. | +| `ValueAsJsonObject()` / `ValueAsJsonToken()` | Escape to native JSON. | diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/ExtensionLogo.png b/src/Tools/Test Framework/Test Libraries/LibraryAgent/ExtensionLogo.png new file mode 100644 index 0000000000..fafe24903e Binary files /dev/null and b/src/Tools/Test Framework/Test Libraries/LibraryAgent/ExtensionLogo.png differ diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/README.md b/src/Tools/Test Framework/Test Libraries/LibraryAgent/README.md new file mode 100644 index 0000000000..d7cfe73fe2 --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/README.md @@ -0,0 +1,7 @@ +# Agent Test Library + +Test helpers for authoring AI agent tests in Business Central. The library provides helper methods to create and manage agent tasks, messages, and user interventions, drive YAML-described turn loops via `Library - Agent.RunTurnAndWait` / `FinalizeTurn`, and integrate with the AI Test Toolkit for evaluation. + +## Public documentation + +- [AI-TEST-AUTHORING.md](AI-TEST-AUTHORING.md) — YAML format reference for AI agent tests, the placeholder syntax, and how each YAML key maps to the library methods that consume it. diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/app.json b/src/Tools/Test Framework/Test Libraries/LibraryAgent/app.json new file mode 100644 index 0000000000..3e09ba9283 --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/app.json @@ -0,0 +1,55 @@ +{ + "id": "5afe217e-507a-40b4-9aa5-f4325b6e8230", + "name": "Agent Test Library", + "publisher": "Microsoft", + "brief": "Test library for automating agent related tests.", + "description": "Test library for automating agent related tests.", + "version": "29.0.0.0", + "privacyStatement": "https://go.microsoft.com/fwlink/?LinkId=724009", + "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120", + "help": "https://go.microsoft.com/fwlink/?linkid=2131960", + "url": "https://go.microsoft.com/fwlink/?LinkId=724011", + "contextSensitiveHelpUrl": "https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-testing-application/", + "logo": "ExtensionLogo.png", + "dependencies": [ + { + "id": "2156302a-872f-4568-be0b-60968696f0d5", + "name": "AI Test Toolkit", + "publisher": "Microsoft", + "version": "29.0.0.0" + }, + { + "id": "dd0be2ea-f733-4d65-bb34-a28f4624fb14", + "name": "Library Assert", + "publisher": "Microsoft", + "version": "29.0.0.0" + } + ], + "internalsVisibleTo": [ + { + "id": "66d1f41b-f670-4788-a2b3-0f82b4e5c05b", + "name": "Agent Test Internal Library", + "publisher": "Microsoft" + } + ], + "screenshots": [], + "platform": "29.0.0.0", + "idRanges": [ + { + "from": 130560, + "to": 130569 + } + ], + "features": [ + "GenerateCaptions", + "TranslationFile", + "NoImplicitWith", + "NoPromotedActionProperties" + ], + "resourceExposurePolicy": { + "allowDebugging": true, + "allowDownloadingSource": true, + "includeSourceInSymbolFile": true + }, + "target": "OnPrem" +} diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/IAgentTestResourceProvider.Interface.al b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/IAgentTestResourceProvider.Interface.al new file mode 100644 index 0000000000..a50ca74eff --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/IAgentTestResourceProvider.Interface.al @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.TestLibraries.Agents; + +/// +/// Interface for resolving test resource files from the consuming test app. +/// Implement this in your test app to provide resource file access to the agent test library. +/// +interface "IAgentTestResourceProvider" +{ + /// + /// Loads a resource file by path and returns its content as an InStream. + /// + /// The resource path as specified in the YAML (e.g. 'datasets/testfiles/invoice.pdf'). + /// Returns the file content as an InStream. + /// Returns the file name extracted from the path. + /// Returns the MIME type of the file. + procedure GetResource(ResourcePath: Text; var ResourceInStream: InStream; var FileName: Text[250]; var MIMEType: Text[100]) +} diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentImpl.Codeunit.al b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentImpl.Codeunit.al new file mode 100644 index 0000000000..1637aa4081 --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentImpl.Codeunit.al @@ -0,0 +1,692 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.TestLibraries.Agents; + +using System.Agents; +using System.Environment; +using System.Environment.Configuration; +using System.Globalization; +using System.TestLibraries.Utilities; +using System.TestTools.AITestToolkit; +using System.TestTools.TestRunner; + +codeunit 130561 "Library - Agent Impl." +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetAgentUnderTest(var AgentUserSecurityID: Guid) + var + AgentTestContext: Codeunit "Agent Test Context"; + begin + EnsureIsTest(); + AgentTestContext.GetAgentUserSecurityID(AgentUserSecurityID); + end; + + procedure EnsureAgentIsActive(AgentUserSecurityID: Guid) + var + Agent: Codeunit Agent; + begin + EnsureIsTest(); + if not Agent.IsActive(AgentUserSecurityID) then + Agent.Activate(AgentUserSecurityID); + end; + + procedure CreateTaskAndWait(var AgentTaskBuilder: Codeunit "Agent Task Builder"; var AgentTask: Record "Agent Task"): Boolean + var + AgentTestContext: Codeunit "Agent Test Context"; + begin + EnsureIsTest(); + AgentTask := AgentTaskBuilder.Create(true, false); // Test tasks do not require message. + AgentTestContext.AddTaskToLog(AgentTask.ID); + Commit(); + exit(WaitForTaskToComplete(AgentTask)); + end; + + procedure CreateMessageAndWait(var AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder"; var AgentTask: Record "Agent Task"): Boolean + var + AgentTestContext: Codeunit "Agent Test Context"; + begin + EnsureIsTest(); + AgentTaskMessageBuilder.SetRequiresReview(false); + AgentTask.Get(AgentTaskMessageBuilder.Create()."Task ID"); + AgentTestContext.AddTaskToLog(AgentTask.ID); + Commit(); + + exit(WaitForTaskToComplete(AgentTask)); + end; + + procedure StopAllTasks() + var + BlankGuid: Guid; + begin + EnsureIsTest(); + StopTasks(BlankGuid); + end; + + procedure StopTasks(AgentUserSecurityId: Guid) + var + AgentTask: Record "Agent Task"; + begin + EnsureIsTest(); + AgentTask.ReadIsolation := IsolationLevel::ReadCommitted; + AgentTask.SetFilter(Status, '<>%1', AgentTask.Status::Paused); + if not IsNullGuid(AgentUserSecurityId) then + AgentTask.SetFilter("Agent User Security ID", AgentUserSecurityId); + if not AgentTask.FindSet() then + exit; + + repeat + StopTask(AgentTask); + until AgentTask.Next() = 0; + end; + + procedure WriteTurnToOutput(var AgentTask: Record "Agent Task"; TurnSuccessful: Boolean; ErrorReason: Text) + var + AITTestContext: Codeunit "AIT Test Context"; + AgentOutputText: Codeunit "Test Output Json"; + TestJsonObject: JsonObject; + AnswerText: Text; + begin + EnsureIsTest(); + AgentOutputText.Initialize(); + WriteTaskToOutput(AgentTask, AgentOutputText); + + TestJsonObject.ReadFrom('{}'); + TestJsonObject.Add('success', TurnSuccessful); + TestJsonObject.Add('errorReason', ErrorReason); + TestJsonObject.Add('taskDetails', AgentOutputText.AsJsonToken()); + TestJsonObject.WriteTo(AnswerText); + + AITTestContext.SetAnswerForQnAEvaluation(AnswerText); + end; + + procedure WriteTaskToOutput(var AgentTask: Record "Agent Task"; var AgentTaskTestOutput: Codeunit "Test Output Json") + begin + EnsureIsTest(); + WriteTaskToOutput(AgentTask, AgentTaskTestOutput, 0DT); + end; + + procedure WriteTaskToOutput(var AgentTask: Record "Agent Task"; var AgentTaskTestOutput: Codeunit "Test Output Json"; FromDateTime: DateTime) + var + AgentMessagesTestOutput: Codeunit "Test Output Json"; + LastLogEntryIdTok: Label 'lastLogEntryId', Locked = true; + LastLogEntryTimestampTok: Label 'lastLogEntryTimestamp', Locked = true; + + AgentLanguageCultureTok: Label 'agentLanguageCulture', Locked = true; + AgentLocaleCultureTok: Label 'agentLocaleCulture', Locked = true; + begin + EnsureIsTest(); + AgentTaskTestOutput.Add(IdTok, Format(AgentTask.ID, 0, 9)); + AgentTaskTestOutput.Add(StatusTok, Format(AgentTask.Status, 0, 9)); + AgentTaskTestOutput.Add(LastLogEntryIdTok, AgentTask."Last Log Entry ID"); + AgentTaskTestOutput.Add(LastLogEntryTimestampTok, AgentTask."Last Log Entry Timestamp"); + AgentTaskTestOutput.Add(AgentLanguageCultureTok, GetAgentLanguageCultureName(AgentTask."Agent User Security ID")); + AgentTaskTestOutput.Add(AgentLocaleCultureTok, GetAgentLocaleCultureName(AgentTask."Agent User Security ID")); + AgentMessagesTestOutput := AgentTaskTestOutput.AddArray(MessagesTok); + AddMessagesToOutput(AgentTask, AgentMessagesTestOutput, FromDateTime); + + OnWriteTaskToOutput(AgentTask, AgentTaskTestOutput, FromDateTime); + end; + + procedure ContinueTaskAndWait(var AgentTask: Record "Agent Task"; UserInput: Text): Boolean + var + UserInterventionRequestEntry: Record "Agent Task Log Entry"; + AgentTaskMessage: Record "Agent Task Message"; + AgentUserIntervention: Codeunit "Agent User Intervention"; + AgentTestContext: Codeunit "Agent Test Context"; + begin + EnsureIsTest(); + UserInterventionRequestEntry.Get(AgentTask.ID, AgentTask."Last Log Entry ID"); + AgentUserIntervention.CreateUserIntervention(UserInterventionRequestEntry, UserInput); + + // Mark all output messages that have been reviewed as sent + AgentTaskMessage.SetRange("Task ID", AgentTask.ID); + AgentTaskMessage.SetRange("Type", AgentTaskMessage."Type"::Output); + AgentTaskMessage.SetRange(Status, AgentTaskMessage.Status::Reviewed); + if AgentTaskMessage.FindSet() then + repeat + // ModifyAll is not currently implemented in the virtual data provider. + AgentTaskMessage.Status := AgentTaskMessage.Status::Sent; + AgentTaskMessage.Modify(); + until AgentTaskMessage.Next() = 0; + + Commit(); + Sleep(GetDefaultWaitTime()); + SelectLatestVersion(); + AgentTask.Find(); + AgentTestContext.AddTaskToLog(AgentTask.ID); + exit(WaitForTaskToComplete(AgentTask)); + end; + + procedure ParseUserInterventionRequestType(UserInterventionRequestTypeText: Text): Enum "Agent User Int Request Type" + var + UserInterventionRequestType: Enum "Agent User Int Request Type"; + IndexOfName: Integer; + begin + EnsureIsTest(); + IndexOfName := UserInterventionRequestType.Names.IndexOf(UserInterventionRequestTypeText); + if IndexOfName <= 0 then + Error(UserInterventionRequestTypeDoesNotExistErr, UserInterventionRequestTypeText); + + UserInterventionRequestType := Enum::"Agent User Int Request Type".FromInteger(UserInterventionRequestType.Ordinals().Get(IndexOfName)); + exit(UserInterventionRequestType); + end; + + procedure RequiresUserIntervention(AgentTask: Record "Agent Task"): Boolean + begin + EnsureIsTest(); + exit((AgentTask.Status = AgentTask.Status::Paused) and AgentTask."Needs Attention"); + end; + + procedure GetLastUserInterventionRequestDetails( + AgentTask: Record "Agent Task"; + var TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; + var TempUserInterventionAnnotation: Record "Agent Annotation" temporary; + var TempUserInterventionSuggestion: Record "Agent Task User Int Suggestion" temporary): Boolean + var + UserInterventionRequestEntry: Record "Agent Task Log Entry"; + AgentUserIntervention: Codeunit "Agent User Intervention"; + begin + EnsureIsTest(); + UserInterventionRequestEntry.SetRange("Task ID", AgentTask.ID); + UserInterventionRequestEntry.SetRange(Type, UserInterventionRequestEntry.Type::"User Intervention Request"); + UserInterventionRequestEntry.SetCurrentKey("ID"); + if not UserInterventionRequestEntry.FindLast() then + exit(false); + + AgentUserIntervention.GetUserInterventionRequestDetails(UserInterventionRequestEntry, TempUserInterventionRequest, TempUserInterventionAnnotation, TempUserInterventionSuggestion); + exit(true); + end; + + procedure GetUserInterventionRequestDetails( + UserInterventionRequestEntry: Record "Agent Task Log Entry"; + var TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; + var TempUserInterventionAnnotation: Record "Agent Annotation" temporary; + var TempUserInterventionSuggestion: Record "Agent Task User Int Suggestion" temporary) + var + AgentUserIntervention: Codeunit "Agent User Intervention"; + begin + EnsureIsTest(); + AgentUserIntervention.GetUserInterventionRequestDetails(UserInterventionRequestEntry, TempUserInterventionRequest, TempUserInterventionAnnotation, TempUserInterventionSuggestion); + end; + + procedure CreateUserInterventionAndWait(var AgentTask: Record "Agent Task"; UserInput: Text): Boolean + var + UserInterventionRequestEntry: Record "Agent Task Log Entry"; + AgentUserIntervention: Codeunit "Agent User Intervention"; + AgentTestContext: Codeunit "Agent Test Context"; + begin + EnsureIsTest(); + AgentTestContext.AddTaskToLog(AgentTask.ID); + UserInterventionRequestEntry.Get(AgentTask.ID, AgentTask."Last Log Entry ID"); + AgentUserIntervention.CreateUserIntervention(UserInterventionRequestEntry, UserInput); + + Commit(); + Sleep(GetDefaultWaitTime()); + SelectLatestVersion(); + AgentTask.Find(); + + exit(WaitForTaskToComplete(AgentTask)); + end; + + procedure CreateUserInterventionFromSuggestionAndWait(var AgentTask: Record "Agent Task"; SuggestionCode: Code[20]): Boolean + var + UserInterventionRequestEntry: Record "Agent Task Log Entry"; + AgentUserIntervention: Codeunit "Agent User Intervention"; + AgentTestContext: Codeunit "Agent Test Context"; + begin + EnsureIsTest(); + AgentTestContext.AddTaskToLog(AgentTask.ID); + UserInterventionRequestEntry.Get(AgentTask.ID, AgentTask."Last Log Entry ID"); + AgentUserIntervention.CreateUserInterventionFromSuggestionCode(UserInterventionRequestEntry, SuggestionCode); + + Commit(); + Sleep(GetDefaultWaitTime()); + SelectLatestVersion(); + AgentTask.Find(); + + exit(WaitForTaskToComplete(AgentTask)); + end; + + procedure WaitForTaskToComplete(var AgentTask: Record "Agent Task"): Boolean + var + AgentTestContext: Codeunit "Agent Test Context"; + WaitTime: Duration; + Timeout: Duration; + TaskCompleted: Boolean; + begin + EnsureIsTest(); + // Logging to capture all tasks that are invoked via UI actions. + // Test can create a task by invoking UI and wait for the task. + AgentTestContext.AddTaskToLog(AgentTask.ID); + + Timeout := GetAgentTaskTimeout(); + VerifyAgentIsActive(AgentTask."Agent User Security ID"); + VerifyTimeout(Timeout); + + while (IsAgentTaskRunning(AgentTask) and (WaitTime < Timeout)) + do begin + Sleep(GetDefaultWaitTime()); + WaitTime += GetDefaultWaitTime(); + SelectLatestVersion(); + AgentTask.Find(); + end; + + TaskCompleted := HasAgentTaskCompleted(AgentTask); + if not TaskCompleted then + StopTask(AgentTask); + + exit(TaskCompleted); + end; + + procedure SetAgentTaskTimeout(NewTaskTimeout: Duration) + begin + EnsureIsTest(); + VerifyTimeout(NewTaskTimeout); + GlobalAgentTaskTimeout := NewTaskTimeout; + end; + + local procedure StopTask(var AgentTask: Record "Agent Task") + begin + AgentTask.Status := AgentTask.Status::"Stopped by User"; + AgentTask.Modify(true); + Commit(); + end; + + local procedure VerifyTimeout(Timeout: Duration) + var + MaxTimeout: Duration; + begin + if Timeout < 0 then + Error(TimeoutCannotBeNegativeErr); + + MaxTimeout := GetMaximumTimeout(); + if Timeout > MaxTimeout then + Error(TimeoutExceedsMaximumErr, Timeout, MaxTimeout); + end; + + local procedure VerifyAgentIsActive(AgentUserSecurityId: Guid) + var + Agent: Codeunit Agent; + begin + if not Agent.IsActive(AgentUserSecurityId) then + Error(AgentNotActiveErr, AgentUserSecurityId); + end; + + local procedure AddMessagesToOutput(var AgentTask: Record "Agent Task"; var AgentMessagesTestOutput: Codeunit "Test Output Json"; FromDateTime: DateTime) + var + AgentTaskMessage: Record "Agent Task Message"; + SingleMessageTestOutput: Codeunit "Test Output Json"; + begin + AgentTaskMessage.SetRange("Task ID", AgentTask.ID); + if FromDateTime <> 0DT then + AgentTaskMessage.SetFilter(SystemCreatedAt, '>=%1', FromDateTime); + + if AgentTaskMessage.FindSet() then + repeat + SingleMessageTestOutput := AgentMessagesTestOutput.Add('{}'); + SingleMessageTestOutput.Add(IdTok, Format(AgentTaskMessage.ID, 0, 4)); + SingleMessageTestOutput.Add(TypeTok, AgentTaskMessage."Type"); + SingleMessageTestOutput.Add(StatusTok, AgentTaskMessage.Status); + SingleMessageTestOutput.Add(ContentTok, GetMessageText(AgentTaskMessage)); + SingleMessageTestOutput.Add(CreatedDateTimeTok, AgentTaskMessage.SystemCreatedAt); + until AgentTaskMessage.Next() = 0; + end; + + local procedure GetAgentTaskTimeout(): Duration + var + BlankDuration: Duration; + begin + if GlobalAgentTaskTimeout <> BlankDuration then + exit(GlobalAgentTaskTimeout); + + GlobalAgentTaskTimeout := 15 * 60 * 1000; // 15 minutes + + exit(GlobalAgentTaskTimeout); + end; + + local procedure GetMaximumTimeout(): Duration + begin + exit(30 * 60 * 1000); // 30 minutes + end; + + local procedure GetDefaultEncoding(): TextEncoding + begin + exit(TextEncoding::UTF8); + end; + + local procedure GetMessageText(var AgentTaskMessage: Record "Agent Task Message"): Text + var + ContentInStream: InStream; + ContentText: Text; + begin + AgentTaskMessage.CalcFields(Content); + AgentTaskMessage.Content.CreateInStream(ContentInStream, GetDefaultEncoding()); + ContentInStream.Read(ContentText); + exit(ContentText); + end; + + local procedure IsAgentTaskRunning(var AgentTask: Record "Agent Task"): Boolean + begin + exit((AgentTask.Status = AgentTask.Status::Ready) or + (AgentTask.Status = AgentTask.Status::Scheduled) or + (AgentTask.Status = AgentTask.Status::Running)); + end; + + local procedure HasAgentTaskCompleted(var AgentTask: Record "Agent Task"): Boolean + begin + exit((AgentTask.Status = AgentTask.Status::Paused) or + (AgentTask.Status = AgentTask.Status::Completed) or + (AgentTask.Status = AgentTask.Status::"Stopped by System") or + (AgentTask.Status = AgentTask.Status::"Stopped by User")); + end; + + local procedure GetAgentLanguageCultureName(AgentUserSecurityId: Guid): Text + var + TempUserSettings: Record "User Settings" temporary; + Agent: Codeunit Agent; + Language: Codeunit Language; + begin + Agent.GetUserSettings(AgentUserSecurityId, TempUserSettings); + + if TempUserSettings."Language ID" <> 0 then + exit(Language.GetCultureName(TempUserSettings."Language ID")); + + exit(''); + end; + + local procedure GetAgentLocaleCultureName(AgentUserSecurityId: Guid): Text + var + TempUserSettings: Record "User Settings" temporary; + Agent: Codeunit Agent; + Language: Codeunit Language; + begin + Agent.GetUserSettings(AgentUserSecurityId, TempUserSettings); + if TempUserSettings."Locale ID" <> 0 then + exit(Language.GetCultureName(TempUserSettings."Locale ID")); + + exit(''); + end; + + local procedure GetDefaultWaitTime(): Duration + begin + exit(500); + end; + + procedure RunTurnAndWait(AgentUserSecurityId: Guid; var AgentTask: Record "Agent Task"; LoadResources: Boolean; AgentTestResourceProvider: Interface IAgentTestResourceProvider): Boolean + var + AITTestContext: Codeunit "AIT Test Context"; + QueryInput: Codeunit "Test Input Json"; + IsTaskInput, IsIntervention : Boolean; + begin + EnsureIsTest(); + QueryInput := AITTestContext.GetQuery(); + + QueryInput.ElementExists(TitleTok, IsTaskInput); + QueryInput.ElementExists(InterventionTok, IsIntervention); + + if IsTaskInput and IsIntervention then + Error(InvalidQueryBothErr); + + if not IsTaskInput and not IsIntervention then + Error(InvalidQueryNeitherErr); + + if IsTaskInput then + exit(CreateTaskFromQueryAndWait(AgentUserSecurityId, QueryInput, AgentTask, LoadResources, AgentTestResourceProvider)); + + exit(ProcessInterventionAndWait(QueryInput, AgentTask)); + end; + + local procedure ProcessInterventionAndWait(QueryInput: Codeunit "Test Input Json"; var AgentTask: Record "Agent Task"): Boolean + var + TempSuggestion: Record "Agent Task User Int Suggestion" temporary; + InterventionInput: Codeunit "Test Input Json"; + SuggestionExists, InstructionExists : Boolean; + begin + InterventionInput := QueryInput.Element(InterventionTok); + + InterventionInput.ElementExists(SuggestionTok, SuggestionExists); + if SuggestionExists then + exit(CreateUserInterventionFromSuggestionAndWait( + AgentTask, + CopyStr(InterventionInput.Element(SuggestionTok).ValueAsText(), 1, MaxStrLen(TempSuggestion.Code)))); + + InterventionInput.ElementExists(InstructionTok, InstructionExists); + if InstructionExists then + exit(CreateUserInterventionAndWait( + AgentTask, InterventionInput.Element(InstructionTok).ValueAsText())); + + Error(InvalidInterventionErr); + end; + + local procedure CreateTaskFromQueryAndWait(AgentUserSecurityId: Guid; QueryInput: Codeunit "Test Input Json"; var AgentTask: Record "Agent Task"; LoadResources: Boolean; AgentTestResourceProvider: Interface IAgentTestResourceProvider): Boolean + var + AgentTaskBuilder: Codeunit "Agent Task Builder"; + AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder"; + AttachmentsInput: Codeunit "Test Input Json"; + TitleInput, FromInput, MessageInput : Codeunit "Test Input Json"; + Assert: Codeunit "Library Assert"; + ResourceInStream: InStream; + FromValue: Text[250]; + MessageValue: Text; + FileName: Text[250]; + MIMEType: Text[100]; + HasTitle, HasFrom, HasMessage, HasAttachments : Boolean; + I: Integer; + begin + TitleInput := QueryInput.ElementExists(TitleTok, HasTitle); + Assert.IsTrue(HasTitle, MissingTitleErr); + +#pragma warning disable AA0139 + AgentTaskBuilder.Initialize(AgentUserSecurityId, TitleInput.ValueAsText()); +#pragma warning restore AA0139 + + FromInput := QueryInput.ElementExists(FromTok, HasFrom); + if not HasFrom then + exit; + +#pragma warning disable AA0139 + FromValue := FromInput.ValueAsText(); +#pragma warning restore AA0139 + + MessageInput := QueryInput.ElementExists(MessageTok, HasMessage); + if HasMessage then + MessageValue := MessageInput.ValueAsText(); + + AgentTaskMessageBuilder.Initialize(FromValue, MessageValue); + AgentTaskMessageBuilder.SetRequiresReview(false); + AgentTaskBuilder.AddTaskMessage(AgentTaskMessageBuilder); + + if LoadResources then begin + AttachmentsInput := QueryInput.ElementExists(AttachmentsTok, HasAttachments); + if HasAttachments then + for I := 0 to AttachmentsInput.GetElementCount() - 1 do begin + AgentTestResourceProvider.GetResource( + AttachmentsInput.ElementAt(I).Element(FileTok).ValueAsText(), + ResourceInStream, FileName, MIMEType); + AgentTaskMessageBuilder.AddAttachment(FileName, MIMEType, ResourceInStream); + end; + end; + + exit(CreateTaskAndWait(AgentTaskBuilder, AgentTask)); + end; + + procedure FinalizeTurn(var AgentTask: Record "Agent Task"; TurnSuccessful: Boolean; ErrorReason: Text) Continue: Boolean + var + AITTestContext: Codeunit "AIT Test Context"; + begin + EnsureIsTest(); + WriteTurnToOutput(AgentTask, TurnSuccessful, ErrorReason); + Commit(); + + ValidateInterventionExpectation(AgentTask); + if not TurnSuccessful then + exit(false); + + exit(AITTestContext.NextTurn()); + end; + + local procedure ValidateInterventionExpectation(AgentTask: Record "Agent Task") + var + TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; + TempAnnotation: Record "Agent Annotation" temporary; + TempSuggestion: Record "Agent Task User Int Suggestion" temporary; + Assert: Codeunit "Library Assert"; + ExpectedInterventionRequest: Codeunit "Test Input Json"; + HasActualIntervention: Boolean; + HasExpectedIntervention: Boolean; + InterventionMustBeDeclared: Boolean; + begin + HasActualIntervention := RequiresUserIntervention(AgentTask); + if HasActualIntervention then + HasActualIntervention := GetLastUserInterventionRequestDetails(AgentTask, TempUserInterventionRequest, TempAnnotation, TempSuggestion); + + HasExpectedIntervention := GetExpectedInterventionRequest(ExpectedInterventionRequest); + + if HasExpectedIntervention and (not HasActualIntervention) then + Assert.Fail(ExpectedInterventionNotFoundErr); + + InterventionMustBeDeclared := HasActualIntervention and (TempUserInterventionRequest.Type in [TempUserInterventionRequest.Type::Assistance, TempUserInterventionRequest.Type::ReviewRecord]); + if InterventionMustBeDeclared and (not HasExpectedIntervention) then + Assert.Fail(UnexpectedInterventionErr); + + if HasActualIntervention and HasExpectedIntervention then + ValidateInterventionDetails(TempUserInterventionRequest, TempSuggestion, ExpectedInterventionRequest); + end; + + local procedure ValidateInterventionDetails( + TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; + var TempSuggestion: Record "Agent Task User Int Suggestion" temporary; + ExpectedInterventionRequest: Codeunit "Test Input Json") + var + TypeInput, SuggestionsInput : Codeunit "Test Input Json"; + Assert: Codeunit "Library Assert"; + ExpectedType: Enum "Agent User Int Request Type"; + TypeExists, SuggestionsExist : Boolean; + I: Integer; + begin + TypeInput := ExpectedInterventionRequest.ElementExists(TypeTok, TypeExists); + if TypeExists then begin + ExpectedType := ParseUserInterventionRequestType(TypeInput.ValueAsText()); + Assert.AreEqual(ExpectedType, TempUserInterventionRequest.Type, + StrSubstNo(TypeMismatchErr, Format(ExpectedType), Format(TempUserInterventionRequest.Type))); + end; + + SuggestionsInput := ExpectedInterventionRequest.ElementExists(SuggestionsTok, SuggestionsExist); + if SuggestionsExist then begin + // Each YAML suggestion code must exist in the actual TempSuggestion table + for I := 0 to SuggestionsInput.GetElementCount() - 1 do begin + TempSuggestion.SetRange(Code, CopyStr(SuggestionsInput.ElementAt(I).ValueAsText(), 1, MaxStrLen(TempSuggestion.Code))); + Assert.IsFalse(TempSuggestion.IsEmpty(), + StrSubstNo(SuggestionMissingErr, SuggestionsInput.ElementAt(I).ValueAsText())); + TempSuggestion.Reset(); + end; + + // Counts must match — no extra actual suggestions beyond what YAML declares + Assert.AreEqual(SuggestionsInput.GetElementCount(), TempSuggestion.Count(), + StrSubstNo(SuggestionCountMismatchErr, SuggestionsInput.GetElementCount(), TempSuggestion.Count())); + end; + end; + + procedure GetExpectedInterventionRequest(var ExpectedInterventionRequest: Codeunit "Test Input Json"): Boolean + var + AITTestContext: Codeunit "AIT Test Context"; + HasInterventionRequest: Boolean; + begin + EnsureIsTest(); + ExpectedInterventionRequest := AITTestContext.GetExpectedData().ElementExists(InterventionRequestTok, HasInterventionRequest); + exit(HasInterventionRequest); + end; + + procedure ValidateInterventionRequest(AgentTask: Record "Agent Task"; ExpectedInterventionRequest: Codeunit "Test Input Json") + var + TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; + TempAnnotation: Record "Agent Annotation" temporary; + TempSuggestion: Record "Agent Task User Int Suggestion" temporary; + TypeInput, SuggestionsInput : Codeunit "Test Input Json"; + Assert: Codeunit "Library Assert"; + ExpectedType: Enum "Agent User Int Request Type"; + TypeExists, SuggestionsExist : Boolean; + I: Integer; + begin + EnsureIsTest(); + Assert.IsTrue(RequiresUserIntervention(AgentTask), + StrSubstNo(NotPausedErr, Format(AgentTask.Status))); + + Assert.IsTrue(GetLastUserInterventionRequestDetails(AgentTask, TempUserInterventionRequest, TempAnnotation, TempSuggestion), + StrSubstNo(NoRequestErr, Format(AgentTask.ID))); + + TypeInput := ExpectedInterventionRequest.ElementExists(TypeTok, TypeExists); + if TypeExists then begin + ExpectedType := ParseUserInterventionRequestType(TypeInput.ValueAsText()); + Assert.AreEqual(ExpectedType, TempUserInterventionRequest.Type, + StrSubstNo(TypeMismatchErr, Format(ExpectedType), Format(TempUserInterventionRequest.Type))); + end; + + SuggestionsInput := ExpectedInterventionRequest.ElementExists(SuggestionsTok, SuggestionsExist); + if SuggestionsExist then + for I := 0 to SuggestionsInput.GetElementCount() - 1 do begin + TempSuggestion.SetRange(Code, CopyStr(SuggestionsInput.ElementAt(I).ValueAsText(), 1, MaxStrLen(TempSuggestion.Code))); + Assert.IsFalse(TempSuggestion.IsEmpty(), + StrSubstNo(SuggestionMissingErr, SuggestionsInput.ElementAt(I).ValueAsText())); + TempSuggestion.Reset(); + end; + end; + + [InternalEvent(false, false)] + local procedure OnWriteTaskToOutput(var AgentTask: Record "Agent Task"; var AgentTaskTestOutput: Codeunit "Test Output Json"; FromDateTime: DateTime) + begin + end; + + /// + /// Ensures the current session is a test session. All publicly exposed methods should call this method. + /// + local procedure EnsureIsTest() + var + NotInTestErr: Label 'This method can only be called in a test context.'; + begin + if not FeatureAccessManagement.IsTestSession() then + Error(NotInTestErr); + end; + + var + FeatureAccessManagement: Codeunit "Feature Access Management"; + GlobalAgentTaskTimeout: Duration; + AgentNotActiveErr: Label 'Agent with user security id %1 is not active.', Comment = '%1 = agent user security id'; + TimeoutExceedsMaximumErr: Label 'The specified timeout %1 exceeds the maximum allowed timeout of %2.', Comment = '%1 = specified timeout, %2 = maximum timeout'; + IdTok: Label 'id', Locked = true; + TypeTok: Label 'type', Locked = true; + StatusTok: Label 'status', Locked = true; + MessagesTok: Label 'messages', Locked = true; + ContentTok: Label 'content', Locked = true; + CreatedDateTimeTok: Label 'createdDateTime', Locked = true; + TimeoutCannotBeNegativeErr: Label 'The task timeout cannot be negative.'; + UserInterventionRequestTypeDoesNotExistErr: Label 'The user intervention request type "%1" does not exist.', Comment = '%1 = user intervention request type'; + InterventionTok: Label 'intervention', Locked = true; + SuggestionTok: Label 'suggestion', Locked = true; + InstructionTok: Label 'instruction', Locked = true; + MessageTok: Label 'message', Locked = true; + TitleTok: Label 'title', Locked = true; + FromTok: Label 'from', Locked = true; + AttachmentsTok: Label 'attachments', Locked = true; + FileTok: Label 'file', Locked = true; + InterventionRequestTok: Label 'intervention_request', Locked = true; + + SuggestionsTok: Label 'suggestions', Locked = true; + InvalidQueryBothErr: Label 'Query cannot contain both ''title'' and ''intervention'' elements.'; + InvalidQueryNeitherErr: Label 'Query must contain either a ''title'' (task input) or ''intervention'' element.'; + InvalidInterventionErr: Label 'Intervention must contain either a ''suggestion'' or ''instruction'' element.'; + MissingTitleErr: Label 'Task input query must contain a ''title'' element.'; + NotPausedErr: Label 'Expected task to require user intervention but status is %1.', Comment = '%1 = task status'; + NoRequestErr: Label 'No user intervention request found on task %1.', Comment = '%1 = task ID'; + TypeMismatchErr: Label 'Expected intervention type %1 but got %2.', Comment = '%1 = expected type, %2 = actual type'; + SuggestionMissingErr: Label 'Expected suggestion "%1" not found in intervention request.', Comment = '%1 = suggestion code'; + SuggestionCountMismatchErr: Label 'Expected %1 suggestions but found %2 actual suggestions.', Comment = '%1 = expected count, %2 = actual count'; + UnexpectedInterventionErr: Label 'Task paused for user intervention but no intervention_request found in expected_data for this turn.'; + ExpectedInterventionNotFoundErr: Label 'Expected intervention_request in expected_data but the task did not pause for user intervention.'; +} \ No newline at end of file diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentInstall.Codeunit.al b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentInstall.Codeunit.al new file mode 100644 index 0000000000..4294ca8dfd --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentInstall.Codeunit.al @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.TestLibraries.Agents; + +codeunit 130562 "Library - Agent Install" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + Subtype = Install; + + trigger OnInstallAppPerDatabase() + var + LibraryAgentUtilities: Codeunit "Library - Agent Utilities"; + begin + LibraryAgentUtilities.VerifyCanRunOnCurrentEnvironment(); + end; +} \ No newline at end of file diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentUtilities.Codeunit.al b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentUtilities.Codeunit.al new file mode 100644 index 0000000000..d21c07c3fd --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/Internal/LibraryAgentUtilities.Codeunit.al @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.TestLibraries.Agents; + +using System.Environment; + +codeunit 130563 "Library - Agent Utilities" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure VerifyCanRunOnCurrentEnvironment() + begin + if not IsSupportedEnvironment() then + Error(UnsupportedEnvironmentErr); + end; + + [NonDebuggable] + local procedure IsSupportedEnvironment(): Boolean + var + FeatureAccessManagement: Codeunit "Feature Access Management"; + EnvironmentInformation: Codeunit "Environment Information"; + begin + if not EnvironmentInformation.IsSaaS() then + exit(true); + + if EnvironmentInformation.IsSandbox() then + exit(true); + + if FeatureAccessManagement.IsEnvironmentPositiveListed() then + exit(true); + + exit(false); + end; + + var + UnsupportedEnvironmentErr: Label 'This functionality is not supported in the current environment. This functionality is only available in sandbox environments.'; +} \ No newline at end of file diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/LibraryAgent.Codeunit.al b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/LibraryAgent.Codeunit.al new file mode 100644 index 0000000000..fa40374aea --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/LibraryAgent.Codeunit.al @@ -0,0 +1,362 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.TestLibraries.Agents; + +using System.Agents; +using System.TestTools.TestRunner; + +/// +/// Provides test helper functions for creating and managing agent tasks, messages, and user interventions. +/// This library simplifies agent testing by providing convenient methods to create tasks, simulate user input, +/// wait for task completion, and capture test output. +/// +codeunit 130560 "Library - Agent" +{ + InherentEntitlements = X; + InherentPermissions = X; + + #region Manage Agent Tasks + + /// + /// Creates a new agent task and waits for the task to complete. + /// + /// The agent task builder codeunit used to configure and create the task. + /// True if the task completed successfully; false if it timed out or failed. + procedure CreateTaskAndWait(var AgentTaskBuilder: Codeunit "Agent Task Builder"): Boolean + var + CreatedAgentTask: Record "Agent Task"; + begin + exit(LibraryAgentImpl.CreateTaskAndWait(AgentTaskBuilder, CreatedAgentTask)); + end; + + /// + /// Creates a new agent task and waits for the task to complete. + /// + /// The agent task builder codeunit used to configure and create the task. + /// The record of the created agent task. + /// True if the task completed successfully; false if it timed out or failed. + procedure CreateTaskAndWait(var AgentTaskBuilder: Codeunit "Agent Task Builder"; var CreatedAgentTask: Record "Agent Task"): Boolean + begin + exit(LibraryAgentImpl.CreateTaskAndWait(AgentTaskBuilder, CreatedAgentTask)); + end; + + /// + /// Stops all tasks for all agents. + /// Use this to clean up tasks during test teardown. + /// + [Scope('OnPrem')] + procedure StopAllTasks() + begin + LibraryAgentImpl.StopAllTasks(); + end; + + /// + /// Stops all agent tasks for a specific agent. + /// Use this to clean up tasks during test teardown. + /// + /// The unique identifier of the agent whose tasks should be deactivated. + procedure StopTasks(AgentUserSecurityId: Guid) + begin + LibraryAgentImpl.StopTasks(AgentUserSecurityId); + end; + + /// + /// Continues a paused task or the task that is waiting for user input. + /// This convenience method creates a user intervention, marks output messages as sent, and waits for the task to complete. + /// + /// The agent task to continue. Will be refreshed with updated state. + /// True if the task completed successfully; false if it timed out or failed. + procedure ContinueTaskAndWait(var AgentTask: Record "Agent Task"): Boolean + var + ContinueTaskTok: Label 'Continue Task', Locked = true; + begin + exit(LibraryAgentImpl.ContinueTaskAndWait(AgentTask, ContinueTaskTok)); + end; + + /// + /// Continues a paused task or the task that is waiting for user input with custom user input. + /// This convenience method creates a user intervention, marks output messages as sent, and waits for the task to complete. + /// + /// The agent task to continue. Will be refreshed with updated state. + /// The user input text to provide when continuing the task. + /// True if the task completed successfully; false if it timed out or failed. + procedure ContinueTaskAndWait(var AgentTask: Record "Agent Task"; UserInput: Text): Boolean + begin + exit(LibraryAgentImpl.ContinueTaskAndWait(AgentTask, UserInput)); + end; + + /// + /// Waits for an agent task to complete, blocking until the task finishes or the default timeout is reached. + /// This method should be used for end to end testing scenarios where the functionality to create task is invoked via product, e.g. through an action. + /// + /// The agent task to wait for. Will be refreshed with final state. + /// True if the task completed successfully; false if it timed out or failed. + procedure WaitForTaskToComplete(var AgentTask: Record "Agent Task"): Boolean + begin + exit(LibraryAgentImpl.WaitForTaskToComplete(AgentTask)); + end; + + /// + /// Writes the agent task details and log entries to a test output JSON structure. + /// + /// The agent task to write to output. + /// The test output JSON codeunit to write to. + procedure WriteTaskToOutput(var AgentTask: Record "Agent Task"; var AgentTaskTestOutput: Codeunit "Test Output Json") + begin + LibraryAgentImpl.WriteTaskToOutput(AgentTask, AgentTaskTestOutput); + end; + + /// + /// Writes the agent task details and log entries to a test output JSON structure, filtered from a specific date/time. + /// + /// The agent task to write to output. + /// The test output JSON codeunit to write to. + /// Only include log entries from this date/time onwards. + procedure WriteTaskToOutput(var AgentTask: Record "Agent Task"; var AgentTaskTestOutput: Codeunit "Test Output Json"; FromDateTime: DateTime) + begin + LibraryAgentImpl.WriteTaskToOutput(AgentTask, AgentTaskTestOutput, FromDateTime); + end; + + /// + /// Writes the turn to output for an agent test including task details, success status, and error reason. + /// Sets the answer used for evaluation in the AI Test Context. + /// + /// The agent task to write to output. + /// Whether the turn completed successfully. + /// The error reason if the turn failed. + procedure WriteTurnToOutput(var AgentTask: Record "Agent Task"; TurnSuccessful: Boolean; ErrorReason: Text) + begin + LibraryAgentImpl.WriteTurnToOutput(AgentTask, TurnSuccessful, ErrorReason); + end; + + /// + /// Sets the agent task timeout. This value will be used for waiting on all task related methods like , , , and other similar methods. + /// + /// The new timeout duration to set for all agent task operations. + procedure SetAgentTaskTimeout(NewTimeout: Duration) + begin + LibraryAgentImpl.SetAgentTaskTimeout(NewTimeout); + end; + #endregion + + #region Manage Agent Messages + + /// + /// Creates a new message for an existing agent task. The method will start the task immediately and wait for the task to complete. + /// The task status is set to Ready after the message is created. + /// + /// The agent task message builder codeunit used to create the message details and the target agent task. Returns with updated status set to Ready. + /// True if the task completed successfully; false if it timed out or failed. + procedure CreateMessageAndWait(var AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder"): Boolean + var + AgentTask: Record "Agent Task"; + begin + exit(LibraryAgentImpl.CreateMessageAndWait(AgentTaskMessageBuilder, AgentTask)); + end; + + /// + /// Creates a new message for an existing agent task. The method will start the task immediately and wait for the task to complete. + /// The task status is set to Ready after the message is created. + /// + /// The agent task message builder codeunit used to create the message details and the target agent task. Returns with updated status set to Ready. + /// The agent task record that the message is associated with. + /// True if the task completed successfully; false if it timed out or failed. + procedure CreateMessageAndWait(var AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder"; var AgentTask: Record "Agent Task"): Boolean + begin + exit(LibraryAgentImpl.CreateMessageAndWait(AgentTaskMessageBuilder, AgentTask)); + end; + + #endregion + + #region Manage User Interventions + + /// + /// Parses the User Intervention Request Type from text to enum. + /// This function is used in data driven testing where the intervention request type is provided as text input. + /// The type must be provided to match the ordinal enum value in English. No translations should be used. + /// + /// The text representation of the user intervention request type. + /// The corresponding for the provided text. If the value does not exist, an error is thrown. + procedure ParseUserInterventionRequestType(UserInterventionRequestTypeText: Text): Enum "Agent User Int Request Type" + begin + exit(LibraryAgentImpl.ParseUserInterventionRequestType(UserInterventionRequestTypeText)); + end; + + /// + /// Specifies whether the task currently requires a user intervention to proceed further. + /// + /// The agent task record to check for user intervention requests. + /// True if the task requires user intervention; false otherwise. + procedure RequiresUserIntervention(AgentTask: Record "Agent Task"): Boolean + begin + exit(LibraryAgentImpl.RequiresUserIntervention(AgentTask)); + end; + + /// + /// Retrieves the details of the last user intervention request including any annotations. + /// Call the method first to check if there is an active user intervention request before calling this method to retrieve the details. + /// + /// The method may return the user intervention that was already handled. + /// The agent task record containing the user intervention request. + /// Returns a temporary record with the intervention request details. + /// Returns a temporary record with any intervention annotations. + /// Returns a temporary record with any intervention suggestions. + /// True if a user intervention request was found; false otherwise. + procedure GetLastUserInterventionRequestDetails(AgentTask: Record "Agent Task"; var TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; var TempUserInterventionAnnotation: Record "Agent Annotation" temporary; var TempUserInterventionSuggestion: Record "Agent Task User Int Suggestion" temporary): Boolean + begin + exit(LibraryAgentImpl.GetLastUserInterventionRequestDetails(AgentTask, TempUserInterventionRequest, TempUserInterventionAnnotation, TempUserInterventionSuggestion)); + end; + + /// + /// Retrieves the details of a user intervention request including any annotations. + /// + /// The log entry containing the user intervention request. + /// Returns a temporary record with the intervention request details. + /// Returns a temporary record with any intervention annotations. + procedure GetUserInterventionRequestDetails(UserInterventionRequestEntry: Record "Agent Task Log Entry"; var TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; var TempUserInterventionAnnotation: Record "Agent Annotation" temporary) + var + TempUserInterventionSuggestion: Record "Agent Task User Int Suggestion" temporary; + begin + LibraryAgentImpl.GetUserInterventionRequestDetails(UserInterventionRequestEntry, TempUserInterventionRequest, TempUserInterventionAnnotation, TempUserInterventionSuggestion); + end; + + /// + /// Retrieves the details of a user intervention request including any annotations. + /// + /// The log entry containing the user intervention request. + /// Returns a temporary record with the intervention request details. + /// Returns a temporary record with any intervention annotations. + /// Returns a temporary record with any intervention suggestions. + procedure GetUserInterventionRequestDetails(UserInterventionRequestEntry: Record "Agent Task Log Entry"; var TempUserInterventionRequest: Record "Agent User Int Request Details" temporary; var TempUserInterventionAnnotation: Record "Agent Annotation" temporary; var TempUserInterventionSuggestion: Record "Agent Task User Int Suggestion" temporary) + begin + LibraryAgentImpl.GetUserInterventionRequestDetails(UserInterventionRequestEntry, TempUserInterventionRequest, TempUserInterventionAnnotation, TempUserInterventionSuggestion); + end; + + /// + /// Creates a user intervention response and then waits for the agent task to complete. + /// + /// The agent task to create the intervention for. Will be refreshed with updated state. + /// The user's text input for the intervention. + /// True if the intervention was created and the task completed successfully; false otherwise. +#pragma warning disable AS0022, AS0024, AS0026, AS0078 + procedure CreateUserInterventionAndWait(var AgentTask: Record "Agent Task"; UserInput: Text): Boolean +#pragma warning restore AS0022, AS0024, AS0026, AS0078 + begin + exit(LibraryAgentImpl.CreateUserInterventionAndWait(AgentTask, UserInput)); + end; + + /// + /// Creates a user intervention response and then waits for the agent task to complete. + /// + /// The log entry containing the intervention request to respond to. + /// The code of the suggestion selected by the user. + /// True if the intervention was created and the task completed successfully; false otherwise. + procedure CreateUserInterventionFromSuggestionAndWait(var AgentTask: Record "Agent Task"; SuggestionCode: Code[20]): Boolean + begin + exit(LibraryAgentImpl.CreateUserInterventionFromSuggestionAndWait(AgentTask, SuggestionCode)); + end; + + #endregion + + #region Data-Driven Turn Processing + + /// + /// Provides input to the agent based on the current turn's query and waits for the task to complete. + /// For task input queries (detected by 'title' element): creates a task with from, title, message, and attachments. + /// For intervention queries (detected by 'intervention' element): responds with a suggestion code or free-text instruction. + /// Throws an error if the query contains both 'title' and 'intervention', or neither. + /// We recommend using this method instead of using the granular methods that are defined below. + /// + /// The security ID of the agent user to give the task to. + /// The agent task record. Task will be created for input queries; must be the existing task for interventions. + /// Interface for resolving attachment files from the consuming test app's resources. + /// True if the task completed without unexpected errors. This does not mean the task produced correct results — only that it finished. False indicates an unexpected error or timeout. + procedure RunTurnAndWait(AgentUserSecurityId: Guid; var AgentTask: Record "Agent Task"; AgentTestResourceProvider: Interface IAgentTestResourceProvider): Boolean + begin + exit(LibraryAgentImpl.RunTurnAndWait(AgentUserSecurityId, AgentTask, true, AgentTestResourceProvider)); + end; + + /// + /// Provides input to the agent based on the current turn's query and waits for the task to complete. + /// Overload without attachment support — use when the query has no attachments. + /// We recommend using this method instead of using the granular methods that are defined below. + /// + /// The security ID of the agent user to give the task to. + /// The agent task record. Task will be created for input queries; must be the existing task for interventions. + /// True if the task completed without unexpected errors. This does not mean the task produced correct results — only that it finished. False indicates an unexpected error or timeout. + procedure RunTurnAndWait(AgentUserSecurityId: Guid; var AgentTask: Record "Agent Task"): Boolean + var + NoOpResourceProvider: Codeunit "NoOp Agent Test Res. Provider"; + begin + exit(LibraryAgentImpl.RunTurnAndWait(AgentUserSecurityId, AgentTask, false, NoOpResourceProvider)); + end; + + /// + /// Gets the expected intervention request from the current turn's expected data. + /// Uses AITTestContext.GetExpectedData() which is multi-turn aware (resolves to current turn). + /// + /// Returns the intervention_request element if found. + /// True if the current turn's expected data contains an intervention_request. + procedure GetExpectedInterventionRequest(var ExpectedInterventionRequest: Codeunit "Test Input Json"): Boolean + begin + exit(LibraryAgentImpl.GetExpectedInterventionRequest(ExpectedInterventionRequest)); + end; + + /// + /// Validates the current intervention request against expected data from the test input. + /// Uses Assert to fail the test with a descriptive message if any check fails. + /// Checks that the task requires intervention, the type matches, and expected suggestions are present. + /// + /// The agent task to validate. + /// The expected intervention request data from the YAML. + procedure ValidateInterventionRequest(AgentTask: Record "Agent Task"; ExpectedInterventionRequest: Codeunit "Test Input Json") + begin + LibraryAgentImpl.ValidateInterventionRequest(AgentTask, ExpectedInterventionRequest); + end; + + /// + /// Writes the turn output and determines if the test should continue to the next turn. + /// Calls Commit() after writing output. + /// Validates that the task did not pause for an unexpected intervention (no intervention_request in expected_data). + /// + /// The agent task for the current turn. + /// Whether the current turn completed successfully. + /// The error reason if the turn failed. + /// Continue is true if there is a next turn and the current turn was successful. + procedure FinalizeTurn(var AgentTask: Record "Agent Task"; TurnSuccessful: Boolean; ErrorReason: Text) Continue: Boolean + begin + Continue := LibraryAgentImpl.FinalizeTurn(AgentTask, TurnSuccessful, ErrorReason); + end; + + #endregion + + #region Manage agent + + /// + /// Gets the agent that is used by the test suite. + /// This method can be used to enable A/B testing in the AI Evaluation Tool. + /// You need to implement the logic in the test to create the agent or use already preconfigured agent. + /// + /// The user security ID of the agent used by the test suite. If no agent is set, a null GUID is returned. + procedure GetAgentUnderTest(var AgentUserSecurityID: Guid) + begin + LibraryAgentImpl.GetAgentUnderTest(AgentUserSecurityID); + end; + + /// + /// Ensures the agent is active. If the agent is not active, it will be activated. + /// + /// The user security ID of the agent to activate. + procedure EnsureAgentIsActive(AgentUserSecurityID: Guid) + begin + LibraryAgentImpl.EnsureAgentIsActive(AgentUserSecurityID); + end; + + #endregion + + var + LibraryAgentImpl: Codeunit "Library - Agent Impl."; +} \ No newline at end of file diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/LibraryCopilotCapability.Codeunit.al b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/LibraryCopilotCapability.Codeunit.al new file mode 100644 index 0000000000..eed53f76de --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/LibraryCopilotCapability.Codeunit.al @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace System.TestLibraries.AI; + +using System.AI; + +/// +/// Provides utility functions for managing copilot capabilities beyond the scope of the Copilot Capability codeunit. +/// +codeunit 130564 "Library - Copilot Capability" +{ + Permissions = tabledata "Copilot Settings" = rm; + + /// + /// Activates a copilot capability defined by the specified extension. + /// + /// The copilot capability to activate. + /// The application ID of the extension defining the copilot capability. + procedure ActivateCopilotCapability(Capability: Enum "Copilot Capability"; AppId: Guid) + var + CopilotSettings: Record "Copilot Settings"; + begin + if CopilotSettings.Get(Capability, AppId) then + if CopilotSettings.Status = CopilotSettings.Status::Active then + exit + else begin + CopilotSettings.Status := CopilotSettings.Status::Active; + CopilotSettings.Modify(); + exit; + end; + + RegisterCopilotCapabilityWithAppId(Capability, AppId); + end; + + local procedure RegisterCopilotCapabilityWithAppId(Capability: Enum "Copilot Capability"; AppId: Guid) + var + CopilotSettings: Record "Copilot Settings"; + CopilotCapability: Codeunit "Copilot Capability"; + ModuleInfo: ModuleInfo; + begin + CopilotCapability.RegisterCapability(Capability, ''); + + NavApp.GetCurrentModuleInfo(ModuleInfo); + if CopilotSettings.Get(Capability, ModuleInfo.Id) then begin + CopilotSettings.Rename(Capability, AppId); + CopilotSettings.Status := CopilotSettings.Status::Active; + CopilotSettings.Modify(); + Commit(); + end; + end; +} \ No newline at end of file diff --git a/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/NoOpAgentTestResProvider.Codeunit.al b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/NoOpAgentTestResProvider.Codeunit.al new file mode 100644 index 0000000000..443acb9d43 --- /dev/null +++ b/src/Tools/Test Framework/Test Libraries/LibraryAgent/src/NoOpAgentTestResProvider.Codeunit.al @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.TestLibraries.Agents; + +/// +/// No-op implementation of IAgentTestResourceProvider. +/// Used by the ProvideInputAndWait overload that does not support attachments. +/// Any call to GetResource will error, since this provider should only be used +/// when LoadResources is false. +/// +codeunit 130565 "NoOp Agent Test Res. Provider" implements "IAgentTestResourceProvider" +{ + Access = Internal; + +#pragma warning disable AA0150 + procedure GetResource(ResourcePath: Text; var ResourceInStream: InStream; var FileName: Text[250]; var MIMEType: Text[100]) +#pragma warning restore AA0150 + begin + Error(NoResourceProviderErr); + end; + + var + NoResourceProviderErr: Label 'No resource provider configured. Use the ProvideInputAndWait overload that accepts an IAgentTestResourceProvider to load attachments.'; +}