Skip to content

Commit

Permalink
Merge branch 'main' into lilyydu/devops-bot
Browse files Browse the repository at this point in the history
  • Loading branch information
lilyydu authored Apr 22, 2024
2 parents b571c16 + 720447f commit 90c8ba8
Show file tree
Hide file tree
Showing 11 changed files with 261 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,80 @@ void CaptureSend(Activity[] arg)
Assert.Equivalent(expectedInvokeResponse, activitiesToSend[0].Value);
}

[Fact]
public async Task Test_OnHandoff()
{
// Arrange
Activity[]? activitiesToSend = null;
void CaptureSend(Activity[] arg)
{
activitiesToSend = arg;
}
var adapter = new SimpleAdapter(CaptureSend);
var activity1 = new Activity
{
Type = ActivityTypes.Invoke,
Name = "handoff/action",
Value = new { Continuation = "test" },
Id = "test",
Recipient = new() { Id = "recipientId" },
Conversation = new() { Id = "conversationId" },
From = new() { Id = "fromId" },
ChannelId = "channelId"
};
var activity2 = new Activity
{
Type = ActivityTypes.Event,
Name = "actionableMessage/executeAction",
Recipient = new() { Id = "recipientId" },
Conversation = new() { Id = "conversationId" },
From = new() { Id = "fromId" },
ChannelId = "channelId"
};
var activity3 = new Activity
{
Type = ActivityTypes.Invoke,
Name = "composeExtension/queryLink",
Recipient = new() { Id = "recipientId" },
Conversation = new() { Id = "conversationId" },
From = new() { Id = "fromId" },
ChannelId = "channelId"
};
var turnContext1 = new TurnContext(adapter, activity1);
var turnContext2 = new TurnContext(adapter, activity2);
var turnContext3 = new TurnContext(adapter, activity3);
var expectedInvokeResponse = new InvokeResponse
{
Status = 200
};
var turnState = TurnStateConfig.GetTurnStateWithConversationStateAsync(turnContext1);
var app = new Application<TurnState>(new()
{
RemoveRecipientMention = false,
StartTypingTimer = false,
TurnStateFactory = () => turnState.Result,
});
var ids = new List<string>();
app.OnHandoff((turnContext, _, _, _) =>
{
ids.Add(turnContext.Activity.Id);
return Task.CompletedTask;
});

// Act
await app.OnTurnAsync(turnContext1);
await app.OnTurnAsync(turnContext2);
await app.OnTurnAsync(turnContext3);

// Assert
Assert.Single(ids);
Assert.Equal("test", ids[0]);
Assert.NotNull(activitiesToSend);
Assert.Equal(1, activitiesToSend.Length);
Assert.Equal("invokeResponse", activitiesToSend[0].Type);
Assert.Equivalent(expectedInvokeResponse, activitiesToSend[0].Value);
}

[Fact]
public async Task Test_OnTeamsReadReceipt()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Bot.Schema;
using Microsoft.Bot.Schema.Teams;
using Microsoft.Teams.AI.AI;
using Microsoft.Teams.AI.Application;
using Microsoft.Teams.AI.Exceptions;
using Microsoft.Teams.AI.State;
using Microsoft.Teams.AI.Utilities;
Expand Down Expand Up @@ -746,6 +747,35 @@ public Application<TState> OnO365ConnectorCardAction(O365ConnectorCardActionHand
return this;
}

/// <summary>
/// Handles handoff activities.
/// </summary>
/// <param name="handler">Function to call when the route is triggered.</param>
/// <returns>The application instance for chaining purposes.</returns>
public Application<TState> OnHandoff(HandoffHandler<TState> handler)
{
Verify.ParamNotNull(handler);
RouteSelectorAsync routeSelector = (context, _) => Task.FromResult
(
string.Equals(context.Activity?.Type, ActivityTypes.Invoke, StringComparison.OrdinalIgnoreCase)
&& string.Equals(context.Activity?.Name, "handoff/action")
);
RouteHandler<TState> routeHandler = async (turnContext, turnState, cancellationToken) =>
{
string token = turnContext.Activity.Value.GetType().GetProperty("Continuation").GetValue(turnContext.Activity.Value) as string;

Check warning on line 765 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/Application/Application.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (6.0)

Converting null literal or possible null value to non-nullable type.

Check warning on line 765 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/Application/Application.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (7.0)

Converting null literal or possible null value to non-nullable type.
await handler(turnContext, turnState, token, cancellationToken);

Check warning on line 766 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/Application/Application.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (6.0)

Possible null reference argument for parameter 'continuation' in 'Task HandoffHandler<TState>.Invoke(ITurnContext turnContext, TState turnState, string continuation, CancellationToken cancellationToken)'.

Check warning on line 766 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/Application/Application.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (7.0)

Possible null reference argument for parameter 'continuation' in 'Task HandoffHandler<TState>.Invoke(ITurnContext turnContext, TState turnState, string continuation, CancellationToken cancellationToken)'.
// Check to see if an invoke response has already been added
if (turnContext.TurnState.Get<object>(BotAdapter.InvokeResponseKey) == null)
{
Activity activity = ActivityUtilities.CreateInvokeResponseActivity();
await turnContext.SendActivityAsync(activity, cancellationToken);
}
};
AddRoute(routeSelector, routeHandler, isInvokeRoute: true);
return this;
}

