diff --git a/libraries/botbuilder-dialogs-fluent/.gitignore b/libraries/botbuilder-dialogs-fluent/.gitignore new file mode 100644 index 0000000000..d5feef8e26 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/.gitignore @@ -0,0 +1,5 @@ +/**/node_modules +/**/.vscode +/**/lib/* +coverage +.nyc_output \ No newline at end of file diff --git a/libraries/botbuilder-dialogs-fluent/.nycrc b/libraries/botbuilder-dialogs-fluent/.nycrc new file mode 100644 index 0000000000..5e26d54160 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/.nycrc @@ -0,0 +1,19 @@ +{ + "extension": [ + ".js" + ], + "include": [ + "lib/**/*.js" + ], + "exclude": [ + "**/node_modules/**", + "**/tests/**", + "**/coverage/**", + "**/*.d.ts" + ], + "reporter": [ + "html" + ], + "all": true, + "cache": true +} \ No newline at end of file diff --git a/libraries/botbuilder-dialogs-fluent/LICENSE b/libraries/botbuilder-dialogs-fluent/LICENSE new file mode 100644 index 0000000000..21071075c2 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/libraries/botbuilder-dialogs-fluent/README.md b/libraries/botbuilder-dialogs-fluent/README.md new file mode 100644 index 0000000000..5bedc2f27f --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/README.md @@ -0,0 +1,132 @@ +# Bot Builder Fluent Dialog + +A Microsoft BotBuilder dialog implementation using event sourcing. + +- [Installing](#installing) +- [Basic Use](#use) +- [Learn More](#learn-more) +- [Documentation](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Class Reference](https://docs.microsoft.com/en-us/javascript/api/botbuilder-dialogs/) +- [GitHub Repo](https://github.com/Microsoft/botbuilder-js) +- [Report Issues](https://github.com/Microsoft/botbuilder-js/issues) + +## Installing +To add the latest version of this package to your bot: + +```bash +npm install --save botbuilder-dialogs-fluent +``` + +#### How to Use Daily Builds +If you want to play with the very latest versions of botbuilder, you can opt in to working with the daily builds. This is not meant to be used in a production environment and is for advanced development. Quality will vary and you should only use daily builds for exploratory purposes. + +To get access to the daily builds of this library, configure npm to use the MyGet feed before installing. + +```bash +npm config set registry https://botbuilder.myget.org/F/botbuilder-v4-js-daily/npm/ +``` + +To reset the registry in order to get the latest published version, run: +```bash +npm config set registry https://registry.npmjs.org/ +``` + +## What's included? + +This module includes a dialog implementation using an approach similar to a durable function. The FluentDialog uses event sourcing to enable arbitrarily complex user interactions in a seemingly uninterrupted execution flow. +Behind the scenes, the yield operator in the dialog flow function yields control of the execution thread back to a dialog flow dispatcher. The dispatcher then commits any new actions that the dialog flow function scheduled (such as starting a child dialog, receiving an activity or making an async call) to storage. +The transparent commit action updates the execution history of the dialog flow by appending all new events into the dialog state, much like an append-only log. +Once the history is updated, the dialog ends its turn and, when it is later resumed, the dispatcher re-executes the entire function from the start to rebuild the local state. +During the replay, if the code tries to begin a child dialog (or do any async work), the dispatcher consults the execution history, replays that result and the function code continues to run. +The replay continues until the function code is finished or until it yields a new suspension task. + +## Use + +After adding the module to your application, modify your app's code to import the multi-turn dialog management capabilities. Near your other `import` and `require` statements, add: + +```javascript +// Import some of the capabities from the module. +const { DialogSet, TextPrompt, ConfirmPrompt } = require("botbuilder-dialogs"); +const { FluentDialog } = require("botbuilder-dialogs-fluent"); +``` + +Then, create one or more `DialogSet` objects to manage the dialogs used in your bot. +A DialogSet is used to collect and execute dialogs. A bot may have more than one +DialogSet, which can be used to group dialogs logically and avoid name collisions. + +Then, create one or more dialogs and add them to the DialogSet. Use the WaterfallDialog +class to construct dialogs defined by a series of functions for sending and receiving input +that will be executed in order. + +More sophisticated multi-dialog sets can be created using the `ComponentDialog` class, which +contains a DialogSet, is itself also a dialog that can be triggered like any other. By building on top ComponentDialog, +developer can bundle multiple dialogs into a single unit which can then be packaged, distributed and reused. + +```javascript +// Set up a storage system that will capture the conversation state. +const storage = new MemoryStorage(); +const convoState = new ConversationState(storage); + +// Define a property associated with the conversation state. +const dialogState = convoState.createProperty('dialogState'); + +// Initialize a DialogSet, passing in a property used to capture state. +const dialogs = new DialogSet(dialogState); + +// Each dialog is identified by a unique name used to invoke the dialog later. +const MAIN_DIALOG = 'MAIN_DIALOG'; +const TEXT_PROMPT = 'TEXT_PROMPT' +const CONFIRM_PROMPT = 'CONFIRM_PROMPT' + +// Implement the dialog flow function +function *dialogFlow(context) { + + let response = yield context.prompt(DIALOG_PROMPT, 'say something'); + yield context.sendActivity(`you said: ${response}`); + + let shouldContinue = yield context.prompt(CONFIRM_PROMPT, 'play another round?', ['yes', 'no']) + if (shouldContinue) { + yield context.restart(); + } + + yield context.sendActivity('good bye!'); +} + +// Add a dialog. Use the included FluentDialog type, initialized with the dialog flow function +dialogs.add(new FluentDialog(MAIN_DIALOG, dialogFlow)); +dialogs.add(new TextPrompt(DIALOG_PROMPT)); +dialogs.add(new ConfirmPrompt(CONFIRM_PROMPT)); + +``` + +Finally, from somewhere in your bot's code, invoke your dialog by name: + +```javascript +// Receive and process incoming events into TurnContext objects in the normal way +adapter.processActivity(req, res, async (turnContext) => { + // Create a DialogContext object from the incoming TurnContext + const dc = await dialogs.createContext(turnContext); + + // ...evaluate message and do other bot logic... + + // If the bot hasn't yet responded, try to continue any active dialog + if (!turnContext.responded) { + const results = await dc.continueDialog(); + if (results.status === DialogTurnStatus.empty) { + await dialogContext.beginDialog(MAIN_DIALOG); + } + } +}); +``` + +## Examples + +See this module in action in these example apps: + +# Learn More + +[Prompts](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=javascript) This module contains several types of built-in prompt that can be used to create dialogs that capture and validate specific data types like dates, numbers and multiple-choice answers. + +[DialogSet](https://docs.microsoft.com/en-us/javascript/api/botbuilder-dialogs/dialogset) DialogSet is a container for multiple dialogs. Once added to a DialogSet, dialogs can be called and interlinked. + +[ComponentDialog](https://docs.microsoft.com/en-us/javascript/api/botbuilder-dialogs/componentdialog) ComponentDialogs are containers that encapsulate multiple sub-dialogs, but can be invoked like normal dialogs. This is useful for re-usable dialogs, or creating multiple dialogs with similarly named sub-dialogs that would otherwise collide. diff --git a/libraries/botbuilder-dialogs-fluent/api-extractor.json b/libraries/botbuilder-dialogs-fluent/api-extractor.json new file mode 100644 index 0000000000..3a46b07098 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/api-extractor.json @@ -0,0 +1,11 @@ +{ + "extends": "../../api-extractor.json", + "mainEntryPointFilePath": "./lib/index.d.ts", + "messages": { + "compilerMessageReporting": { + "TS2321": { + "logLevel": "none" + } + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-dialogs-fluent/etc/botbuilder-dialogs-fluent.api.md b/libraries/botbuilder-dialogs-fluent/etc/botbuilder-dialogs-fluent.api.md new file mode 100644 index 0000000000..2ebf0e59ff --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/etc/botbuilder-dialogs-fluent.api.md @@ -0,0 +1,111 @@ +## API Report File for "botbuilder-dialogs-fluent" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Activity } from 'botbuilder-core'; +import { Choice } from 'botbuilder-dialogs'; +import { Dialog } from 'botbuilder-dialogs'; +import { DialogContext } from 'botbuilder-dialogs'; +import { DialogReason } from 'botbuilder-dialogs'; +import { DialogTurnResult } from 'botbuilder-dialogs'; +import type { Jsonify } from 'type-fest'; +import type { JsonValue } from 'type-fest'; +import { PromptOptions } from 'botbuilder-dialogs'; +import { ResourceResponse } from 'botbuilder-core'; +import { TurnContext } from 'botbuilder-core'; +import { ZodType } from 'zod'; + +// @public +export interface DialogFlowBoundCallable { + (...args: A): R; + project(projector: (value: R) => T): (...args: A) => T; +} + +// @public +export interface DialogFlowContext { + bind any>(func: T): (...args: Parameters) => Jsonify>; + call(task: (context: TurnContext) => Promise): DialogFlowTask; + callAsUser(oauthDialogId: string, task: (token: string, context: TurnContext) => Promise): DialogFlowTask; + callDialog(dialogId: string, options?: object): DialogFlowTask; + get channelId(): string; + get currentUtcTime(): Date; + get dialogId(): string; + get isReplaying(): boolean; + newGuid(): string; + get options(): O; + prompt(dialogId: string, promptOrOptions: string | Partial | PromptOptions): DialogFlowTask; + prompt(dialogId: string, promptOrOptions: string | Partial | PromptOptions, choices: (string | Choice)[]): DialogFlowTask; + receiveActivity(): DialogFlowTask; + restart(options?: O): DialogFlowTask; + sendActivity(activityOrText: string | Partial, speak?: string, inputHint?: string): DialogFlowTask; +} + +// @public +export class DialogFlowError extends Error { + constructor(message: string); +} + +// @public +export interface DialogFlowTask> { + configureRetry(shouldRetry: boolean): this; + configureRetry(settings: RetrySettings): this; + configureRetry(policy: RetryPolicy): this; + get id(): string; + get kind(): string; + project(projector: (value: Jsonify) => T): DialogFlowTask; + project(schema: ZodType): DialogFlowTask; + result(): Generator; + then(continuation: (value: R, context: TurnContext) => T | Promise): DialogFlowTask; +} + +// @public +export type ErrorFilter = (error: any) => boolean; + +// @public +export function exponentialRetry(initialDelay: number, maxDelay: number): RetryDelay; + +// @public +export class FluentDialog extends Dialog { + constructor(dialogId: string, dialogFlow: (context: DialogFlowContext) => Generator); + // (undocumented) + beginDialog(dc: DialogContext, options?: O): Promise; + // (undocumented) + continueDialog(dc: DialogContext): Promise; + // (undocumented) + resumeDialog(dc: DialogContext, reason: DialogReason, result?: any): Promise; + protected runWorkflow(dc: DialogContext, reason: DialogReason, result?: any): Promise; +} + +// @public +export const immediateRetry: RetryDelay; + +// @public +export function linearRetry(retryDelay: number): RetryDelay; + +// @public +export const noRetry: RetryPolicy; + +// @public +export const retryAny: ErrorFilter; + +// @public +export type RetryDelay = (attempt: number) => number; + +// @public +export type RetryPolicy = (error: any, attempt: number) => Promise; + +// @public +export function retryPolicy(settings: RetrySettings): RetryPolicy; + +// @public +export interface RetrySettings { + readonly errorFilter?: ErrorFilter; + readonly maxAttempts: number; + readonly retryDelay: RetryDelay; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/libraries/botbuilder-dialogs-fluent/package.json b/libraries/botbuilder-dialogs-fluent/package.json new file mode 100644 index 0000000000..7402eca434 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/package.json @@ -0,0 +1,51 @@ +{ + "name": "botbuilder-dialogs-fluent", + "author": "Microsoft Corp.", + "description": "Fluent execution flow library for the Microsoft BotBuilder dialog system.", + "version": "4.1.6", + "preview": true, + "license": "MIT", + "keywords": [ + "botbuilder", + "botframework", + "bots", + "chatbots" + ], + "bugs": { + "url": "https://github.com/Microsoft/botbuilder-js/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/botbuilder-js.git" + }, + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "typesVersions": { + "<3.9": { + "*": [ + "_ts3.4/*" + ] + } + }, + "dependencies": { + "botbuilder-core": "4.1.6", + "botbuilder-dialogs": "4.1.6", + "zod": "^3.23.8", + "type-fest": "3.13.1" + }, + "scripts": { + "build": "tsc -b", + "build-docs": "typedoc --theme markdown --entryPoint botbuilder-dialogs-fluent --excludePrivate --includeDeclarations --ignoreCompilerErrors --module amd --out ..\\..\\doc\\botbuilder-dialogs .\\lib\\index.d.ts --hideGenerator --name \"Bot Builder SDK - Dialogs\" --readme none", + "clean": "rimraf _ts3.4 lib tsconfig.tsbuildinfo", + "depcheck": "depcheck --config ../../.depcheckrc --ignores botbuilder-ai,botbuilder-dialogs-adaptive", + "lint": "eslint . --config ../../eslint.config.cjs", + "postbuild": "downlevel-dts lib _ts3.4/lib --checksum", + "test": "yarn build && nyc mocha tests/**/*.test.js --exit", + "test:compat": "api-extractor run --verbose" + }, + "files": [ + "_ts3.4", + "lib", + "src" + ] +} diff --git a/libraries/botbuilder-dialogs-fluent/src/dialogFlowBoundCallable.ts b/libraries/botbuilder-dialogs-fluent/src/dialogFlowBoundCallable.ts new file mode 100644 index 0000000000..f46f479de6 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/dialogFlowBoundCallable.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { JsonValue } from 'type-fest'; + +/** + * Interface for a callable that is bound to a dialog flow context. + * + * @template A The parameter types of the bound function. + * @template R The return type of the bound function. + */ +export interface DialogFlowBoundCallable { + /** + * Invokes the bound function with the given arguments. + * + * @param args The arguments to pass to the function. + * @returns The observable result of the function call. + */ + (...args: A): R; + + /** + * Gets a new function that has the same arguments as the bound function and returns an observable + * value of a different type. + * + * @template T The type of the observable value produced by the projector + * @param projector The callback used to convert the deserialized result to its observable value + * @returns The projected function. + */ + project(projector: (value: R) => T): (...args: A) => T; +} diff --git a/libraries/botbuilder-dialogs-fluent/src/dialogFlowContext.ts b/libraries/botbuilder-dialogs-fluent/src/dialogFlowContext.ts new file mode 100644 index 0000000000..14ec2d12eb --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/dialogFlowContext.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogFlowTask } from './dialogFlowTask'; +import { Activity, ResourceResponse, TurnContext } from 'botbuilder-core'; +import { Choice, PromptOptions } from 'botbuilder-dialogs'; +import type { Jsonify } from 'type-fest'; + +/** + * Context object passed in to a dialog flow generator function. + * + * @param O (Optional) type of options passed to the fluent dialog in the call to `DialogContext.beginDialog()`. + */ +export interface DialogFlowContext { + /** + * Gets whether the workflow is replaying. + */ + get isReplaying(): boolean; + + /** + * Gets the initial information that was passed to the [Dialog](xref:botbuilder-dialogs.Dialog). + */ + get options(): O; + + /** + * Gets the ID that uniquely identifies the channel. Set by the channel. + */ + get channelId(): string; + + /** + * Gets the ID of the fluent dialog + */ + get dialogId(): string; + + /** + * Gets the current date/time in a way that is safe for use in fluent dialog flows. + * + * @returns The current date/time. + * @remarks + * It always returns the same value at specific points in the dialog flow function, + * making it deterministic and safe for replay. + */ + get currentUtcTime(): Date; + + /** + * Creates a new GUID in a way that is safe for use in fluent dialog flows + * + * @returns The new GUID. + * @remarks + * It always returns the same value at specific points in the dialog flow function, + * making it deterministic and safe for replay. + */ + newGuid(): string; + + /** + * Invokes the given asyncronous function. + * + * @template T (Optional) type of the result returned by the asynchronous function. + * @param task The asyncronous function to invoke. + * @returns The task instance which will yield the call result. + */ + call(task: (context: TurnContext) => Promise): DialogFlowTask; + + /** + * Acquires a bearer token from the user and invokes the asynchronous function. + * + * @template T (Optional) type of the result returned by the asynchronous function. + * @param oauthDialogId ID of the oauth dialog used to sign-in the user if needed. + * @param task The asyncronous function to invoke. + * @returns The task instance which will yield the call result. + */ + callAsUser(oauthDialogId: string, task: (token: string, context: TurnContext) => Promise): DialogFlowTask; + + /** + * Runs a child dialog. + * + * @template T (Optional) type of the dialog result. + * @param dialogId ID of the dialog to run. + * @returns The task instance which will yield the dialog result. + */ + callDialog(dialogId: string, options?: object): DialogFlowTask; + + /** + * Helper function to simplify formatting the options for calling a prompt dialog. + * + * @param dialogId ID of the prompt dialog to start. + * @param promptOrOptions The text of the initial prompt to send the user, + * the activity to send as the initial prompt, or + * the object with which to format the prompt dialog. + * + * @returns The task instance which will yield the prompt result. + * + * @remarks + * This helper method formats the object to use as the `options` parameter, and then calls + * callDialog to start the specified prompt dialog. + * + * ```JavaScript + * return yield context.prompt('confirmPrompt', `Are you sure you'd like to quit?`); + * ``` + * + * **See also** + * + * - [prompt](xref:botbuilder-dialogs.DialogContext.prompt) + */ + prompt(dialogId: string, promptOrOptions: string | Partial | PromptOptions): DialogFlowTask; + + /** + * Helper function to simplify formatting the options for calling a prompt dialog. + * + * @param dialogId ID of the prompt dialog to start. + * @param promptOrOptions The text of the initial prompt to send the user, + * the [Activity](xref:botframework-schema.Activity) to send as the initial prompt, or + * the object with which to format the prompt dialog. + * @param choices Optional. Array of choices for the user to choose from, + * for use with a [ChoicePrompt](xref:botbuilder-dialogs.ChoicePrompt). + * + * @returns The task instance which will yield the prompt result. + * + * @remarks + * This helper method formats the object to use as the `options` parameter, and then calls + * callDialog to start the specified prompt dialog. + * + * ```JavaScript + * return yield context.prompt('confirmPrompt', `Are you sure you'd like to quit?`); + * ``` + * + * **See also** + * + * - [prompt](xref:botbuilder-dialogs.DialogContext.prompt) + */ + prompt( + dialogId: string, + promptOrOptions: string | Partial | PromptOptions, + choices: (string | Choice)[], + ): DialogFlowTask; + + /** + * Sends a message to the user. + * + * @param activityOrText The activity or text to send. + * @param speak Optional. The text to be spoken by your bot on a speech-enabled channel. + * @param inputHint Optional. Indicates whether your bot is accepting, expecting, or ignoring user + * input after the message is delivered to the client. One of: 'acceptingInput', 'ignoringInput', + * or 'expectingInput'. Default is 'acceptingInput'. + * @returns The task instance which will yield a ResourceResponse. + * @remarks + * For example: + * ```JavaScript + * yield context.sendActivity(`Hello World`); + * ``` + * + * **See also** + * + * - [sendActivities](xref:botbuilder-core.TurnContext.sendActivity) + */ + sendActivity( + activityOrText: string | Partial, + speak?: string, + inputHint?: string, + ): DialogFlowTask; + + /** + * Waits to receive an event from the user. + * + * @returns The task instance which will yield the received activity. + */ + receiveActivity(): DialogFlowTask; + + /** + * Restarts the dialog flow. + * + * @param options Optional, initial information to pass to the [Dialog](xref:botbuilder-dialogs.Dialog). + * @returns The task instance. + */ + restart(options?: O): DialogFlowTask; + + /** + * Binds a non-deterministic function to the dialog flow. + * + * @param func The function to bind. + * @returns The bound function which is safe for use in the dialog flow. + * @remarks + * The returned function will always return the same value at specific points in the dialog flow function, + * making it deterministic and safe for replay. + */ + bind any>(func: T): (...args: Parameters) => Jsonify>; +} diff --git a/libraries/botbuilder-dialogs-fluent/src/dialogFlowDispatcher.ts b/libraries/botbuilder-dialogs-fluent/src/dialogFlowDispatcher.ts new file mode 100644 index 0000000000..f14729abea --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/dialogFlowDispatcher.ts @@ -0,0 +1,405 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogFlowContext, DialogFlowTask, DialogFlowError, DialogFlowBoundCallable } from './'; +import { + AbstractDialogFlowTask, + AsyncCallTask, + DialogCallTask, + RestartDialogFlowTask, + SuspendDialogFlowTask, + ReceiveActivityTask, + TaskResult, + taskSucceeded, + taskFailed, + defaultProjector, +} from './tasks/'; + +import { createHash, randomUUID } from 'crypto'; +import { Choice, DialogContext, DialogReason, DialogTurnResult, PromptOptions } from 'botbuilder-dialogs'; +import { Activity, ResourceResponse, TokenResponse, TurnContext } from 'botbuilder-core'; +import type { Jsonify, JsonPrimitive } from 'type-fest'; +import * as assert from 'node:assert'; + +/** + * Workflow dispatcher implementation. + */ +export class DialogFlowDispatcher implements DialogFlowContext { + private readonly state: FluentDialogState; + private nextTask: number; + + /** + * Initializes a new WorkflowDispatcher instance. + * + * @param dc The dialog context for the current turn of conversation with the user. + */ + constructor(private readonly dc: DialogContext) { + this.state = dc.activeDialog!.state as FluentDialogState; + this.nextTask = 0; + } + + /** + * @inheritdoc + */ + get options(): O { + return this.state.options; + } + + /** + * @inheritdoc + */ + get channelId(): string { + return this.dc.context.activity.channelId; + } + + /** + * @inheritdoc + */ + get dialogId(): string { + return this.dc.activeDialog!.id; + } + + /** + * @inheritdoc + */ + get isReplaying(): boolean { + return this.nextTask < this.state.history.length || !!this.state.resumeState; + } + + /** + * @inheritdoc + */ + get currentUtcTime(): Date { + return new Date(this.callBuiltIn(Date.now, 'currentUtcTime')); + } + + /** + * @inheritdoc + */ + newGuid(): string { + return this.callBuiltIn(randomUUID, 'newGuid'); + } + + /** + * @inheritdoc + */ + call(task: (context: TurnContext) => Promise): DialogFlowTask { + return new AsyncCallTask(task, defaultProjector); + } + + /** + * @inheritdoc + */ + callAsUser(oauthDialogId: string, task: (token: string, context: TurnContext) => Promise): DialogFlowTask { + return this.callDialog(oauthDialogId).then((tokenResponse, context) => { + if (!tokenResponse || !tokenResponse.token) { + throw new DialogFlowError('Sign-in failed.'); + } + return task(tokenResponse.token, context); + }); + } + + /** + * @inheritdoc + */ + callDialog(dialogId: string, options?: object): DialogFlowTask { + return new DialogCallTask(dialogId, options, defaultProjector); + } + + /** + * Helper function to simplify formatting the options for calling a prompt dialog. + * + * @param dialogId ID of the prompt dialog to start. + * @param promptOrOptions The text of the initial prompt to send the user, + * or the [Activity](xref:botframework-schema.Activity) to send as the initial prompt. + * @param choices Optional. Array of choices for the user to choose from, + * for use with a [ChoicePrompt](xref:botbuilder-dialogs.ChoicePrompt). + * @returns The task instance which will yield the prompt result. + */ + prompt( + dialogId: string, + promptOrOptions: string | Partial | PromptOptions, + choices?: (string | Choice)[], + ): DialogFlowTask { + let options: PromptOptions; + if ( + (typeof promptOrOptions === 'object' && (promptOrOptions as Activity).type !== undefined) || + typeof promptOrOptions === 'string' + ) { + options = { prompt: promptOrOptions as string | Partial }; + } else { + options = { ...(promptOrOptions as PromptOptions) }; + } + + if (choices) { + options.choices = choices; + } + + return this.callDialog(dialogId, options); + } + + /** + * @inheritdoc + */ + sendActivity( + activityOrText: string | Partial, + speak?: string, + inputHint?: string, + ): DialogFlowTask { + return this.call((context: TurnContext) => { + return context.sendActivity(activityOrText, speak, inputHint); + }); + } + + /** + * @inheritdoc + */ + receiveActivity(): DialogFlowTask { + return new ReceiveActivityTask(); + } + + /** + * @inheritdoc + */ + restart(options?: O): DialogFlowTask { + return new RestartDialogFlowTask(options); + } + + /** + * @inheritdoc + */ + bind any>(func: T): DialogFlowBoundCallable, Jsonify>> { + // eslint-disable-next-line @typescript-eslint/no-this-alias -- 'this' in the function below is not the same as 'this' instance + const context = this; + const kind = `boundFunc_${func.name}_${func.length}`; + + function bound(...args: Parameters): Jsonify> { + const callId = context.getHashOf(`${args.toString()}`); + if (context.nextTask == context.state.history.length) { + assert.ok(!context.isReplaying); + + try { + context.state.history.push({ + kind: kind, + hashedId: callId, + result: taskSucceeded(func(...args)), + }); + } catch (error) { + context.state.history.push({ + kind: kind, + hashedId: callId, + result: taskFailed(error), + }); + } + } + + const entry = context.state.history[context.nextTask++]; + + assert.equal(kind, entry.kind); + assert.equal(callId, entry.hashedId); + + if (entry.result.success == true) { + return entry.result.value; + } + + throw new DialogFlowError(entry.result.error); + } + + bound.project = (projector: (value: Jsonify>) => O) => { + return (...args: Parameters): O => projector(bound(...args)); + }; + + return bound; + } + + /** + * Starts or resumes the workflow. + * + * @param dialogFlow The workflow function to run. + * @param reason The reason for starting or resuming the workflow. + * @param resumeResult The result of the previous suspension, if any. + * @returns A promise that resolves to the turn result. + */ + async run( + dialogFlow: (context: DialogFlowContext) => Generator, + reason: DialogReason, + resumeResult?: any, + ): Promise { + const generator = dialogFlow(this); + + // Replay the recorded histroy + let it = generator.next(); + for (; it.done === false && this.nextTask < this.state.history.length; ) { + it = this.replayNext(generator, it.value); + } + + // Resume from the last suspension, unless the workflow is run for the first time + if (reason !== DialogReason.beginCalled) { + assert.ok(!it.done && it.value instanceof SuspendDialogFlowTask); + assert.equal(it.value.kind, this.state.resumeState?.kind); + assert.equal(this.getHashOf(it.value.id), this.state.resumeState?.hashedId); + + this.state.resumeState = undefined; + + it = this.record(generator, it.value, await it.value.onResume(this.dc.context, resumeResult)); + } + + // Execute and resume the async invocations + while (it.done === false && it.value instanceof AsyncCallTask) { + it = this.record(generator, it.value, await it.value.invoke(this.dc.context)); + } + + assert.equal(this.nextTask, this.state.history.length); + + // If the workflow is being suspended, record the suspension point + if (it.done === false) { + assert.ok(it.value instanceof SuspendDialogFlowTask); + + this.state.resumeState = { + hashedId: this.getHashOf(it.value.id), + kind: it.value.kind, + }; + + return await it.value.onSuspend(this.dc); + } + + return await this.dc.endDialog(it.value); + } + + /** + * Gets the hash of a value. + * + * @param value The value to hash. + * @returns The hashed value. + */ + private getHashOf(value: string): string { + return createHash('sha256').update(value).digest('base64'); + } + + /** + * Records the result of a task execution. + * + * @param generator The generator to move forward. + * @param task The task that was executed. + * @param result The result of the task execution. + * @returns The iterator used to continue the workflow. + */ + private record( + generator: Generator, + task: AbstractDialogFlowTask, + result: TaskResult, + ): IteratorResult { + assert.ok(!this.isReplaying); + + this.state.history.push({ + hashedId: this.getHashOf(task.id), + kind: task.kind, + result: result, + }); + + this.nextTask = this.state.history.length; + return task.replay(generator, result); + } + + /** + * Replays the next recorded task execution result. + * + * @param generator The generator to move forward. + * @param task The task that was executed + * @returns The iterator used to continue the workflow. + */ + private replayNext( + generator: Generator, + task: DialogFlowTask, + ): IteratorResult { + assert.ok(this.nextTask < this.state.history.length); + const entry = this.state.history[this.nextTask++]; + + assert.ok(task instanceof AbstractDialogFlowTask, `Expected task to be an instance of AbstractDialogFlowTask`); + assert.equal(task.kind, entry.kind); + assert.equal(this.getHashOf(task.id), entry.hashedId); + + return task.replay(generator, entry.result); + } + + /** + * Calls a built-in function and records its result. + * + * @param func The function to call. + * @param kind The kind of the task. + * @returns The result of the function call. + */ + private callBuiltIn(func: () => T, kind: string): T { + if (this.nextTask == this.state.history.length) { + assert.ok(!this.state.resumeState); + this.state.history.push({ + kind: kind, + hashedId: '', + result: { success: true, value: func() }, + }); + } + + assert.ok(this.nextTask < this.state.history.length); + const entry = this.state.history[this.nextTask++]; + + assert.ok(entry.result.success == true); + assert.equal(kind, entry.kind); + assert.equal('', entry.hashedId); + return entry.result.value; + } +} + +/** + * Represents a dialog execution flow history entry. + */ +export interface DialogFlowHistoryEntry { + /** + * The task's kind. + */ + kind: string; + + /** + * The hash of the task's persistent identifier. + */ + hashedId: string; + + /** + * The result of the task execution. + */ + result: TaskResult; +} + +/** + * Represents the dialog execution flow suspension state. + */ +export interface DialogFlowResumeState { + /** + * The task's kind. + */ + kind: string; + + /** + * The hash of the task's persistent identifier. + */ + hashedId: string; +} + +/** + * Represents the fluent dialog state. + */ +export interface FluentDialogState { + /** + * The dialog flow options. + */ + options: O; + + /** + * The dialog flow execution history. + */ + history: DialogFlowHistoryEntry[]; + + /** + * The dialog flow suspension state. + */ + resumeState?: DialogFlowResumeState; +} diff --git a/libraries/botbuilder-dialogs-fluent/src/dialogFlowError.ts b/libraries/botbuilder-dialogs-fluent/src/dialogFlowError.ts new file mode 100644 index 0000000000..4685454eac --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/dialogFlowError.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Specialized error thrown when the dialog flow resumes after a task failure. + * + * @remarks + * Because errors cannot be reliably reconstructed across suspend/result boundaries, their details + * are captured in the execution history and made available to the dialog function as the message + * property of the DialogFlowError object. + */ +export class DialogFlowError extends Error { + /** + * Initializes a new instance of the DialogFlowError class. + * + * @param message - The error message. + */ + constructor(message: string) { + super(message); + this.name = 'DialogFlowError'; + Object.setPrototypeOf(this, DialogFlowError.prototype); + } +} diff --git a/libraries/botbuilder-dialogs-fluent/src/dialogFlowTask.ts b/libraries/botbuilder-dialogs-fluent/src/dialogFlowTask.ts new file mode 100644 index 0000000000..78a3980173 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/dialogFlowTask.ts @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RetryPolicy, RetrySettings } from './retryPolicy'; +import { ZodType } from 'zod'; +import { TurnContext } from 'botbuilder-core'; +import type { Jsonify } from 'type-fest'; + +/** + * Represents a task that can be executed in a dialog flow. + * + * @template R The task's runtime execution result type + * @template O The task's observable execution result type. + */ +export interface DialogFlowTask> { + /** + * Gets the task's kind + */ + get kind(): string; + + /** + * Gets the persistent identifier for this task instance + */ + get id(): string; + + /** + * Configures the task's retry behavior on on failure + * + * @param shouldRetry Whether to retry the task on failure + * @returns The task instance. + */ + configureRetry(shouldRetry: boolean): this; + + /** + * Configures the task's retry behavior on on failure + * + * @param settings The settings used to configure the retry behavior. + * @returns The task instance. + */ + configureRetry(settings: RetrySettings): this; + + /** + * Configures the task's retry behavior on on failure + * + * @param policy The retry policy used to configure the retry behavior. + * @returns The task instance. + */ + configureRetry(policy: RetryPolicy): this; + + /** + * Configures an asynchronous callback to run after the task is executed + * + * @template T The type of result returned by the continuation callback + * @param continuation The continuation callback + * @returns A new task instance. + * + * @remarks + * Use this method to chain additional processing and avoid storing intermediate results in the + * flow's execution history. + * + * ```JavaScript + * let message = yield context.receiveActivity().then(activity => activity.text); + * ``` + */ + then(continuation: (value: R, context: TurnContext) => T | Promise): DialogFlowTask; + + /** + * Configures the task's deserialized execution result conversion to its observable value. + * + * @template T The type of the observable value produced by the projector + * @param projector The callback used to convert the deserialized result to its observable value + * @returns A new task instance. + * + * @remarks + * The projector will run every time the task replayed by the workflow. + * Use this method to translate the task's observable result to a different type. + * + * ```JavaScript + * let timex = yield context.prompt( + * DATE_TIME_PROMPT, + * 'On what date would you like to travel?' + * ).project(results => new TimexProperty(result[0].timex)); + * ``` + */ + project(projector: (value: Jsonify) => T): DialogFlowTask; + + /** + * Configures the task's deserialized execution result conversion to its observable value. + * + * @template T The type of the observable value produced by the projector + * @param schema The zod schema used to parse the result + * @returns A new task instance. + */ + project(schema: ZodType): DialogFlowTask; + + /** + * Generator method used to yield the task's typed result. + * + * @returns The generator used to yield the task's result. + * + * @remarks + * This method is useful when using typescript to infer the task's result type. + */ + result(): Generator; +} diff --git a/libraries/botbuilder-dialogs-fluent/src/fluentDialog.ts b/libraries/botbuilder-dialogs-fluent/src/fluentDialog.ts new file mode 100644 index 0000000000..637987ff24 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/fluentDialog.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Dialog, DialogContext, DialogReason, DialogTurnResult } from 'botbuilder-dialogs'; +import { DialogFlowContext, DialogFlowTask } from './'; +import { DialogFlowDispatcher, FluentDialogState } from './dialogFlowDispatcher'; + +/** + * Similar with a durable function, a FluentDialog uses event sourcing to enable arbitrarily complex user + * interactions in a seemingly uninterrupted execution flow. + * Behind the scenes, the yield operator in the dialog flow function yields control of the execution thread + * back to a dialog flow dispatcher. The dispatcher then commits any new actions that the dialog flow function + * scheduled (such as starting a child dialog, receiving an activity or making an async call) to storage. + * The transparent commit action updates the execution history of the dialog flow by appending all new events + * into the dialog state, much like an append-only log. + * Once the history is updated, the dialog ends its turn and, when it is later resumed, the dispatcher re-executes + * the entire function from the start to rebuild the local state. + * During the replay, if the code tries to begin a child dialog (or do any async work), the dispatcher consults + * the execution history, replays that result and the function code continues to run. + * The replay continues until the function code is finished or until it yields a new suspension task. + * + * @param O (Optional) type of options passed to the fluent dialog in the call to `DialogContext.beginDialog()`. + * @param T (Optional) type of value returned by the dialog flow function. + */ +export class FluentDialog extends Dialog { + /** + * Creates a new FluentDialog instance. + * + * @param dialogId Unique ID of the dialog within the component or set its being added to. + * @param dialogFlow The workflow generator function. + */ + constructor( + dialogId: string, + private readonly dialogFlow: (context: DialogFlowContext) => Generator, + ) { + super(dialogId); + } + + /** + * @inheritdoc + */ + override beginDialog(dc: DialogContext, options?: O): Promise { + const state: FluentDialogState = dc.activeDialog!.state as FluentDialogState; + state.options = options || ({} as O); + state.history = []; + + return this.runWorkflow(dc, DialogReason.beginCalled); + } + + /** + * @inheritdoc + */ + override continueDialog(dc: DialogContext): Promise { + return this.resumeDialog(dc, DialogReason.continueCalled); + } + + /** + * @inheritdoc + */ + override resumeDialog(dc: DialogContext, reason: DialogReason, result?: any): Promise { + return this.runWorkflow(dc, reason, result); + } + + /** + * Executes the dialog flow up to the next task + * + * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. + * @param reason The [Reason](xref:botbuilder-dialogs.DialogReason) the workflow is being executed. + * @param result Optional, result returned by a dialog called in the previous workflow step. + * @returns A Promise that represents the work queued to execute. + */ + protected runWorkflow(dc: DialogContext, reason: DialogReason, result?: any): Promise { + const dispatcher = new DialogFlowDispatcher(dc); + return dispatcher.run(this.dialogFlow, reason, result); + } +} diff --git a/libraries/botbuilder-dialogs-fluent/src/index.ts b/libraries/botbuilder-dialogs-fluent/src/index.ts new file mode 100644 index 0000000000..a1563704e2 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/index.ts @@ -0,0 +1,14 @@ +/** + * @module botbuilder-dialogs-workflow + */ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export { DialogFlowError } from './dialogFlowError'; +export { DialogFlowTask } from './dialogFlowTask'; +export { DialogFlowContext } from './dialogFlowContext'; +export { DialogFlowBoundCallable } from './dialogFlowBoundCallable'; +export { FluentDialog } from './fluentDialog'; +export * from './retryPolicy'; diff --git a/libraries/botbuilder-dialogs-fluent/src/retryPolicy.ts b/libraries/botbuilder-dialogs-fluent/src/retryPolicy.ts new file mode 100644 index 0000000000..96b537ed65 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/retryPolicy.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Callback invoked when handling execution errors + * + * @param error The execution error + * @param attempt The current retry attempt + * @returns A promise which indicates whether the error should be retried + */ +export type RetryPolicy = (error: any, attempt: number) => Promise; + +/** + * The policy used to never retry + * + * @returns A promise which resolves to false always + */ +export const noRetry: RetryPolicy = () => Promise.resolve(false); + +/** + * Settings used to configure a retry policy + */ +export interface RetrySettings { + /** + * The maximum number of retry attempts + */ + readonly maxAttempts: number; + + /** + * The callback used to get the retry delay for the current attempt + */ + readonly retryDelay: RetryDelay; + + /** + * Optional callback used to decide if an error should be retried + */ + readonly errorFilter?: ErrorFilter; +} + +/** + * Callback used to decide if an error should be retried + * + * @param error The execution error + * @returns true if the error should be retried; otherwise false + */ +export type ErrorFilter = (error: any) => boolean; + +/** + * The default error filter which will accept all errors + * + * @returns always true + */ +export const retryAny: ErrorFilter = () => true; + +/** + * Delegate used to determine the delay before the next retry attempt. + * + * @param attempt The retry attempt number + * @returns The time to wait, in milliseconds, before the next retry + */ +export type RetryDelay = (attempt: number) => number; + +/** + * Retry delay callback which will cause the policy to retry immediately + * + * @returns Unconditionally returns a 0 milliseconds delay + */ +export const immediateRetry: RetryDelay = () => 0; + +/** + * Gets a retry delay callback which will cause the policy to retry at fixed intervals + * + * @param retryDelay The delay, in milliseconds, between retry attempts + * @returns The retry delay callback + */ +export function linearRetry(retryDelay: number): RetryDelay { + return () => retryDelay; +} + +/** + * Gets a retry delay callback which will cause the policy to retry at exponentially increasing + * intervals. + * + * @param initialDelay The initial retry delay + * @param maxDelay The maximum delay. Once this value is reached retries will become linear + * @returns The retry delay callback + */ +export function exponentialRetry(initialDelay: number, maxDelay: number): RetryDelay { + const maxExponentiationAttempts = maxDelay > initialDelay ? Math.floor(Math.log2(maxDelay / initialDelay)) : 0; + + if (maxExponentiationAttempts < 1) { + return linearRetry(maxDelay); + } + + return (attempt) => { + return attempt < maxExponentiationAttempts ? initialDelay * 2 ** attempt : maxDelay; + }; +} + +/** + * Gets a retry policy which will retry according to the supplied settings. + * + * @param settings The settings used to configure the retry policy + * @returns The retry policy + */ +export function retryPolicy(settings: RetrySettings): RetryPolicy { + const { maxAttempts, retryDelay, errorFilter = retryAny } = settings; + + return async (error, attempt) => { + if (attempt < maxAttempts && errorFilter(error)) { + await delay(retryDelay(attempt)); + return true; + } + + return false; + }; +} + +/** + * Returns a promise that is scheduled to complete after the suppiled interval (in milliseconds) + * + * @param interval The delay duration, in milliseconds. + * @returns The delay promise + */ +function delay(interval: number): Promise { + return interval > 0 ? new Promise((resolve) => setTimeout(resolve, interval)) : Promise.resolve(); +} diff --git a/libraries/botbuilder-dialogs-fluent/src/tasks/abstractDialogFlowTask.ts b/libraries/botbuilder-dialogs-fluent/src/tasks/abstractDialogFlowTask.ts new file mode 100644 index 0000000000..67cfe368ff --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/tasks/abstractDialogFlowTask.ts @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + DialogFlowTask, + DialogFlowError, + RetryPolicy, + RetrySettings, + noRetry, + exponentialRetry, + retryPolicy, +} from '../.'; + +import { TurnContext } from 'botbuilder-core'; +import { ZodType } from 'zod'; +import util from 'util'; +import type { Jsonify, JsonValue } from 'type-fest'; + +/** + * Abstract task that can be executed in a fluent dialog flow. + * + * @template R The task's execution result type + * @template O The task's observable execution result type. + */ +export abstract class AbstractDialogFlowTask> implements DialogFlowTask { + /** + * Initializes a new AbstractWorkflowTask instance. + * + * @param projector The callback used to convert the deserialized result to its observable value + * @param retryPolicy - The retry policy used to configure the retry behavior. + */ + constructor( + private readonly projector: (value: Jsonify) => O, + private readonly retryPolicy: RetryPolicy = noRetry, + ) {} + + /** + * @inheritdoc + */ + abstract get kind(): string; + + /** + * @inheritdoc + */ + abstract get id(): string; + + /** + * Gets the default retry settings. + * + * @returns The default retry settings. + */ + protected get defaultRetrySettings(): RetrySettings { + return { + maxAttempts: 5, + retryDelay: exponentialRetry(50, 1000), + errorFilter: (error) => !(error instanceof DialogFlowError), + }; + } + + /** + * Configures the task's retry behavior on on failure + * + * @param {boolean | RetrySettings | RetryPolicy} retryConfig - The retry configuration. + * @returns The task instance. + */ + configureRetry(retryConfig: boolean | RetrySettings | RetryPolicy): this { + if (typeof retryConfig === 'boolean') { + retryConfig = retryConfig ? retryPolicy(this.defaultRetrySettings) : noRetry; + } else if (typeof retryConfig === 'object') { + retryConfig = retryPolicy(retryConfig); + } + + return Object.assign(this.clone(), { + retryPolicy: retryConfig, + }); + } + + /** + * @inheritdoc + */ + abstract then(continuation: (value: R, context: TurnContext) => T | Promise): DialogFlowTask; + + /** + * Configures the task's deserialized execution result conversion to its observable value. + * + * @template T The type of the observable value produced by the projector + * @param projectorOrSchema The zod schema used to parse the result or the projector function + * @returns A new task instance. + */ + project(projectorOrSchema: ZodType | ((value: Jsonify) => T)): DialogFlowTask { + const projector = + projectorOrSchema instanceof ZodType ? (value) => projectorOrSchema.parse(value) : projectorOrSchema; + + return Object.assign(this.clone(), { + projector: projector, + }); + } + + /** + * @inheritdoc + */ + *result(): Generator { + return (yield this) as O; + } + + /** + * Replays the result of a previous task execution. + * + * @param generator The generator to replay. + * @param result The result of the previous task execution. + * @returns The iterator used to continue the workflow. + */ + replay( + generator: Generator, + result: TaskResult, + ): IteratorResult { + return result.success === true + ? generator.next(this.projector(result.value)) + : generator.throw(new DialogFlowError(result.error)); + } + + /** + * Creates a shallow copy of the task. + * + * @returns The cloned task. + */ + protected clone(): this { + return Object.assign(Object.create(this.constructor.prototype), this); + } + + /** + * Called to determine whether to retry the task. + * + * @param error The error that occurred during task execution + * @param attempt The current retry attempt + * @returns true if the task should be retried; otherwise false. + */ + protected shouldRetry(error: any, attempt: number): Promise { + return this.retryPolicy(error, attempt); + } + + /** + * Applies the retry policy to a task. + * + * @param task The task to apply the retry policy to. + * @param args The arguments to pass to the task. + * @returns The result of the task execution. + */ + protected async applyRetryPolicy Promise>( + task: T, + ...args: Parameters + ): Promise> { + for (let attempt: number = 1; ; ++attempt) { + const result = await this.invokeTask(task, ...args); + + if (result.success === true || !(await this.shouldRetry(result.error, attempt))) { + return result; + } + } + } + + /** + * Helper method to invoke a task and return the result as a TaskResult. + * + * @param task The task to apply the retry policy to. + * @param args The arguments to pass to the task. + * @returns The result of the task execution. + */ + private invokeTask Promise>( + task: T, + ...args: Parameters + ): Promise> { + return task(...args) + .then(taskSucceeded) + .catch(taskFailed); + } +} + +/** + * Represents the outcome of a task's execution. + * + * @template T (Optional) The task's runtime execution result type + */ +export type TaskResult = + | { + success: true; + value?: Jsonify; + } + | { + success: false; + error: string; + }; + +/** + * Converts the task execution result to a TaskResult. + * + * @template T The task's runtime execution result type. + * @param value The task's execution result. + * @returns The TaskResult. + */ +export function taskSucceeded(value: T): TaskResult { + return { + success: true, + value: convertToJson(value), + }; +} + +/** + * Converts a task execution failure to a TaskResult. + * + * @template T The task's runtime execution result type. + * @param error The error that occurred during task execution. + * @returns The TaskResult. + */ +export function taskFailed(error: any): TaskResult { + return { + success: false, + error: util.inspect(error, { depth: null, showHidden: true }), + }; +} + +/** + * Default value to json converter + * + * @template T The type of value to convert. + * @param value The value to convert. + * @returns The converted value. + */ +export function convertToJson(value: T): Jsonify { + return JSON.parse(JSON.stringify(value)) as Jsonify; +} + +/** + * The default projector function used to convert the deserialized result to its observable value. + * This function simply returns the passed-in value without any modifications. + * + * @param value The value to convert. + * @template T The type of the value to convert. + * @returns The passed-in value. + */ +export function defaultProjector(value: T): T { + return value; +} diff --git a/libraries/botbuilder-dialogs-fluent/src/tasks/asyncCallTask.ts b/libraries/botbuilder-dialogs-fluent/src/tasks/asyncCallTask.ts new file mode 100644 index 0000000000..37ae0f33b3 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/tasks/asyncCallTask.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogFlowTask } from '../'; +import { AbstractDialogFlowTask, TaskResult, defaultProjector } from './abstractDialogFlowTask'; +import { TurnContext } from 'botbuilder-core'; +import type { Jsonify } from 'type-fest'; + +/** + * Represents the invocation of an async function. + * + * @template R The task's execution result type + * @template O The task's observable execution result type. + */ +export class AsyncCallTask> extends AbstractDialogFlowTask { + /** + * Initializes a new AbstractWorkflowTask instance. + * + * @param task The async function to invoke. + * @param projector The callback used to convert the deserialized result to its observable value + */ + constructor( + private readonly task: (context: TurnContext) => Promise, + projector: (value: Jsonify) => O, + ) { + super(projector); + } + + /** + * @inheritdoc + */ + override get kind(): string { + return 'AsyncCall'; + } + + /** + * @inheritdoc + */ + override get id(): string { + return this.task.toString(); + } + + /** + * @inheritdoc + */ + override then(continuation: (value: R, context: TurnContext) => T | Promise): DialogFlowTask { + return Object.assign(this.project(defaultProjector) as AsyncCallTask, { + task: (context: TurnContext) => this.task(context).then((result) => continuation(result, context)), + }); + } + + /** + * Executes the task. + * + * @param context The turn context for the current turn of conversation with the user. + * @returns The result of the task execution. + */ + invoke(context: TurnContext): Promise> { + return this.applyRetryPolicy(this.task, context); + } + + /** + * @inheritdoc + */ + protected override clone(): this { + return Object.assign(super.clone(), { + task: this.task, + }); + } +} diff --git a/libraries/botbuilder-dialogs-fluent/src/tasks/dialogCallTask.ts b/libraries/botbuilder-dialogs-fluent/src/tasks/dialogCallTask.ts new file mode 100644 index 0000000000..d94437f0cc --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/tasks/dialogCallTask.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SuspendDialogFlowTask } from './suspendDialogFlowTask'; +import { DialogTurnResult, DialogContext } from 'botbuilder-dialogs'; +import type { Jsonify } from 'type-fest'; + +/** + * Represents a task that runs a child dialog and receives the dialog's result. + * + * @template R The task's execution result type + * @template O The task's observable execution result type. + */ +export class DialogCallTask> extends SuspendDialogFlowTask { + /** + * Initializes a new DialogCallTask instance. + * + * @param promptId The dialog ID of the prompt to invoke. + * @param options (Optional) The prompt options. + * @param projector The callback used to convert the deserialized result to its observable value + */ + constructor( + private readonly promptId: string, + private readonly options: object | undefined, + projector: (value: Jsonify) => O, + ) { + super(projector); + } + + /** + * @inheritdoc + */ + override get kind(): string { + return 'DialogCall'; + } + + /** + * @inheritdoc + */ + override get id(): string { + return this.promptId; + } + + /** + * @inheritdoc + */ + override onSuspend(dialogContext: DialogContext): Promise { + return dialogContext.beginDialog(this.promptId, this.options); + } + + /** + * @inheritdoc + */ + protected override clone(): this { + return Object.assign(super.clone(), { + promptId: this.promptId, + options: this.options, + }); + } +} diff --git a/libraries/botbuilder-dialogs-fluent/src/tasks/index.ts b/libraries/botbuilder-dialogs-fluent/src/tasks/index.ts new file mode 100644 index 0000000000..9f36b86012 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/tasks/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './abstractDialogFlowTask'; +export * from './asyncCallTask'; +export * from './suspendDialogFlowTask'; +export * from './dialogCallTask'; +export * from './restartDialogFlowTask'; +export * from './receiveActivityTask'; diff --git a/libraries/botbuilder-dialogs-fluent/src/tasks/receiveActivityTask.ts b/libraries/botbuilder-dialogs-fluent/src/tasks/receiveActivityTask.ts new file mode 100644 index 0000000000..fb87055ff8 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/tasks/receiveActivityTask.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SuspendDialogFlowTask } from './suspendDialogFlowTask'; +import { defaultProjector } from './abstractDialogFlowTask'; +import { DialogTurnResult, DialogContext, Dialog } from 'botbuilder-dialogs'; +import { Activity } from 'botbuilder-core'; + +/** + * Represents a task that prompts the user for input. + */ +export class ReceiveActivityTask extends SuspendDialogFlowTask { + /** + * Initializes a new ReceiveActivityTask instance. + */ + constructor() { + super(defaultProjector, (context) => Promise.resolve(context.activity)); + } + + /** + * @inheritdoc + */ + override get kind(): string { + return 'ReceiveActivity'; + } + + /** + * @inheritdoc + */ + override get id(): string { + return ''; + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- required by the interface + override onSuspend(dialogContext: DialogContext): Promise { + return Promise.resolve(Dialog.EndOfTurn); + } +} diff --git a/libraries/botbuilder-dialogs-fluent/src/tasks/restartDialogFlowTask.ts b/libraries/botbuilder-dialogs-fluent/src/tasks/restartDialogFlowTask.ts new file mode 100644 index 0000000000..afc494edc2 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/tasks/restartDialogFlowTask.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as assert from 'node:assert'; +import { SuspendDialogFlowTask } from './suspendDialogFlowTask'; +import { TaskResult } from './abstractDialogFlowTask'; +import { DialogTurnResult, DialogContext } from 'botbuilder-dialogs'; +import { TurnContext } from 'botbuilder-core'; + +/** + * Represents a task that restarts the dialog flow. + */ +export class RestartDialogFlowTask extends SuspendDialogFlowTask { + /** + * Initializes a new RestartWorkflowTask instance. + * + * @param options (Optional) The options to pass to the restarted workflow. + */ + constructor(private readonly options?: object) { + super( + () => assert.fail('Unexpected call'), + () => assert.fail('Unexpected call'), + ); + } + + /** + * @inheritdoc + */ + override get kind(): string { + return 'Restart'; + } + + /** + * @inheritdoc + */ + override get id(): string { + return ''; + } + + /** + * @inheritdoc + */ + override async onSuspend(dialogContext: DialogContext): Promise { + return dialogContext.replaceDialog(dialogContext.activeDialog!.id, this.options); + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- required by the interface + override onResume(turnContext: TurnContext, result: any): Promise> { + assert.fail('RestartWorkflowTask.onResume will never be called'); + } + + protected override clone(): this { + assert.fail('RestartWorkflowTask.clone will never be called'); + } +} diff --git a/libraries/botbuilder-dialogs-fluent/src/tasks/suspendDialogFlowTask.ts b/libraries/botbuilder-dialogs-fluent/src/tasks/suspendDialogFlowTask.ts new file mode 100644 index 0000000000..95db27867a --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/src/tasks/suspendDialogFlowTask.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogFlowTask } from '../'; +import { AbstractDialogFlowTask, defaultProjector, TaskResult } from './abstractDialogFlowTask'; +import { DialogTurnResult, DialogContext } from 'botbuilder-dialogs'; +import { TurnContext } from 'botbuilder-core'; +import type { Jsonify } from 'type-fest'; + +function resumeDefault(context: TurnContext, result: any): Promise { + return Promise.resolve(result); +} + +/** + * Abstract task that will cause the dialog flow to be suspend and resumed once its result becomes available. + * + * @template R The task's execution result type + * @template O The task's observable execution result type. + */ +export abstract class SuspendDialogFlowTask> extends AbstractDialogFlowTask { + /** + * Initializes a new SuspendDialogFlowTask instance. + * + * @param projector The callback used to convert the deserialized result to its observable value + * @param resume The callback used to retrieve the task result. + */ + constructor( + projector: (value: Jsonify) => O, + private readonly resume: (context: TurnContext, result: any) => Promise = resumeDefault, + ) { + super(projector); + } + + /** + * @inheritdoc + */ + override then(continuation: (value: R, context: TurnContext) => T | Promise): DialogFlowTask> { + return Object.assign(this.project(defaultProjector) as SuspendDialogFlowTask, { + resume: (context, result) => this.resume(context, result).then((prev) => continuation(prev, context)), + }); + } + + /** + * Invoked before the workflow is suspended. + * + * @param dialogContext The dialog context for the current turn of conversation with the user. + * @returns A promise resolving to the dialog turn result. + */ + abstract onSuspend(dialogContext: DialogContext): Promise; + + /** + * Invoked when the workflow is being resumed. + * + * @param turnContext The turn context for the current turn of conversation with the user. + * @param result The result of the invoked task. + * @returns A promise resolving to the invocation result. + */ + onResume(turnContext: TurnContext, result: any): Promise> { + return this.applyRetryPolicy(this.resume, turnContext, result); + } + + /** + * @inheritdoc + */ + protected override clone(): this { + return Object.assign(super.clone(), { + resume: this.resume, + }); + } +} diff --git a/libraries/botbuilder-dialogs-fluent/tests/dialogFlowContext.test.js b/libraries/botbuilder-dialogs-fluent/tests/dialogFlowContext.test.js new file mode 100644 index 0000000000..658b33b8a6 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/tests/dialogFlowContext.test.js @@ -0,0 +1,244 @@ +const { ConversationState, MemoryStorage, TestAdapter } = require('botbuilder-core'); +const { DialogSet, DialogTurnStatus, Dialog, TextPrompt } = require('botbuilder-dialogs'); +const { FluentDialog } = require('../lib'); + +const assert = require('assert'); +const { mock } = require('node:test'); + +const beginMessage = { text: 'begin', type: 'message' }; + +function setupDialogFlowTest(...dialogsOrFlows) { + const adapter = new TestAdapter(async (turnContext) => { + const dc = await dialogs.createContext(turnContext); + + const results = await dc.continueDialog(); + switch (results.status) { + case DialogTurnStatus.empty: + await dc.beginDialog('a0'); + break; + + case DialogTurnStatus.complete: + await turnContext.sendActivity(results.result); + break; + } + await convoState.saveChanges(turnContext); + }); + + // Create new ConversationState with MemoryStorage and register the state as middleware. + const convoState = new ConversationState(new MemoryStorage()); + + // Create a DialogState property, DialogSet and register the dialog flow. + const dialogState = convoState.createProperty('dialogState'); + const dialogs = new DialogSet(dialogState); + + dialogsOrFlows.forEach((dialogOrFlow, index) => { + // Register the dialog flow with a unique ID. + if (!(dialogOrFlow instanceof Dialog)) { + dialogOrFlow = new FluentDialog(`a${index}`, dialogOrFlow); + } + + dialogs.add(dialogOrFlow); + }); + + return adapter; +} + +describe('FluentDialog', function () { + this.timeout(5000); + + it('should send and receive activities.', async function () { + function* testDialogFlow(context) { + yield context.sendActivity('bot responding.'); + + const message = yield context.receiveActivity().then((activity) => activity.text); + assert(message === 'continue', 'Unexpected input received.'); + + return 'ending dialog flow.'; + } + + // Initialize TestAdapter. + const adapter = setupDialogFlowTest(testDialogFlow); + + await adapter + .send(beginMessage) + .assertReply('bot responding.') + .send('continue') + .assertReply('ending dialog flow.') + .startTest(); + }); + + it('should call child dialog.', async function () { + function* testDialogFlow(context) { + const response = yield context.prompt('prompt', 'say something'); + + yield context.sendActivity(`you said: ${response}`); + + const message = yield context.receiveActivity().then((activity) => activity.text); + assert(message === 'continue', 'Unexpected input received.'); + + return 'ending dialog flow.'; + } + + // Initialize TestAdapter. + const adapter = setupDialogFlowTest(testDialogFlow, new TextPrompt('prompt')); + + await adapter + .send(beginMessage) + .assertReply('say something') + .send('hello world') + .assertReply('you said: hello world') + .send('continue') + .assertReply('ending dialog flow.') + .startTest(); + }); + + it('should call async function.', async function () { + const tracked = mock.fn((context) => context.sendActivity('bot responding.')); + + function* testDialogFlow(context) { + yield context.call(tracked); + + const message = yield context.receiveActivity().then((activity) => activity.text); + assert(message === 'continue', 'Unexpected input received.'); + + return 'ending dialog flow.'; + } + + // Initialize TestAdapter. + const adapter = setupDialogFlowTest(testDialogFlow); + + await adapter + .send(beginMessage) + .assertReply('bot responding.') + .send('continue') + .assertReply('ending dialog flow.') + .startTest(); + + assert.strictEqual(tracked.mock.callCount(), 1, 'Unexpected call count.'); + }); + + it('should restart dialog flow.', async function () { + function* testDialogFlow(context) { + yield context.sendActivity('bot responding.'); + + const message = yield context.receiveActivity().then((activity) => activity.text); + assert(message === 'continue', 'Unexpected input received.'); + + let iterationCount = context.options?.iterationCount ?? 0; + if (++iterationCount <= 2) { + yield context.restart({ iterationCount: iterationCount }); + } + + return 'ending dialog flow.'; + } + + // Initialize TestAdapter. + const adapter = setupDialogFlowTest(testDialogFlow); + + await adapter + .send(beginMessage) + .assertReply('bot responding.') + .send('continue') + .assertReply('bot responding.') + .send('continue') + .assertReply('bot responding.') + .send('continue') + .assertReply('ending dialog flow.') + .startTest(); + }); + + it('currentUtcTime should be deterministic.', async function () { + let refTime = undefined; + + function* testDialogFlow(context) { + const time = context.currentUtcTime; + const replayed = context.isReplaying; + + if (!context.isReplaying) { + assert(refTime === undefined, 'Unexpected replay detected.'); + refTime = time; + } else { + assert(refTime.getTime() === time.getTime(), 'Unexpected currentUtcTime received.'); + } + + const message = yield context.receiveActivity().then((activity) => activity.text); + assert(message === 'continue', 'Unexpected input received.'); + assert(replayed, 'Unexpected replay detected.'); + + return 'ending dialog flow.'; + } + + // Initialize TestAdapter. + const adapter = setupDialogFlowTest(testDialogFlow); + + await adapter.send(beginMessage).send('continue').assertReply('ending dialog flow.').startTest(); + }); + + it('newGuid should be deterministic.', async function () { + let refGuid1 = undefined; + let refGuid2 = undefined; + + function* testDialogFlow(context) { + const guid1 = context.newGuid(); + const guid2 = context.newGuid(); + + assert(guid1 !== guid2, 'Unexpected newGuid received.'); + + if (!context.isReplaying) { + refGuid1 = guid1; + refGuid2 = guid2; + } else { + assert(refGuid1 === guid1, 'Unexpected value received.'); + assert(refGuid2 === guid2, 'Unexpected value received.'); + } + + const replayed = context.isReplaying; + const message = yield context.receiveActivity().then((activity) => activity.text); + assert(message === 'continue', 'Unexpected input received.'); + assert(replayed, 'Unexpected replay detected.'); + + return 'ending dialog flow.'; + } + + // Initialize TestAdapter. + const adapter = setupDialogFlowTest(testDialogFlow); + + await adapter.send(beginMessage).send('continue').assertReply('ending dialog flow.').startTest(); + }); + + it('bound functions should be deterministic.', async function () { + let refMessage1 = undefined; + let refMessage2 = undefined; + + function bindableFunction(message) { + return message + new Date().toISOString(); + } + + function* testDialogFlow(context) { + const bound1 = context.bind(bindableFunction); + + const result1 = bound1('test message1'); + const result2 = bound1('test message2'); + const replayed = context.isReplaying; + + if (!context.isReplaying) { + refMessage1 = result1; + refMessage2 = result2; + } else { + assert(refMessage1 === result1, 'Unexpected value received.'); + assert(refMessage2 === result2, 'Unexpected value received.'); + } + + const message = yield context.receiveActivity().then((activity) => activity.text); + assert(message === 'continue', 'Unexpected input received.'); + assert(replayed, 'Unexpected replay detected.'); + + return 'ending dialog flow.'; + } + + // Initialize TestAdapter. + const adapter = setupDialogFlowTest(testDialogFlow); + + await adapter.send(beginMessage).send('continue').assertReply('ending dialog flow.').startTest(); + }); +}); diff --git a/libraries/botbuilder-dialogs-fluent/tests/mocha.opts b/libraries/botbuilder-dialogs-fluent/tests/mocha.opts new file mode 100644 index 0000000000..1bbb1b08af --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/tests/mocha.opts @@ -0,0 +1,4 @@ +--require ts-node/register +--require source-map-support/register +--recursive +**/*.js \ No newline at end of file diff --git a/libraries/botbuilder-dialogs-fluent/tsconfig.json b/libraries/botbuilder-dialogs-fluent/tsconfig.json new file mode 100644 index 0000000000..0d6f772476 --- /dev/null +++ b/libraries/botbuilder-dialogs-fluent/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2015", "dom"], + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index e1229e098c..32d9024b0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14400,16 +14400,8 @@ string-argv@~0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.2.2, string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14524,14 +14516,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15070,6 +15055,11 @@ type-detect@^4.1.0: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== +type-fest@3.13.1: + version "3.13.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.13.1.tgz#bb744c1f0678bea7543a2d1ec24e83e68e8c8706" + integrity sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g== + type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -15977,7 +15967,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16004,15 +15994,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"