Skip to content

Commit 9c656bb

Browse files
authored
chore(compass-assistant): add confirmation step to explain plan entrypoint COMPASS-9836 (#7326)
1 parent 238a79b commit 9c656bb

9 files changed

+856
-77
lines changed

packages/compass-assistant/src/compass-assistant-drawer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
showConfirmation,
99
spacing,
1010
} from '@mongodb-js/compass-components';
11-
import { AssistantChat } from './assistant-chat';
11+
import { AssistantChat } from './components/assistant-chat';
1212
import {
1313
ASSISTANT_DRAWER_ID,
1414
AssistantActionsContext,

packages/compass-assistant/src/compass-assistant-provider.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { useTelemetry } from '@mongodb-js/compass-telemetry/provider';
2929
import type { AtlasAiService } from '@mongodb-js/compass-generative-ai/provider';
3030
import { atlasAiServiceLocator } from '@mongodb-js/compass-generative-ai/provider';
3131
import { buildConversationInstructionsPrompt } from './prompts';
32+
import { createOpenAI } from '@ai-sdk/openai';
3233

3334
export const ASSISTANT_DRAWER_ID = 'compass-assistant-drawer';
3435

@@ -40,6 +41,11 @@ export type AssistantMessage = UIMessage & {
4041
* Used for warning messages in cases like using non-genuine MongoDB.
4142
*/
4243
isPermanent?: boolean;
44+
/** Information for confirmation messages. */
45+
confirmation?: {
46+
description: string;
47+
state: 'confirmed' | 'rejected' | 'pending';
48+
};
4349
};
4450
};
4551

@@ -172,9 +178,12 @@ export const AssistantProvider: React.FunctionComponent<
172178
return;
173179
}
174180

