Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Agents/Assistants #2286

Merged
merged 20 commits into from
Oct 17, 2024
Merged
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
aa90e2a
Add AssistantThread* events
misscoded Aug 15, 2024
8eb69c8
Create bare-bones, albeit functioning Assistant class
misscoded Sep 6, 2024
925a734
Add support for threadStarted and contextChanged, including utils
misscoded Sep 6, 2024
ce594d7
Add support for userMessage [WIP]
misscoded Sep 10, 2024
b1ec561
Complete support for userMessage, validate config + create error type
misscoded Sep 10, 2024
68e3f80
Write tests for Assistant class [WIP]
misscoded Sep 12, 2024
433d976
Merge master, fix conflicts
misscoded Sep 12, 2024
9978e80
Add say utility to Assistant callbacks
misscoded Sep 13, 2024
bcd0f72
Finish tests for Assistant class
misscoded Sep 13, 2024
9a8c6da
Flatten one-arg utilities; add prop error; update tests
misscoded Sep 19, 2024
8b0f4b1
Merge branch 'main' of https://github.com/slackapi/bolt-js into feat-…
misscoded Oct 7, 2024
0371d7b
Export Assistant-related items
misscoded Oct 10, 2024
46bf53a
Create utils for get and save thread context
misscoded Oct 11, 2024
ed70e8e
Implement AssistantContextStore [WIP]
misscoded Oct 12, 2024
6c43900
Organize, comment, tidy
misscoded Oct 13, 2024
85a4d4c
Incorporate PR feedback
misscoded Oct 14, 2024
95557c7
Merge branch 'main' of https://github.com/slackapi/bolt-js into feat-…
misscoded Oct 15, 2024
bbac3bd
Augment validate to include threadContextStore
misscoded Oct 15, 2024
37c2441
Prune exports, narrow types, add tests
misscoded Oct 16, 2024
ad7a190
Rename method and add TODO, per request
misscoded Oct 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Write tests for Assistant class [WIP]
misscoded committed Sep 12, 2024

Verified

This commit was signed with the committer’s verified signature.
commit 68e3f80cc5976830cc0f25bad10f47b42e390b96
393 changes: 393 additions & 0 deletions src/Assistant.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,393 @@
import 'mocha';
import { assert } from 'chai';
import sinon from 'sinon';
import rewiremock from 'rewiremock';
import { WebClient } from '@slack/web-api';
import {
Assistant,
AssistantMiddlewareArgs,
AllAssistantMiddlewareArgs,
AssistantMiddleware,
AssistantConfig,
AssistantThreadStartedMiddlewareArgs,
AssistantThreadContextChangedMiddlewareArgs,
AssistantUserMessageMiddlewareArgs,
} from './Assistant';
import { Override } from './test-helpers';
import { AllMiddlewareArgs, AnyMiddlewareArgs, AssistantThreadStartedEvent, Middleware } from './types';
import { AssistantInitializationError } from './errors';

async function importAssistant(overrides: Override = {}): Promise<typeof import('./Assistant')> {
return rewiremock.module(() => import('./Assistant'), overrides);
}

const MOCK_FN = async () => {};

const MOCK_CONFIG_SINGLE = {
threadStarted: MOCK_FN,
threadContextChanged: MOCK_FN,
userMessage: MOCK_FN,
};

const MOCK_CONFIG_MULTIPLE = {
threadStarted: [MOCK_FN, MOCK_FN],
threadContextChanged: [MOCK_FN],
userMessage: [MOCK_FN, MOCK_FN, MOCK_FN],
};

