H-392: surface Captain playground failures (#66)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
+145
@@ -0,0 +1,145 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import AssistantPlayground from './AssistantPlayground.vue';
|
||||
|
||||
const mocked = vi.hoisted(() => ({ playground: vi.fn() }));
|
||||
|
||||
vi.mock('dashboard/api/captain/assistant', () => ({
|
||||
default: { playground: mocked.playground },
|
||||
}));
|
||||
|
||||
vi.mock('shared/composables/useMessageFormatter', () => ({
|
||||
useMessageFormatter: () => ({ formatMessage: message => message }),
|
||||
}));
|
||||
|
||||
const mountPlayground = () =>
|
||||
mount(AssistantPlayground, {
|
||||
props: { assistantId: 7 },
|
||||
global: {
|
||||
stubs: {
|
||||
Avatar: { template: '<span />' },
|
||||
Icon: { template: '<span />' },
|
||||
Spinner: { template: '<span />' },
|
||||
NextButton: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sendMessage = async (wrapper, content) => {
|
||||
await wrapper.get('input').setValue(content);
|
||||
await wrapper.findAll('button').at(-1).trigger('click');
|
||||
};
|
||||
|
||||
describe('AssistantPlayground', () => {
|
||||
beforeEach(() => {
|
||||
mocked.playground.mockReset();
|
||||
});
|
||||
|
||||
it('shows an accessible loading row and prevents duplicate sends', async () => {
|
||||
let resolveRequest;
|
||||
mocked.playground.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolveRequest = resolve;
|
||||
})
|
||||
);
|
||||
const wrapper = mountPlayground();
|
||||
|
||||
await sendMessage(wrapper, 'Hello');
|
||||
await wrapper.get('input').setValue('Another message');
|
||||
await wrapper.get('input').trigger('keydown.enter');
|
||||
await wrapper.findAll('button').at(-1).trigger('click');
|
||||
|
||||
expect(mocked.playground).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.get('[role="status"]').attributes('aria-live')).toBe(
|
||||
'polite'
|
||||
);
|
||||
expect(wrapper.get('[role="status"]').text()).toContain(
|
||||
'Generating response...'
|
||||
);
|
||||
expect(wrapper.findAll('button').at(-1).attributes('disabled')).toBe('');
|
||||
|
||||
resolveRequest({ data: { response: 'Hi there' } });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('Hi there');
|
||||
});
|
||||
|
||||
it('maps fail-closed responses to a generic retryable error', async () => {
|
||||
let resolveRetry;
|
||||
mocked.playground
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
response: 'conversation_handoff',
|
||||
reasoning: 'raw provider timeout',
|
||||
provider: {
|
||||
base_url: 'https://private.example',
|
||||
api_key: 'secret',
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveRetry = resolve;
|
||||
})
|
||||
);
|
||||
const wrapper = mountPlayground();
|
||||
|
||||
await sendMessage(wrapper, 'Original question');
|
||||
await flushPromises();
|
||||
|
||||
const alert = wrapper.get('[role="alert"]');
|
||||
const retryButton = alert.get('button');
|
||||
expect(alert.text()).toContain(
|
||||
"The assistant couldn't respond. Try again or send a new message."
|
||||
);
|
||||
expect(retryButton.element.tagName).toBe('BUTTON');
|
||||
expect(retryButton.attributes('type')).toBe('button');
|
||||
expect(wrapper.html()).not.toMatch(
|
||||
/conversation_handoff|reasoning|private\.example|secret/
|
||||
);
|
||||
|
||||
await retryButton.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.get('[role="alert"] button').attributes('disabled')).toBe(
|
||||
''
|
||||
);
|
||||
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
|
||||
|
||||
resolveRetry({ data: { response: 'Recovered answer' } });
|
||||
await flushPromises();
|
||||
|
||||
expect(mocked.playground).toHaveBeenCalledTimes(2);
|
||||
expect(mocked.playground.mock.calls[1][0].messageContent).toBe(
|
||||
'Original question'
|
||||
);
|
||||
expect(wrapper.text().match(/Original question/g)).toHaveLength(1);
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('Recovered answer');
|
||||
});
|
||||
|
||||
it('keeps the failed row while a new message succeeds', async () => {
|
||||
mocked.playground
|
||||
.mockRejectedValueOnce(
|
||||
new Error('raw provider error with api_key=secret')
|
||||
)
|
||||
.mockResolvedValueOnce({ data: { response: 'New answer' } });
|
||||
const wrapper = mountPlayground();
|
||||
|
||||
await sendMessage(wrapper, 'Failed question');
|
||||
await flushPromises();
|
||||
await sendMessage(wrapper, 'New question');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocked.playground).toHaveBeenCalledTimes(2);
|
||||
expect(mocked.playground.mock.calls[1][0].messageContent).toBe(
|
||||
'New question'
|
||||
);
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(
|
||||
"The assistant couldn't respond. Try again or send a new message."
|
||||
);
|
||||
expect(wrapper.text()).toContain('New answer');
|
||||
expect(wrapper.html()).not.toMatch(/api_key|secret|raw provider error/);
|
||||
});
|
||||
});
|
||||
+21
-13
@@ -47,17 +47,19 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim() || isLoading.value) return;
|
||||
const sendMessage = async (retryMessage = null) => {
|
||||
const currentMessage = retryMessage?.content ?? newMessage.value;
|
||||
if (!currentMessage.trim() || isLoading.value) return;
|
||||
|
||||
const userMessage = {
|
||||
content: newMessage.value,
|
||||
const userMessage = retryMessage || {
|
||||
content: currentMessage,
|
||||
sender: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
messages.value.push(userMessage);
|
||||
const currentMessage = newMessage.value;
|
||||
newMessage.value = '';
|
||||
if (!retryMessage) {
|
||||
messages.value.push(userMessage);
|
||||
newMessage.value = '';
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
@@ -67,15 +69,17 @@ const sendMessage = async () => {
|
||||
messageHistory: formatMessagesForApi(),
|
||||
});
|
||||
|
||||
if (data.response === 'conversation_handoff') throw new Error();
|
||||
|
||||
userMessage.status = undefined;
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error getting assistant response:', error);
|
||||
} catch {
|
||||
userMessage.status = 'failed';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -110,7 +114,11 @@ const handleEnterKey = event => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||
<MessageList
|
||||
:messages="messages"
|
||||
:is-loading="isLoading"
|
||||
@retry="sendMessage"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
|
||||
@@ -124,9 +132,9 @@ const handleEnterKey = event => {
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
:disabled="!newMessage.trim()"
|
||||
:disabled="isLoading || !newMessage.trim()"
|
||||
icon="i-lucide-send"
|
||||
@click="sendMessage"
|
||||
@click="sendMessage()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+41
-15
@@ -2,6 +2,8 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -15,6 +17,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
|
||||
const messageContainer = ref(null);
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -22,9 +26,6 @@ const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isUserMessage = sender => sender === 'user';
|
||||
|
||||
const getMessageAlignment = sender =>
|
||||
isUserMessage(sender) ? 'justify-end' : 'justify-start';
|
||||
|
||||
const getMessageDirection = sender =>
|
||||
isUserMessage(sender) ? 'flex-row-reverse' : 'flex-row';
|
||||
|
||||
@@ -45,7 +46,7 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.messages.length, scrollToBottom);
|
||||
watch([() => props.messages.length, () => props.isLoading], scrollToBottom);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,8 +57,8 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
class="flex"
|
||||
:class="getMessageAlignment(message.sender)"
|
||||
class="flex flex-col gap-2"
|
||||
:class="isUserMessage(message.sender) ? 'items-end' : 'items-start'"
|
||||
>
|
||||
<div
|
||||
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
|
||||
@@ -76,21 +77,46 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
<div v-html="formatMessage(message.content)" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.status === 'failed'"
|
||||
role="alert"
|
||||
class="flex items-center gap-2 max-w-[90%] md:max-w-[60%] text-xs text-n-ruby-11"
|
||||
>
|
||||
<Icon icon="i-lucide-alert-triangle" class="size-4 shrink-0" />
|
||||
<span>{{ t('CAPTAIN.PLAYGROUND.ERROR') }}</span>
|
||||
<NextButton
|
||||
type="button"
|
||||
link
|
||||
ruby
|
||||
xs
|
||||
:label="t('CAPTAIN.PLAYGROUND.RETRY')"
|
||||
:disabled="isLoading"
|
||||
@click="emit('retry', message)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isLoading" class="flex justify-start">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="flex justify-start"
|
||||
>
|
||||
<div class="flex items-start gap-1.5">
|
||||
<Avatar :name="getAvatarName('assistant')" rounded-full :size="24" />
|
||||
<div
|
||||
class="max-w-sm rounded-lg p-3 text-sm bg-n-solid-iris text-n-slate-12"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce" />
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.2s]"
|
||||
/>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.4s]"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ t('CAPTAIN.PLAYGROUND.GENERATING') }}</span>
|
||||
<div aria-hidden="true" class="flex gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce" />
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.2s]"
|
||||
/>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.4s]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -440,6 +440,9 @@
|
||||
"MESSAGE_PLACEHOLDER": "Type your message...",
|
||||
"HEADER": "Playground",
|
||||
"DESCRIPTION": "Use this playground to send messages to your assistant and check if it responds accurately, quickly, and in the tone you expect.",
|
||||
"GENERATING": "Generating response...",
|
||||
"ERROR": "The assistant couldn't respond. Try again or send a new message.",
|
||||
"RETRY": "Retry",
|
||||
"CREDIT_NOTE": "Messages sent here will count toward your Captain credits."
|
||||
},
|
||||
"PAYWALL": {
|
||||
|
||||
@@ -439,6 +439,9 @@
|
||||
"MESSAGE_PLACEHOLDER": "输入您的消息...",
|
||||
"HEADER": "试验场",
|
||||
"DESCRIPTION": "使用此试验场向您的助手发送消息,并检查其是否能够准确、快速地以您期望的语气做出回应。",
|
||||
"GENERATING": "正在生成回复…",
|
||||
"ERROR": "助手暂时无法回复。请重试,或直接发送新消息。",
|
||||
"RETRY": "重试",
|
||||
"CREDIT_NOTE": "这里发送的消息将计入您的 Captain 积分。"
|
||||
},
|
||||
"PAYWALL": {
|
||||
|
||||
Reference in New Issue
Block a user