175-
const { prompt, displayText } = builder(props);
181+
const { prompt, metadata } = builder(props);
176182
void assistantActionsContext.current.ensureOptInAndSend(
177-
{ text: prompt, metadata: { displayText } },
183+
{
184+
text: prompt,
185+
metadata,
186+
},
178187
{},
179188
() => {
180189
openDrawer(ASSISTANT_DRAWER_ID);
@@ -185,17 +194,17 @@ export const AssistantProvider: React.FunctionComponent<
185194
}
186195
);
187196
};
188-
});
197+
}).current;
189198
const assistantActionsContext = useRef<AssistantActionsContextType>({
190-
interpretExplainPlan: createEntryPointHandler.current(
199+
interpretExplainPlan: createEntryPointHandler(
191200
'explain plan',
192201
buildExplainPlanPrompt
193202
),
194-
interpretConnectionError: createEntryPointHandler.current(
203+
interpretConnectionError: createEntryPointHandler(
195204
'connection error',
196205
buildConnectionErrorPrompt
197206
),
198-
tellMoreAboutInsight: createEntryPointHandler.current(
207+
tellMoreAboutInsight: createEntryPointHandler(
199208
'performance insights',
200209
buildProactiveInsightsPrompt
201210
),
@@ -220,6 +229,10 @@ export const AssistantProvider: React.FunctionComponent<
220229
// place to do tracking.
221230
callback();
222231

232+
if (chat.status === 'streaming') {
233+
await chat.stop();
234+
}
235+
223236
await chat.sendMessage(message, options);
224237
},
225238
});
@@ -267,10 +280,13 @@ export const CompassAssistantProvider = registerCompassPlugin(
267280
initialProps.chat ??
268281
new Chat({
269282
transport: new DocsProviderTransport({
270-
baseUrl: atlasService.assistantApiEndpoint(),
271283
instructions: buildConversationInstructionsPrompt({
272284
target: initialProps.appNameForPrompt,
273285
}),
286+
model: createOpenAI({
287+
baseURL: atlasService.assistantApiEndpoint(),
288+
apiKey: '',
289+
}).responses('mongodb-chat-latest'),
274290
}),
275291
onError: (err: Error) => {
276292
logger.log.error(

packages/compass-assistant/src/assistant-chat.spec.tsx renamed to packages/compass-assistant/src/components/assistant-chat.spec.tsx

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
} from '@mongodb-js/testing-library-compass';
99
import { AssistantChat } from './assistant-chat';
1010
import { expect } from 'chai';
11-
import { createMockChat } from '../test/utils';
11+
import { createMockChat } from '../../test/utils';
1212
import type { ConnectionInfo } from '@mongodb-js/connection-info';
1313
import {
1414
AssistantActionsContext,
1515
type AssistantMessage,
16-
} from './compass-assistant-provider';
16+
} from '../compass-assistant-provider';
1717
import sinon from 'sinon';
18+
import type { TextPart } from 'ai';
1819

1920
describe('AssistantChat', function () {
2021
const mockMessages: AssistantMessage[] = [
@@ -533,6 +534,237 @@ describe('AssistantChat', function () {
533534
});
534535
});
535536

537+
describe('messages with confirmation', function () {
538+
let mockConfirmationMessage: AssistantMessage;
539+
540+
beforeEach(function () {
541+
mockConfirmationMessage = {
542+
id: 'confirmation-test',
543+
role: 'assistant',
544+
parts: [{ type: 'text', text: 'This is a confirmation message.' }],
545+
metadata: {
546+
confirmation: {
547+
state: 'pending',
548+
description: 'Are you sure you want to proceed with this action?',
549+
},
550+
},
551+
};
552+
});
553+
554+
it('renders confirmation message when message has confirmation metadata', function () {
555+
renderWithChat([mockConfirmationMessage]);
556+
557+
expect(screen.getByText('Please confirm your request')).to.exist;
558+
expect(
559+
screen.getByText('Are you sure you want to proceed with this action?')
560+
).to.exist;
561+
expect(screen.getByText('Confirm')).to.exist;
562+
expect(screen.getByText('Cancel')).to.exist;
563+
});
564+
565+
it('does not render regular message content when confirmation metadata exists', function () {
566+
renderWithChat([mockConfirmationMessage]);
567+
568+
// Should not show the message text content when confirmation is present
569+
expect(screen.queryByText('This is a confirmation message.')).to.not
570+
.exist;
571+
});
572+
573+
it('shows confirmation as pending when it is the last message', function () {
574+
renderWithChat([mockConfirmationMessage]);
575+
576+
expect(screen.getByText('Confirm')).to.exist;
577+
expect(screen.getByText('Cancel')).to.exist;
578+
expect(screen.queryByText('Request confirmed')).to.not.exist;
579+
expect(screen.queryByText('Request cancelled')).to.not.exist;
580+
});
581+
582+
it('shows confirmation as rejected when it is not the last message', function () {
583+
const messages: AssistantMessage[] = [
584+
mockConfirmationMessage,
585+
{
586+
id: 'newer-message',
587+
role: 'user' as const,
588+
parts: [{ type: 'text', text: 'Another message' }],
589+
},
590+
];
591+
592+
renderWithChat(messages);
593+
594+
// The confirmation message (first one) should show as rejected since it's not the last
595+
expect(screen.queryByText('Confirm')).to.not.exist;
596+
expect(screen.queryByText('Cancel')).to.not.exist;
597+
expect(screen.getByText('Request cancelled')).to.exist;
598+
});
599+
600+
it('adds new confirmed message when confirmation is confirmed', function () {
601+
const { chat, ensureOptInAndSendStub } = renderWithChat([
602+
mockConfirmationMessage,
603+
]);
604+
605+
const confirmButton = screen.getByText('Confirm');
606+
userEvent.click(confirmButton);
607+
608+
// Should add a new message without confirmation metadata
609+
expect(chat.messages).to.have.length(2);
610+
const newMessage = chat.messages[1];
611+
expect(newMessage.id).to.equal('confirmation-test-confirmed');
612+
expect(newMessage.metadata?.confirmation).to.be.undefined;
613+
expect(newMessage.parts).to.deep.equal(mockConfirmationMessage.parts);
614+
615+
// Should call ensureOptInAndSend to send the new message
616+
expect(ensureOptInAndSendStub.calledOnce).to.be.true;
617+
});
618+
619+
it('updates confirmation state to confirmed and adds a new message when confirm button is clicked', function () {
620+
const { chat } = renderWithChat([mockConfirmationMessage]);
621+
622+
const confirmButton = screen.getByText('Confirm');
623+
userEvent.click(confirmButton);
624+
625+
// Original message should have updated confirmation state
626+
const originalMessage = chat.messages[0];
627+
expect(originalMessage.metadata?.confirmation?.state).to.equal(
628+
'confirmed'
629+
);
630+
631+
expect(chat.messages).to.have.length(2);
632+
633+
expect(
634+
screen.getByText((mockConfirmationMessage.parts[0] as TextPart).text)
635+
).to.exist;
636+
});
637+
638+
it('updates confirmation state to rejected and does not add a new message when cancel button is clicked', function () {
639+
const { chat, ensureOptInAndSendStub } = renderWithChat([
640+
mockConfirmationMessage,
641+
]);
642+
643+
const cancelButton = screen.getByText('Cancel');
644+
userEvent.click(cancelButton);
645+
646+
// Original message should have updated confirmation state
647+
const originalMessage = chat.messages[0];
648+
expect(originalMessage.metadata?.confirmation?.state).to.equal(
649+
'rejected'
650+
);
651+
652+
// Should not add a new message
653+
expect(chat.messages).to.have.length(1);
654+
655+
// Should not call ensureOptInAndSend
656+
expect(ensureOptInAndSendStub.notCalled).to.be.true;
657+
});
658+
659+
it('shows confirmed status after confirmation is confirmed', function () {
660+
const { chat } = renderWithChat([mockConfirmationMessage]);
661+
662+
// Verify buttons are initially present
663+
expect(screen.getByText('Confirm')).to.exist;
664+
expect(screen.getByText('Cancel')).to.exist;
665+
666+
const confirmButton = screen.getByText('Confirm');
667+
userEvent.click(confirmButton);
668+
669+
// The state update should be immediate - check the chat messages
670+
const updatedMessage = chat.messages[0];
671+
expect(updatedMessage.metadata?.confirmation?.state).to.equal(
672+
'confirmed'
673+
);
674+
});
675+
676+
it('shows cancelled status after confirmation is rejected', function () {
677+
const { chat } = renderWithChat([mockConfirmationMessage]);
678+
679+
// Verify buttons are initially present
680+
expect(screen.getByText('Confirm')).to.exist;
681+
expect(screen.getByText('Cancel')).to.exist;
682+
683+
const cancelButton = screen.getByText('Cancel');
684+
userEvent.click(cancelButton);
685+
686+
// The state update should be immediate - check the chat messages
687+
const updatedMessage = chat.messages[0];
688+
expect(updatedMessage.metadata?.confirmation?.state).to.equal('rejected');
689+
});
690+
691+
it('handles multiple confirmation messages correctly', function () {
692+
const confirmationMessage1: AssistantMessage = {
693+
id: 'confirmation-1',
694+
role: 'assistant',
695+
parts: [{ type: 'text', text: 'First confirmation' }],
696+
metadata: {
697+
confirmation: {
698+
state: 'pending',
699+
description: 'First confirmation description',
700+
},
701+
},
702+
};
703+
704+
const confirmationMessage2: AssistantMessage = {
705+
id: 'confirmation-2',
706+
role: 'assistant',
707+
parts: [{ type: 'text', text: 'Second confirmation' }],
708+
metadata: {
709+
confirmation: {
710+
state: 'pending',
711+
description: 'Second confirmation description',
712+
},
713+
},
714+
};
715+
716+
renderWithChat([confirmationMessage1, confirmationMessage2]);
717+
718+
expect(screen.getAllByText('Request cancelled')).to.have.length(1);
719+
720+
expect(screen.getAllByText('Confirm')).to.have.length(1);
721+
expect(screen.getAllByText('Cancel')).to.have.length(1);
722+
expect(screen.getByText('Second confirmation description')).to.exist;
723+
});
724+
725+
it('preserves other metadata when creating confirmed message', function () {
726+
const messageWithExtraMetadata: AssistantMessage = {
727+
id: 'confirmation-with-metadata',
728+
role: 'assistant',
729+
parts: [{ type: 'text', text: 'Message with extra metadata' }],
730+
metadata: {
731+
confirmation: {
732+
state: 'pending',
733+
description: 'Confirmation description',
734+
},
735+
displayText: 'Custom display text',
736+
isPermanent: true,
737+
},
738+
};
739+
740+
const { chat } = renderWithChat([messageWithExtraMetadata]);
741+
742+
const confirmButton = screen.getByText('Confirm');
743+
userEvent.click(confirmButton);
744+
745+
// New confirmed message should preserve other metadata
746+
const newMessage = chat.messages[1];
747+
expect(newMessage.metadata?.displayText).to.equal('Custom display text');
748+
expect(newMessage.metadata?.isPermanent).to.equal(true);
749+
expect(newMessage.metadata?.confirmation).to.be.undefined;
750+
});
751+
752+
it('does not render confirmation component for regular messages', function () {
753+
const regularMessage: AssistantMessage = {
754+
id: 'regular',
755+
role: 'assistant',
756+
parts: [{ type: 'text', text: 'This is a regular message' }],
757+
};
758+
759+
renderWithChat([regularMessage]);
760+
761+
expect(screen.queryByText('Please confirm your request')).to.not.exist;
762+
expect(screen.queryByText('Confirm')).to.not.exist;
763+
expect(screen.queryByText('Cancel')).to.not.exist;
764+
expect(screen.getByText('This is a regular message')).to.exist;
765+
});
766+
});
767+
536768
describe('related sources', function () {
537769
it('displays related resources links for assistant messages that include them', async function () {
538770
renderWithChat(mockMessages);

0 commit comments

Comments
 (0)