describe('Assistant class', () => {
describe('constructor', () => {
it('should accept config as single functions', async () => {
const assistant = new Assistant(MOCK_CONFIG_SINGLE);
assert.isNotNull(assistant);
});

it('should accept config as multiple functions', async () => {
const assistant = new Assistant(MOCK_CONFIG_MULTIPLE);
assert.isNotNull(assistant);
});
});

describe('getMiddleware', () => {
it('should not call next if a assistant event', async () => {
const assistant = new Assistant(MOCK_CONFIG_SINGLE);
const middleware = assistant.getMiddleware();
const fakeThreadStartedArgs = createFakeThreadStartedEvent() as
unknown as AssistantMiddlewareArgs & AllMiddlewareArgs;

const fakeNext = sinon.spy();
fakeThreadStartedArgs.next = fakeNext;

await middleware(fakeThreadStartedArgs);

assert(fakeNext.notCalled);
});

it('should call next if not an assistant event', async () => {
const assistant = new Assistant(MOCK_CONFIG_SINGLE);
const middleware = assistant.getMiddleware();
const fakeMessageEvent = createFakeMessageEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs;

const fakeNext = sinon.spy();
fakeMessageEvent.next = fakeNext;

await middleware(fakeMessageEvent);

assert(fakeNext.called);
});
});

describe('validate', () => {
it('should throw an error if config is not an object', async () => {
const { validate } = await importAssistant();

// intentionally casting to AssistantConfig to trigger failure
const badConfig = '' as unknown as AssistantConfig;

const validationFn = () => validate(badConfig);
const expectedMsg = 'Assistant expects a configuration object as the argument';
assert.throws(validationFn, AssistantInitializationError, expectedMsg);
});

it('should throw an error if required keys are missing', async () => {
const { validate } = await importAssistant();

// intentionally casting to AssistantConfig to trigger failure
const badConfig = {
threadStarted: async () => {},
} as unknown as AssistantConfig;

const validationFn = () => validate(badConfig);
const expectedMsg = 'Assistant is missing required keys: threadContextChanged, userMessage';
assert.throws(validationFn, AssistantInitializationError, expectedMsg);
});

it('should throw an error if props are not a single callback or an array of callbacks', async () => {
const { validate } = await importAssistant();

// intentionally casting to AssistantConfig to trigger failure
const badConfig = {
threadStarted: async () => {},
threadContextChanged: {},
userMessage: async () => {},
} as unknown as AssistantConfig;

const validationFn = () => validate(badConfig);
const expectedMsg = 'Assistant threadContextChanged property must be a function or an array of functions';
assert.throws(validationFn, AssistantInitializationError, expectedMsg);
});
});

describe('isAssistantEvent', () => {
it('should return true if recognized assistant payload type', async () => {
const fakeThreadStartedArgs = createFakeThreadStartedEvent() as
unknown as AssistantThreadStartedMiddlewareArgs & AllMiddlewareArgs;
const fakeThreadContextChangedArgs = createFakeThreadContextChangedEvent() as
unknown as AssistantThreadContextChangedMiddlewareArgs & AllMiddlewareArgs;
const fakeUserMessageArgs = createFakeUserMessageEvent() as
unknown as AssistantUserMessageMiddlewareArgs & AllMiddlewareArgs;

const { isAssistantEvent } = await importAssistant();

const threadStartedIsAssistantEvent = isAssistantEvent(fakeThreadStartedArgs);
const threadContextChangedIsAssistantEvent = isAssistantEvent(fakeThreadContextChangedArgs);
const userMessageIsAssistantEvent = isAssistantEvent(fakeUserMessageArgs);

assert.isTrue(threadStartedIsAssistantEvent);
assert.isTrue(threadContextChangedIsAssistantEvent);
assert.isTrue(userMessageIsAssistantEvent);
});

it('should return false if not a recognized workflow step payload type', async () => {
const fakeMessageArgs = createFakeUserMessageEvent() as unknown as AnyMiddlewareArgs;
fakeMessageArgs.payload.type = 'message';
fakeMessageArgs.payload.subtype = 'bot';

const { isAssistantEvent } = await importAssistant();
const messageIsAssistantEvent = isAssistantEvent(fakeMessageArgs);

assert.isFalse(messageIsAssistantEvent);
});
});

describe('prepareAssistantArgs', () => {
it('should remove next() from all original event args', async () => {
const fakeThreadStartedArgs = createFakeThreadStartedEvent() as
unknown as AssistantThreadStartedMiddlewareArgs & AllMiddlewareArgs;
const fakeThreadContextChangedArgs = createFakeThreadContextChangedEvent() as
unknown as AssistantThreadContextChangedMiddlewareArgs & AllMiddlewareArgs;
const fakeUserMessageArgs = createFakeUserMessageEvent() as
unknown as AssistantUserMessageMiddlewareArgs & AllMiddlewareArgs;

const { prepareAssistantArgs } = await importAssistant();

const threadStartedArgs = prepareAssistantArgs(fakeThreadStartedArgs);
const threadContextChangedArgs = prepareAssistantArgs(fakeThreadContextChangedArgs);
const userMessageArgs = prepareAssistantArgs(fakeUserMessageArgs);

assert.notExists(threadStartedArgs.next);
assert.notExists(threadContextChangedArgs.next);
assert.notExists(userMessageArgs.next);
});

it('should augment workflow_step_edit args with step and configure()', async () => {
const fakeArgs = createFakeThreadStartedEvent();
const { prepareAssistantArgs } = await importAssistant();
// casting to returned type because prepareAssistantArgs isn't built to do so
const assistantArgs = prepareAssistantArgs(fakeArgs) as
AllAssistantMiddlewareArgs<AssistantThreadStartedMiddlewareArgs>;

assert.exists(assistantArgs.setStatus);
assert.exists(assistantArgs.setTitle);
assert.exists(assistantArgs.setSuggestedPrompts);
});

it('should augment view_submission with step and update()', async () => {
const fakeArgs = createFakeThreadContextChangedEvent();
const { prepareAssistantArgs } = await importAssistant();
// casting to returned type because prepareAssistantArgs isn't built to do so
const assistantArgs = prepareAssistantArgs(fakeArgs) as
AllAssistantMiddlewareArgs<AssistantThreadContextChangedMiddlewareArgs>;

assert.exists(assistantArgs.setStatus);
assert.exists(assistantArgs.setTitle);
assert.exists(assistantArgs.setSuggestedPrompts);
});

it('should augment workflow_step_execute with step, complete() and fail()', async () => {
const fakeArgs = createFakeUserMessageEvent();
const { prepareAssistantArgs } = await importAssistant();
// casting to returned type because prepareAssistantArgs isn't built to do so
const assistantArgs = prepareAssistantArgs(fakeArgs) as
AllAssistantMiddlewareArgs<AssistantUserMessageMiddlewareArgs>;

assert.exists(assistantArgs.setStatus);
assert.exists(assistantArgs.setTitle);
assert.exists(assistantArgs.setSuggestedPrompts);
});
});

describe('assistant utility functions', () => {
it('setSuggestedPrompts should call assistant.threads.setSuggestedPrompts', async () => {
const fakeThreadStartedArgs = createFakeThreadStartedEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len

const fakeClient = { assistant: { threads: { setSuggestedPrompts: sinon.spy() } } };
fakeThreadStartedArgs.client = fakeClient as unknown as WebClient;

const { prepareAssistantArgs } = await importAssistant();
// casting to returned type because prepareAssistantArgs isn't built to do so
const threadStartedArgs = prepareAssistantArgs(
fakeThreadStartedArgs,
) as AllAssistantMiddlewareArgs<AssistantThreadStartedMiddlewareArgs>;

await threadStartedArgs.setSuggestedPrompts({ prompts: [] });

assert(fakeClient.assistant.threads.setSuggestedPrompts.called);
});

it('setTitle should call assistant.threads.setTitle', async () => {
const fakeThreadStartedArgs = createFakeThreadStartedEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len

const fakeClient = { assistant: { threads: { setTitle: sinon.spy() } } };
fakeThreadStartedArgs.client = fakeClient as unknown as WebClient;

const { prepareAssistantArgs } = await importAssistant();
// casting to returned type because prepareAssistantArgs isn't built to do so
const threadStartedArgs = prepareAssistantArgs(
fakeThreadStartedArgs,
) as AllAssistantMiddlewareArgs<AssistantThreadStartedMiddlewareArgs>;

await threadStartedArgs.setTitle({ title: 'Title set!' });

assert(fakeClient.assistant.threads.setTitle.called);
});

it('setStatus should call assistant.threads.setStatus', async () => {
const fakeThreadStartedArgs = createFakeThreadStartedEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len

const fakeClient = { assistant: { threads: { setStatus: sinon.spy() } } };
fakeThreadStartedArgs.client = fakeClient as unknown as WebClient;

const { prepareAssistantArgs } = await importAssistant();
// casting to returned type because prepareAssistantArgs isn't built to do so
const threadStartedArgs = prepareAssistantArgs(
fakeThreadStartedArgs,
) as AllAssistantMiddlewareArgs<AssistantThreadStartedMiddlewareArgs>;

await threadStartedArgs.setStatus({ status: 'Status set!' });

assert(fakeClient.assistant.threads.setStatus.called);
});
});

describe('processStepMiddleware', () => {
it('should call each callback in user-provided middleware', async () => {
const { ...fakeArgs } = createFakeThreadContextChangedEvent() as unknown as AllAssistantMiddlewareArgs;
const { processAssistantMiddleware } = await importAssistant();

const fn1 = sinon.spy((async ({ next: continuation }) => {
await continuation();
}) as Middleware<AssistantThreadStartedEvent>);
const fn2 = sinon.spy(async () => {});
const fakeMiddleware = [fn1, fn2] as AssistantMiddleware;

await processAssistantMiddleware(fakeArgs, fakeMiddleware);

assert(fn1.called);
assert(fn2.called);
});
});
});

