diff --git a/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts b/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts index 08440228ad..e2e257e8fe 100644 --- a/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts +++ b/libraries/botbuilder-dialogs-adaptive/src/actions/beginSkill.ts @@ -5,13 +5,11 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ -import { SkillDialog, SkillDialogOptions, DialogContext, DialogTurnResult, DialogManager, BeginSkillDialogOptions } from 'botbuilder-dialogs'; +import { SkillDialog, SkillDialogOptions, DialogContext, DialogTurnResult, BeginSkillDialogOptions } from 'botbuilder-dialogs'; import { BoolExpression, StringExpression } from 'adaptive-expressions'; +import { Activity, ActivityTypes } from 'botbuilder-core'; import { TemplateInterface } from '../template'; -import { Activity, ActivityTypes, BotFrameworkClient, SkillConversationIdFactoryBase } from 'botbuilder-core'; - -const SKILL_CLIENT = Symbol('skillClient'); -const CONVERSATION_ID_FACTORY = Symbol('conversationIdFactory'); +import { skillClientKey, skillConversationIdFactoryKey } from '../skillExtensions'; export class BeginSkill extends SkillDialog { @@ -87,14 +85,14 @@ export class BeginSkill extends SkillDialog { // Setup the skill to call const botId = this.botId.getValue(dcState); const skillHostEndpoint = this.skillHostEndpoint.getValue(dcState); - if (botId) { this.dialogOptions.botId = botId } - if (skillHostEndpoint) { this.dialogOptions.skillHostEndpoint = skillHostEndpoint } - if (this.skillAppId) { this.dialogOptions.skill.id = this.dialogOptions.skill.appId = this.skillAppId.getValue(dcState) } - if (this.skillEndpoint) { this.dialogOptions.skill.skillEndpoint = this.skillEndpoint.getValue(dcState) } - if (this.connectionName) { this.dialogOptions.connectionName = this.connectionName.getValue(dcState) } - if (!this.dialogOptions.conversationState) { this.dialogOptions.conversationState = dc.dialogManager.conversationState } - if (!this.dialogOptions.skillClient) { this.dialogOptions.skillClient = dc.context.turnState.get(SKILL_CLIENT) } - if (!this.dialogOptions.conversationIdFactory) { this.dialogOptions.conversationIdFactory = dc.context.turnState.get(CONVERSATION_ID_FACTORY) } + if (botId) { this.dialogOptions.botId = botId; } + if (skillHostEndpoint) { this.dialogOptions.skillHostEndpoint = skillHostEndpoint; } + if (this.skillAppId) { this.dialogOptions.skill.id = this.dialogOptions.skill.appId = this.skillAppId.getValue(dcState); } + if (this.skillEndpoint) { this.dialogOptions.skill.skillEndpoint = this.skillEndpoint.getValue(dcState); } + if (this.connectionName) { this.dialogOptions.connectionName = this.connectionName.getValue(dcState); } + if (!this.dialogOptions.conversationState) { this.dialogOptions.conversationState = dc.dialogManager.conversationState; } + if (!this.dialogOptions.skillClient) { this.dialogOptions.skillClient = dc.context.turnState.get(skillClientKey); } + if (!this.dialogOptions.conversationIdFactory) { this.dialogOptions.conversationIdFactory = dc.context.turnState.get(skillConversationIdFactoryKey); } // Get the activity to send to the skill. options = {} as BeginSkillDialogOptions; @@ -126,17 +124,6 @@ export class BeginSkill extends SkillDialog { protected onComputeId(): string { const appId = this.skillAppId ? this.skillAppId.toString() : ''; const activity = this.activity ? this.activity.toString() : ''; - return `Skill[${appId}:${activity}]`; - } - - /** - * Configures the skill client and conversation ID factory to use. - * @param dm DialogManager to configure. - * @param skillClient Skill client instance to use. - * @param conversationIdFactory Conversation ID factory to use. - */ - static setSkillHostOptions(dm: DialogManager, skillClient: BotFrameworkClient, conversationIdFactory: SkillConversationIdFactoryBase): void { - dm.initialTurnState.set(SKILL_CLIENT, skillClient); - dm.initialTurnState.set(CONVERSATION_ID_FACTORY, conversationIdFactory); + return `Skill[${ appId }:${ activity }]`; } } \ No newline at end of file diff --git a/libraries/botbuilder-dialogs-adaptive/src/index.ts b/libraries/botbuilder-dialogs-adaptive/src/index.ts index 3a4bec8882..0cb1133db1 100644 --- a/libraries/botbuilder-dialogs-adaptive/src/index.ts +++ b/libraries/botbuilder-dialogs-adaptive/src/index.ts @@ -19,6 +19,7 @@ export * from './input'; export * from './luis'; export * from './recognizers'; export * from './selectors'; +export * from './skillExtensions'; export * from './templates'; export * from './adaptiveDialog'; export * from './languageGenerationMiddleware'; diff --git a/libraries/botbuilder-dialogs-adaptive/src/skillExtensions.ts b/libraries/botbuilder-dialogs-adaptive/src/skillExtensions.ts new file mode 100644 index 0000000000..5bf70887d8 --- /dev/null +++ b/libraries/botbuilder-dialogs-adaptive/src/skillExtensions.ts @@ -0,0 +1,31 @@ +/** + * @module botbuilder-dialogs-adaptive + */ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { DialogManager } from 'botbuilder-dialogs'; +import { BotFrameworkClient, SkillConversationIdFactoryBase } from 'botbuilder-core'; + +export const skillClientKey = Symbol('SkillClient'); +export const skillConversationIdFactoryKey = Symbol('SkillConversationIdFactory'); + +export class SkillExtensions { + /** + * Configures the skill client to use. + */ + public static useSkillClient(dialogManager: DialogManager, skillClient: BotFrameworkClient): DialogManager { + dialogManager.initialTurnState.set(skillClientKey, skillClient); + return dialogManager; + } + + /** + * Configures the skill conversation id factory to use. + */ + public static useSkillConverationIdFactory(dialogManager: DialogManager, skillConversationIdFactory: SkillConversationIdFactoryBase): DialogManager { + dialogManager.initialTurnState.set(skillConversationIdFactoryKey, skillConversationIdFactory); + return dialogManager; + } +} \ No newline at end of file diff --git a/libraries/botbuilder-dialogs-adaptive/tests/beginSkill.test.js b/libraries/botbuilder-dialogs-adaptive/tests/beginSkill.test.js index d0bd2626d7..ead34aa740 100644 --- a/libraries/botbuilder-dialogs-adaptive/tests/beginSkill.test.js +++ b/libraries/botbuilder-dialogs-adaptive/tests/beginSkill.test.js @@ -12,7 +12,7 @@ const { } = require('botbuilder-core'); const { BoolExpression, StringExpression } = require('adaptive-expressions'); const { DialogManager, DialogTurnStatus } = require('botbuilder-dialogs'); -const { BeginSkill } = require('../lib') +const { BeginSkill, SkillExtensions } = require('../lib') class SimpleConversationIdFactory extends SkillConversationIdFactoryBase { @@ -81,7 +81,8 @@ describe('BeginSkill', function() { const conversationState = new ConversationState(new MemoryStorage()); const dm = new DialogManager(); dm.conversationState = conversationState; - BeginSkill.setSkillHostOptions(dm, skillClient, new SimpleConversationIdFactory()); + SkillExtensions.useSkillClient(dm, skillClient); + SkillExtensions.useSkillConverationIdFactory(dm, new SimpleConversationIdFactory()); // Setup skill dialog const dialog = new BeginSkill(); diff --git a/libraries/botbuilder-dialogs/src/dialogHelper.ts b/libraries/botbuilder-dialogs/src/dialogHelper.ts index 470eab64eb..9cce49d085 100644 --- a/libraries/botbuilder-dialogs/src/dialogHelper.ts +++ b/libraries/botbuilder-dialogs/src/dialogHelper.ts @@ -14,7 +14,7 @@ import { Activity, TurnContext, } from 'botbuilder-core'; import { DialogContext, DialogState } from './dialogContext'; -import { Dialog, DialogTurnStatus } from './dialog'; +import { Dialog, DialogTurnStatus, DialogTurnResult } from './dialog'; import { DialogEvents } from './dialogEvents'; import { DialogSet } from './dialogSet'; import { AuthConstants, GovConstants, isSkillClaim } from './prompts/skillsHelpers'; @@ -80,7 +80,7 @@ export async function runDialog(dialog: Dialog, context: TurnContext, accessor: } if (result.status === DialogTurnStatus.complete || result.status === DialogTurnStatus.cancelled) { - if (sendEoCToParent(context)) { + if (shouldSendEndOfConversationToParent(context, result)) { const endMessageText = `Dialog ${ dialog.id } has **completed**. Sending EndOfConversation.`; await context.sendTraceActivity(telemetryEventName, result.result, undefined, `${ endMessageText }`); @@ -95,7 +95,12 @@ export async function runDialog(dialog: Dialog, context: TurnContext, accessor: * Helper to determine if we should send an EoC to the parent or not. * @param context */ -function sendEoCToParent(context: TurnContext): boolean { +export function shouldSendEndOfConversationToParent(context: TurnContext, turnResult: DialogTurnResult): boolean { + if (!(turnResult.status == DialogTurnStatus.complete || turnResult.status == DialogTurnStatus.cancelled)) { + // The dialog is still going, don't return EoC. + return false; + } + const claimIdentity = context.turnState.get(context.adapter.BotIdentityKey); // Inspect the cached ClaimsIdentity to determine if the bot was called from another bot. if (claimIdentity && isSkillClaim(claimIdentity.claims)) { @@ -114,7 +119,7 @@ function sendEoCToParent(context: TurnContext): boolean { } // Recursively walk up the DC stack to find the active DC. -function getActiveDialogContext(dialogContext: DialogContext): DialogContext { +export function getActiveDialogContext(dialogContext: DialogContext): DialogContext { const child = dialogContext.child; if (!child) { return dialogContext; @@ -123,7 +128,7 @@ function getActiveDialogContext(dialogContext: DialogContext): DialogContext { return getActiveDialogContext(child); } -function isFromParentToSkill(context: TurnContext): boolean { +export function isFromParentToSkill(context: TurnContext): boolean { // If a SkillConversationReference exists, it was likely set by the SkillHandler and the bot is acting as a parent. if (context.turnState.get(SkillConversationReferenceKey)) { return false; diff --git a/libraries/botbuilder-dialogs/src/dialogManager.ts b/libraries/botbuilder-dialogs/src/dialogManager.ts index c5006f8d47..68e35b4bc7 100644 --- a/libraries/botbuilder-dialogs/src/dialogManager.ts +++ b/libraries/botbuilder-dialogs/src/dialogManager.ts @@ -6,7 +6,7 @@ * Licensed under the MIT License. */ -import { TurnContext, BotState, ConversationState, UserState, ActivityTypes, BotStateSet, TurnContextStateCollection } from 'botbuilder-core'; +import { TurnContext, BotState, ConversationState, UserState, ActivityTypes, BotStateSet, TurnContextStateCollection, Activity } from 'botbuilder-core'; import { DialogContext, DialogState } from './dialogContext'; import { DialogTurnResult, Dialog, DialogTurnStatus } from './dialog'; import { Configurable } from './configurable'; @@ -14,6 +14,8 @@ import { DialogSet } from './dialogSet'; import { DialogStateManagerConfiguration, DialogStateManager } from './memory'; import { DialogEvents } from './dialogEvents'; import { DialogTurnStateConstants } from './dialogTurnStateConstants'; +import { isSkillClaim } from './prompts/skillsHelpers'; +import { isFromParentToSkill, getActiveDialogContext, shouldSendEndOfConversationToParent } from './dialogHelper'; const LAST_ACCESS = '_lastAccess'; const CONVERSATION_STATE = 'ConversationState'; @@ -51,18 +53,27 @@ export interface DialogManagerConfiguration { } export class DialogManager extends Configurable { - private dialogSet: DialogSet = new DialogSet(); - private rootDialogId: string; - private dialogStateProperty: string; + private _rootDialogId: string; + private readonly _dialogStateProperty: string; private readonly _initialTurnState: TurnContextStateCollection = new TurnContextStateCollection(); public constructor(rootDialog?: Dialog, dialogStateProperty?: string) { super(); if (rootDialog) { this.rootDialog = rootDialog; } - this.dialogStateProperty = dialogStateProperty || 'DialogStateProperty'; + this._dialogStateProperty = dialogStateProperty || 'DialogStateProperty'; this._initialTurnState.set(DialogTurnStateConstants.dialogManager, this); } + /** + * Bots persisted conversation state. + */ + public conversationState: ConversationState; + + /** + * Optional. Bots persisted user state. + */ + public userState?: UserState; + /** * Values that will be copied to the `TurnContext.turnState` at the beginning of each turn. */ @@ -70,37 +81,38 @@ export class DialogManager extends Configurable { return this._initialTurnState; } - /** - * Bots persisted conversation state. - */ - public conversationState: ConversationState; - /** * Root dialog to start from [onTurn()](#onturn) method. */ - public set rootDialog(dialog: Dialog) { - this.dialogSet.add(dialog); - this.rootDialogId = dialog.id; + public set rootDialog(value: Dialog) { + this.dialogs = new DialogSet(); + if (value) { + this._rootDialogId = value.id; + this.dialogs.telemetryClient = value.telemetryClient; + this.dialogs.add(value); + } else { + this._rootDialogId = undefined; + } } public get rootDialog(): Dialog { - return this.rootDialogId ? this.dialogSet.find(this.rootDialogId) : undefined; + return this._rootDialogId ? this.dialogs.find(this._rootDialogId) : undefined; } /** - * Optional. Bots persisted user state. + * Global dialogs that you want to have be callable. */ - public userState?: UserState; + public dialogs: DialogSet = new DialogSet(); /** - * Optional. Number of milliseconds to expire the bots conversation state after. + * Optional. Path resolvers and memory scopes used for conversations with the bot. */ - public expireAfter?: number; + public stateConfiguration?: DialogStateManagerConfiguration; /** - * Optional. Path resolvers and memory scopes used for conversations with the bot. + * Optional. Number of milliseconds to expire the bots conversation state after. */ - public stateConfiguration?: DialogStateManagerConfiguration; + public expireAfter?: number; public configure(config: Partial): this { return super.configure(config); @@ -108,7 +120,7 @@ export class DialogManager extends Configurable { public async onTurn(context: TurnContext): Promise { // Ensure properly configured - if (!this.rootDialogId) { throw new Error(`DialogManager.onTurn: the bot's 'rootDialog' has not been configured.`); } + if (!this._rootDialogId) { throw new Error(`DialogManager.onTurn: the bot's 'rootDialog' has not been configured.`); } // Copy initial turn state to context this.initialTurnState.forEach((value, key) => { @@ -153,31 +165,37 @@ export class DialogManager extends Configurable { await lastAccessProperty.set(context, lastAccess.toISOString()); // get dialog stack - const dialogsProperty = this.conversationState.createProperty(this.dialogStateProperty); + const dialogsProperty = this.conversationState.createProperty(this._dialogStateProperty); const dialogState: DialogState = await dialogsProperty.get(context, {}); // Create DialogContext - const dc = new DialogContext(this.dialogSet, context, dialogState); + const dc = new DialogContext(this.dialogs, context, dialogState); // Configure dialog state manager and load scopes const dialogStateManager = new DialogStateManager(dc, this.stateConfiguration); await dialogStateManager.loadAllScopes(); let turnResult: DialogTurnResult; - while (true) { + /** + * Loop as long as we are getting valid onError handled we should continue executing the actions for the turn. + * NOTE: We loop around this block because each pass through we either complete the turn and break out of the loop + * or we have had an exception AND there was an onError action which captured the error. We need to continue the + * turn based on the actions the onError handler introduced. + */ + let endOfTurn = false; + while (!endOfTurn) { try { - if (dc.activeDialog) { - // Continue dialog execution - // - This will apply any queued up interruptions and execute the current/next step(s). - turnResult = await dc.continueDialog(); - if (turnResult.status == DialogTurnStatus.empty) { - // Begin root dialog - turnResult = await dc.beginDialog(this.rootDialogId); - } + const claimIdentity = context.turnState.get(context.adapter.BotIdentityKey); + if (claimIdentity && isSkillClaim(claimIdentity.claims)) { + // The bot is running as a skill. + turnResult = await this.handleSkillOnTurn(dc); } else { - turnResult = await dc.beginDialog(this.rootDialogId); + // The bot is running as a root bot. + turnResult = await this.handleBotOnTurn(dc); } - break; + + // turn successfully completed, break the loop + endOfTurn = true; } catch (err) { const handled = await dc.emitEvent(DialogEvents.error, err, true, true); if (!handled) { @@ -192,20 +210,93 @@ export class DialogManager extends Configurable { // Save BotState changes await botStateSet.saveAllChanges(dc.context, false); - // Send trace of memory to emulator - let snapshotDc = dc; - while (snapshotDc.child) { - snapshotDc = snapshotDc.child; - } - const snapshot: object = snapshotDc.state.getMemorySnapshot(); + return { turnResult: turnResult }; + } + + // Helper to send a trace activity with a memory snapshot of the active dialog DC. + private async sendStateSnapshotTrace(dc: DialogContext, traceLabel: string): Promise { + // send trace of memory + const snapshot: object = getActiveDialogContext(dc).state.getMemorySnapshot(); await dc.context.sendActivity({ type: ActivityTypes.Trace, name: 'BotState', valueType: 'https://www.botframework.com/schemas/botState', value: snapshot, - label: 'Bot State' + label: traceLabel }); + } - return { turnResult: turnResult }; + private async handleSkillOnTurn(dc: DialogContext): Promise { + // The bot is running as a skill. + const turnContext = dc.context; + + // Process remote cancellation. + if (turnContext.activity.type === ActivityTypes.EndOfConversation && dc.activeDialog && isFromParentToSkill(turnContext)) { + // Handle remote cancellation request from parent. + const activeDialogContext = getActiveDialogContext(dc); + + const remoteCancelText = 'Skill was canceled through an EndOfConversation activity from the parent.'; + await turnContext.sendTraceActivity(`DialogManager.onTurn()`, undefined, undefined, remoteCancelText); + + // Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the right order. + return await activeDialogContext.cancelAllDialogs(true); + } + + // Handle reprompt + // Process a reprompt event sent from the parent. + if (turnContext.activity.type === ActivityTypes.Event && turnContext.activity.name == DialogEvents.repromptDialog) { + if (!dc.activeDialog) { + return { status: DialogTurnStatus.empty }; + } + + await dc.repromptDialog(); + return { status: DialogTurnStatus.waiting }; + } + + // Continue execution + // - This will apply any queued up interruptions and execute the current/next step(s). + var turnResult = await dc.continueDialog(); + if (turnResult.status == DialogTurnStatus.empty) { + // restart root dialog + var startMessageText = `Starting ${ this._rootDialogId }.`; + await turnContext.sendTraceActivity('DialogManager.onTurn()', undefined, undefined, startMessageText); + turnResult = await dc.beginDialog(this._rootDialogId); + } + + await this.sendStateSnapshotTrace(dc, 'Skill State'); + + if (shouldSendEndOfConversationToParent(turnContext, turnResult)) { + var endMessageText = `Dialog ${ this._rootDialogId } has **completed**. Sending EndOfConversation.`; + await turnContext.sendTraceActivity('DialogManager.onTurn()', turnResult.result, undefined, endMessageText); + + // Send End of conversation at the end. + const activity: Partial = { type: ActivityTypes.EndOfConversation, value: turnResult.result, locale: turnContext.activity.locale }; + await turnContext.sendActivity(activity); + } + + return turnResult; + } + + private async handleBotOnTurn(dc: DialogContext): Promise { + let turnResult: DialogTurnResult; + + // the bot is running as a root bot. + if (!dc.activeDialog) { + // start root dialog + turnResult = await dc.beginDialog(this._rootDialogId); + } else { + // Continue execution + // - This will apply any queued up interruptions and execute the current/next step(s). + turnResult = await dc.continueDialog(); + + if (turnResult.status == DialogTurnStatus.empty) { + // restart root dialog + turnResult = await dc.beginDialog(this._rootDialogId); + } + } + + await this.sendStateSnapshotTrace(dc, 'Bot State'); + + return turnResult; } } \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/tests/dialogManager.test.js b/libraries/botbuilder-dialogs/tests/dialogManager.test.js new file mode 100644 index 0000000000..b91970fa22 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/dialogManager.test.js @@ -0,0 +1,284 @@ +const { ok, strictEqual } = require('assert'); +const { + ActivityTypes, + AutoSaveStateMiddleware, + ConversationState, + InputHints, + MemoryStorage, + MessageFactory, + SkillConversationReferenceKey, + TestAdapter, + UserState, +} = require('botbuilder-core'); +const { + ComponentDialog, + DialogReason, + TextPrompt, + WaterfallDialog, + DialogManager, + DialogTurnStatus, + DialogEvents +} = require('../'); +const { AuthConstants } = require('../lib/prompts/skillsHelpers'); +const { assert } = require('console'); + +const FlowTestCase = { + RootBotOnly: 'RootBotOnly', + RootBotConsumingSkill: 'RootBotConsumingSkill', + MiddleSkill: 'MiddleSkill', + LeafSkill: 'LeafSkill', +}; + +class ClaimsIdentity { + constructor(claims = [], isAuthenticated = true) { + this.claims = claims; + this.isAuthenticated = isAuthenticated; + } + /** + * Returns a claim value (if its present) + * @param {string} claimType The claim type to look for + * @returns {string|null} The claim value or null if not found + */ + getClaimValue(claimType) { + const claim = this.claims.find((c) => c.type === claimType); + return claim ? claim.value : null; + } + + addClaim(claim) { + this.claims.push(claim); + } +} + +const PARENT_BOT_ID = '00000000-0000-0000-0000-0000000000PARENT'; +const SKILL_BOT_ID = '00000000-0000-0000-0000-00000000000SKILL'; +let _eocSent; +let _dmTurnResult; + +function createTestFlow(dialog, testCase = FlowTestCase.RootBotOnly, enabledTrace = false) { + const conversationId = 'testFlowConversationId'; + const storage = new MemoryStorage(); + const convoState = new ConversationState(storage); + const userState = new UserState(storage); + + const convRef = { + channelId: 'test', + serviceUrl: 'https://test.com', + from: { id: 'user1', name: 'User1' }, + recipient: { id: 'bot', name: 'Bot' }, + conversation: { + isGroup: false, + conversationType: conversationId, + id: conversationId + }, + }; + + const dm = new DialogManager(dialog); + dm.userState = userState; + dm.conversationState = convoState; + + const adapter = new TestAdapter(async (context) => { + if (testCase !== FlowTestCase.RootBotOnly) { + // Create a skill ClaimsIdentity and put it in turnState so isSkillClaim() returns true. + const claimsIdentity = new ClaimsIdentity(); + claimsIdentity.addClaim({ type: 'ver', value: '2.0' }); // AuthenticationConstants.VersionClaim + claimsIdentity.addClaim({ type: 'aud', value: SKILL_BOT_ID }); // AuthenticationConstants.AudienceClaim + claimsIdentity.addClaim({ type: 'azp', value: PARENT_BOT_ID }); // AuthenticationConstants.AuthorizedParty + context.turnState.set(context.adapter.BotIdentityKey, claimsIdentity); + + if (testCase === FlowTestCase.RootBotConsumingSkill) { + // Simulate the SkillConversationReference with a channel OAuthScope stored in turnState. + // This emulates a response coming to a root bot through SkillHandler. + context.turnState.set(SkillConversationReferenceKey, { oAuthScope: AuthConstants.ToBotFromChannelTokenIssuer }); + } + + if (testCase === FlowTestCase.MiddleSkill) { + // Simulate the SkillConversationReference with a parent Bot ID stored in turnState. + // This emulates a response coming to a skill from another skill through SkillHandler. + context.turnState.set(SkillConversationReferenceKey, { oAuthScope: PARENT_BOT_ID }); + } + } + + // Interceptor to capture the EoC activity if it was sent so we can assert it in the tests. + context.onSendActivities(async (tc, activities, next) => { + for (let idx = 0; idx < activities.length; idx++) { + if (activities[idx].type === ActivityTypes.EndOfConversation) { + _eocSent = activities[idx]; + break; + } + } + return await next(); + }); + + _dmTurnResult = await dm.onTurn(context); + }, convRef, enabledTrace); + adapter.use(new AutoSaveStateMiddleware(userState, convoState)); + + return adapter; +} + +class SimpleComponentDialog extends ComponentDialog { + constructor(id, property) { + super(id || 'SimpleComponentDialog'); + this.TextPrompt = 'TextPrompt'; + this.WaterfallDialog = 'WaterfallDialog'; + this.addDialog(new TextPrompt(this.TextPrompt)); + this.addDialog(new WaterfallDialog(this.WaterfallDialog, [ + this.promptForName.bind(this), + this.finalStep.bind(this), + ])); + this.initialDialogId = this.WaterfallDialog; + this.endReason; + } + + async onEndDialog(context, instance, reason) { + this.endReason = reason; + return super.onEndDialog(context, instance, reason); + } + + async promptForName(step) { + return step.prompt(this.TextPrompt, { + prompt: MessageFactory.text('Hello, what is your name?', undefined, InputHints.ExpectingInput), + retryPrompt: MessageFactory.text('Hello, what is your name again?', undefined, InputHints.ExpectingInput) + }); + } + + async finalStep(step) { + await step.context.sendActivity(`Hello ${ step.result }, nice to meet you!`); + return step.endDialog(step.result); + } + +} + +describe('DialogManager', function() { + this.timeout(300); + + this.beforeEach(() => { + _dmTurnResult = undefined; + }); + + describe('HandlesBotAndSkillsTestCases', () => { + this.beforeEach(() => { + _eocSent = undefined; + _dmTurnResult = undefined; + }); + + async function handlesBotAndSkillsTestCases(testCase, shouldSendEoc) { + const dialog = new SimpleComponentDialog(); + const testFlow = createTestFlow(dialog, testCase); + await testFlow.send('Hi') + .assertReply('Hello, what is your name?') + .send('SomeName') + .assertReply('Hello SomeName, nice to meet you!') + .startTest(); + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.complete); + + strictEqual(dialog.endReason, DialogReason.endCalled); + if (shouldSendEoc) { + ok(_eocSent, 'Skills should send EndConversation to channel'); + strictEqual(_eocSent.type, ActivityTypes.EndOfConversation); + strictEqual(_eocSent.value, 'SomeName'); + } else { + strictEqual(undefined, _eocSent, 'Root bot should not send EndConversation to channel'); + } + } + + it('rootBotOnly, no sent EoC', async () => { + await handlesBotAndSkillsTestCases(FlowTestCase.RootBotOnly, false); + }); + + it('rootBotConsumingSkill, no sent EoC', async () => { + await handlesBotAndSkillsTestCases(FlowTestCase.RootBotConsumingSkill, false); + }); + + it('middleSkill, sent EoC', async () => { + await handlesBotAndSkillsTestCases(FlowTestCase.MiddleSkill, true); + }); + + it('leafSkill, sent EoC', async () => { + await handlesBotAndSkillsTestCases(FlowTestCase.LeafSkill, true); + }); + }); + + it('SkillHandlesEoCFromParent', async () => { + const dialog = new SimpleComponentDialog(); + const testFlow = createTestFlow(dialog, FlowTestCase.LeafSkill); + await testFlow.send('Hi') + .assertReply('Hello, what is your name?') + .send({ type: ActivityTypes.EndOfConversation }) + .startTest(); + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.cancelled); + }); + + it('SkillHandlesRepromptFromParent', async () => { + const dialog = new SimpleComponentDialog(); + const testFlow = createTestFlow(dialog, FlowTestCase.LeafSkill); + await testFlow.send('Hi') + .assertReply('Hello, what is your name?') + .send({ type: ActivityTypes.Event, name: DialogEvents.repromptDialog }) + .assertReply('Hello, what is your name?') + .startTest(); + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.waiting); + }); + + it('SkillShouldReturnEmptyOnRepromptWithNoDialog', async () => { + const dialog = new SimpleComponentDialog(); + const testFlow = createTestFlow(dialog, FlowTestCase.LeafSkill); + await testFlow.send({ type: ActivityTypes.Event, name: DialogEvents.repromptDialog }) + .startTest(); + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.empty); + }); + + it('Trace skill state', async () => { + const dialog = new SimpleComponentDialog(); + const testFlow = createTestFlow(dialog, FlowTestCase.LeafSkill, true); + await testFlow.send('Hi') + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace); + }) + .assertReply('Hello, what is your name?') + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace); + strictEqual(reply.label, 'Skill State'); + }) + .send('SomeName') + .assertReply('Hello SomeName, nice to meet you!') + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace); + strictEqual(reply.label, 'Skill State'); + }) + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace); + }) + .startTest(); + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.complete); + }); + + it('Trace bot state', async () => { + const dialog = new SimpleComponentDialog(); + const testFlow = createTestFlow(dialog, FlowTestCase.RootBotOnly, true); + await testFlow.send('Hi') + .assertReply('Hello, what is your name?') + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace); + strictEqual(reply.label, 'Bot State'); + }) + .send('SomeName') + .assertReply('Hello SomeName, nice to meet you!') + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace); + strictEqual(reply.label, 'Bot State'); + }) + .startTest(); + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.complete); + }); + + it('Gets or sets root dialog', () => { + const dm = new DialogManager(); + const rootDialog = new SimpleComponentDialog(); + dm.rootDialog = rootDialog; + assert(dm.dialogs.find(rootDialog.id)); + strictEqual(dm.rootDialog.id, rootDialog.id); + dm.rootDialog = undefined; + strictEqual(dm.rootDialog, undefined); + }); +}); \ No newline at end of file