/// <summary>
/// Add a handler that will execute before the turn's activity handler logic is processed.
/// <br/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.Bot.Builder;
using Microsoft.Teams.AI.State;

namespace Microsoft.Teams.AI.Application
{
/// <summary>
/// Function for handling handoff activities.
/// </summary>
/// <typeparam name="TState">Type of the turn state. This allows for strongly typed access to the turn state.</typeparam>
/// <param name="turnContext">A strongly-typed context object for this turn.</param>
/// <param name="turnState">The turn state object that stores arbitrary data for this turn.</param>
/// <param name="continuation">The continuation token.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects
/// or threads to receive notice of cancellation.</param>
/// <returns>A task that represents the work queued to execute.</returns>
public delegate Task HandoffHandler<TState>(ITurnContext turnContext, TState turnState, string continuation, CancellationToken cancellationToken) where TState : TurnState;
}
27 changes: 27 additions & 0 deletions js/packages/teams-ai/src/Application.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,33 @@ describe('Application', () => {
});
});

describe('handoff', () => {
let app = new Application();

beforeEach(() => {
app = new Application();
sandbox.stub(app, 'adapter').get(() => testAdapter);
});

it('should route to correct handler for handoff', async () => {
let handlerCalled = false;

app.handoff(async (context, _state, token) => {
handlerCalled = true;
assert.equal(context.activity.type, ActivityTypes.Invoke);
assert.equal(context.activity.name, 'handoff/action');
assert.equal(token, 'test');
});

const activity = createTestInvoke('handoff/action', { continuation: 'test' });

await testAdapter.processActivity(activity, async (context) => {
await app.run(context);
assert.equal(handlerCalled, true);
});
});
});

