H-392: surface Captain playground failures (#66)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-21 13:01:13 +08:00
committed by GitHub
co-authored by rogee
parent d948222ac6
commit 038f185b57
5 changed files with 213 additions and 28 deletions
@@ -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/);
});
});
@@ -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>
@@ -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": {