function createFakeThreadStartedEvent() {
return {
// body: {
// callback_id: 'test_edit_callback_id',
// trigger_id: 'test_edit_trigger_id',
// },
payload: {
type: 'assistant_thread_started',
assistant_thread: {
user_id: '',
context: {
channel_id: '',
team_id: '',
enterprise_id: '',
},
channel_id: '',
thread_ts: '',
},
event_ts: '',
},
// action: {
// workflow_step: {},
// },
// context: {},
};
}

function createFakeThreadContextChangedEvent() {
return {
// body: {
// callback_id: 'test_edit_callback_id',
// trigger_id: 'test_edit_trigger_id',
// },
payload: {
type: 'assistant_thread_context_changed',
assistant_thread: {
user_id: '',
context: {
channel_id: '',
team_id: '',
enterprise_id: '',
},
channel_id: '',
thread_ts: '',
},
event_ts: '',
},
// action: {
// workflow_step: {},
// },
// context: {},
};
}

function createFakeUserMessageEvent() {
return {
// body: {
// callback_id: 'test_execute_callback_id',
// trigger_id: 'test_execute_trigger_id',
// },
// event: {
// workflow_step: {},
// },
payload: {
user: 'W013QGS7BPF',
type: 'message',
ts: '1725906578.238409',
text: 'test',
team: 'T014GJXU940',
user_team: 'T014GJXU940',
source_team: 'T014GJXU940',
user_profile: {},
thread_ts: '1725906530.124889',
parent_user_id: 'U07KHARPYCQ',
blocks: [],
channel: 'D07JUHHV4FL',
event_ts: '1725906578.238409',
channel_type: 'im',
},
context: {},
};
}

function createFakeMessageEvent() {
return {
// body: {
// callback_id: 'test_view_callback_id',
// trigger_id: 'test_view_trigger_id',
// workflow_step: {
// workflow_step_edit_id: '',
// },
// },
payload: {
user: 'W013QGS7BPF',
type: 'message',
subtype: 'bot',
ts: '1725906578.238409',
text: 'test',
team: 'T014GJXU940',
user_team: 'T014GJXU940',
source_team: 'T014GJXU940',
user_profile: {},
thread_ts: '1725906530.124889',
parent_user_id: 'U07KHARPYCQ',
blocks: [],
channel: 'D07JUHHV4FL',
event_ts: '1725906578.238409',
channel_type: 'im',
},
context: {},
};
}