describe('messageUpdate', () => {
let app = new Application();

Expand Down
28 changes: 25 additions & 3 deletions js/packages/teams-ai/src/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ export class Application<TState extends TurnState = TurnState> {
const handlerWrapper = (context: TurnContext, state: TState) => {
return handler(context, state, context.activity.value as FileConsentCardResponse);
};
this.addRoute(selector, handlerWrapper);
this.addRoute(selector, handlerWrapper, true);
return this;
}

Expand All @@ -588,7 +588,7 @@ export class Application<TState extends TurnState = TurnState> {
const handlerWrapper = (context: TurnContext, state: TState) => {
return handler(context, state, context.activity.value as FileConsentCardResponse);
};
this.addRoute(selector, handlerWrapper);
this.addRoute(selector, handlerWrapper, true);
return this;
}

Expand All @@ -609,7 +609,29 @@ export class Application<TState extends TurnState = TurnState> {
const handlerWrapper = (context: TurnContext, state: TState) => {
return handler(context, state, context.activity.value as O365ConnectorCardActionQuery);
};
this.addRoute(selector, handlerWrapper);
this.addRoute(selector, handlerWrapper, true);
return this;
}

/**
* Registers a handler to handoff conversations from one copilot to another.
* @param {(context: TurnContext, state: TState, continuation: string) => Promise<void>} handler Function to call when the route is triggered.
* @returns {this} The application instance for chaining purposes.
*/
public handoff(handler: (context: TurnContext, state: TState, continuation: string) => Promise<void>): this {
const selector = (context: TurnContext): Promise<boolean> => {
return Promise.resolve(
context.activity.type === ActivityTypes.Invoke && context.activity.name === 'handoff/action'
);
};
const handlerWrapper = async (context: TurnContext, state: TState) => {
await handler(context, state, context.activity.value!.continuation);
await context.sendActivity({
type: ActivityTypes.InvokeResponse,
value: { status: 200 },
});
};
this.addRoute(selector, handlerWrapper, true);
return this;
}

Expand Down
9 changes: 4 additions & 5 deletions js/packages/teams-ai/src/embeddings/OpenAIEmbeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,12 @@ export interface OpenAIEmbeddingsOptions extends BaseOpenAIEmbeddingsOptions {
* Options for configuring an embeddings object that calls an `OpenAI` compliant endpoint.
* @remarks
* The endpoint should comply with the OpenAPI spec for OpenAI's API:
*
*
* https://github.com/openai/openai-openapi
*
*
* And an example of a compliant endpoint is LLaMA.cpp's reference server:
*
*
* https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md
*
*/
export interface OpenAILikeEmbeddingsOptions extends BaseOpenAIEmbeddingsOptions {
/**
Expand Down Expand Up @@ -274,7 +273,7 @@ export class OpenAIEmbeddings implements EmbeddingsModel {
if (this._useAzure) {
const options = this.options as AzureOpenAIEmbeddingsOptions;
requestConfig.headers['api-key'] = options.azureApiKey;
} else if ((this.options as OpenAIEmbeddingsOptions).apiKey){
} else if ((this.options as OpenAIEmbeddingsOptions).apiKey) {
const options = this.options as OpenAIEmbeddingsOptions;
requestConfig.headers['Authorization'] = `Bearer ${options.apiKey}`;
if (options.organization) {
Expand Down
14 changes: 6 additions & 8 deletions js/packages/teams-ai/src/models/OpenAIModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export interface BaseOpenAIModelOptions {
/**
* Optional. Forces the model return a specific response format.
* @remarks
* This can be used to force the model to always return a valid JSON object.
* This can be used to force the model to always return a valid JSON object.
*/
responseFormat?: { "type": "json_object" };
responseFormat?: { type: 'json_object' };

/**
* Optional. Retry policy to use when calling the OpenAI API.
Expand Down Expand Up @@ -66,7 +66,6 @@ export interface BaseOpenAIModelOptions {
* prompt to be sent as `user` messages instead.
*/
useSystemMessages?: boolean;

}

/**
Expand Down Expand Up @@ -102,13 +101,12 @@ export interface OpenAIModelOptions extends BaseOpenAIModelOptions {
* Options for configuring a model that calls and `OpenAI` compliant endpoint.
* @remarks
* The endpoint should comply with the OpenAPI spec for OpenAI's API:
*
*
* https://github.com/openai/openai-openapi
*
*
* And an example of a compliant endpoint is LLaMA.cpp's reference server:
*
*
* https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md
*
*/
export interface OpenAILikeModelOptions extends BaseOpenAIModelOptions {
/**
Expand Down Expand Up @@ -287,7 +285,7 @@ export class OpenAIModel implements PromptCompletionModel {
'user',
'functions',
'function_call',
'data_sources',
'data_sources'
]
);
if (this.options.responseFormat) {
Expand Down
7 changes: 0 additions & 7 deletions js/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1344,13 +1344,6 @@
dependencies:
undici-types "~5.26.4"

"@types/node@^20.12.2":
version "20.12.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.7.tgz#04080362fa3dd6c5822061aa3124f5c152cff384"
integrity sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==
dependencies:
undici-types "~5.26.4"

"@types/parse-json@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
Expand Down
45 changes: 44 additions & 1 deletion python/packages/ai/teams/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from aiohttp.web import Request, Response
from botbuilder.core import Bot, TurnContext
from botbuilder.schema import ActivityTypes
from botbuilder.schema import Activity, ActivityTypes, InvokeResponse
from botbuilder.schema.teams import (
FileConsentCardResponse,
O365ConnectorCardActionQuery,
Expand Down Expand Up @@ -512,6 +512,49 @@ async def __handler__(context: TurnContext, state: StateT):

return __call__

def handoff(
self,
) -> Callable[
[Callable[[TurnContext, StateT, str], Awaitable[None]]],
Callable[[TurnContext, StateT, str], Awaitable[None]],
]:
"""
Registers a handler to handoff conversations from one copilot to another.
```python
# Use this method as a decorator
@app.handoff
async def on_handoff(
context: TurnContext, state: TurnState, continuation: str
):
print(query)
# Pass a function to this method
app.handoff()(on_handoff)
```
"""

def __selector__(context: TurnContext) -> bool:
return (
context.activity.type == ActivityTypes.invoke
and context.activity.name == "handoff/action"
)

def __call__(
func: Callable[[TurnContext, StateT, str], Awaitable[None]]
) -> Callable[[TurnContext, StateT, str], Awaitable[None]]:
async def __handler__(context: TurnContext, state: StateT):
if not context.activity.value:
return False
await func(context, state, context.activity.value["continuation"])
await context.send_activity(
Activity(type=ActivityTypes.invoke_response, value=InvokeResponse(status=200))
)
return True

self._routes.append(Route[StateT](__selector__, __handler__, True))
return func

return __call__

def before_turn(self, func: RouteHandler[StateT]) -> RouteHandler[StateT]:
"""
Registers a new event listener that will be executed before turns.
Expand Down
25 changes: 25 additions & 0 deletions python/packages/ai/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,3 +750,28 @@ async def test_file_consent_decline_invalid_action(self):
)

on_file_consent_decline.assert_not_called()

@pytest.mark.asyncio
async def test_handoff(self):
on_handoff = mock.AsyncMock()
self.app.handoff()(on_handoff)

await self.app.on_turn(
TurnContext(
SimpleAdapter(),
Activity(
id="1234",
type="invoke",
name="handoff/action",
from_property=ChannelAccount(id="user", name="User Name"),
recipient=ChannelAccount(id="bot", name="Bot Name"),
conversation=ConversationAccount(id="convo", name="Convo Name"),
channel_id="UnitTest",
locale="en-uS",
service_url="https://example.org",
value={"continuation": "test"},
),
)
)

on_handoff.assert_called_once()
Loading

0 comments on commit 90c8ba8

Please sign in to comment.