From 038f185b5745f6aca23fb6ee5d2fc827e12aff5b Mon Sep 17 00:00:00 2001
From: Rogee
Date: Fri, 21 Aug 2026 13:01:13 +0800
Subject: [PATCH] H-392: surface Captain playground failures (#66)
Co-authored-by: Rogee
---
.../assistant/AssistantPlayground.spec.js | 145 ++++++++++++++++++
.../captain/assistant/AssistantPlayground.vue | 34 ++--
.../captain/assistant/MessageList.vue | 56 +++++--
.../i18n/locale/en/integrations.json | 3 +
.../i18n/locale/zh_CN/integrations.json | 3 +
5 files changed, 213 insertions(+), 28 deletions(-)
create mode 100644 frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js
diff --git a/frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js b/frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js
new file mode 100644
index 00000000..0d7b71fa
--- /dev/null
+++ b/frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js
@@ -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: '' },
+ Icon: { template: '' },
+ Spinner: { template: '' },
+ 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/);
+ });
+});
diff --git a/frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue b/frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue
index 09e85e6c..a0d19f19 100644
--- a/frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue
+++ b/frontend/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue
@@ -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 => {
-
+
{
diff --git a/frontend/app/javascript/dashboard/components-next/captain/assistant/MessageList.vue b/frontend/app/javascript/dashboard/components-next/captain/assistant/MessageList.vue
index 961a48ac..7718c873 100644
--- a/frontend/app/javascript/dashboard/components-next/captain/assistant/MessageList.vue
+++ b/frontend/app/javascript/dashboard/components-next/captain/assistant/MessageList.vue
@@ -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);
@@ -56,8 +57,8 @@ watch(() => props.messages.length, scrollToBottom);
props.messages.length, scrollToBottom);
+
+
+ {{ t('CAPTAIN.PLAYGROUND.ERROR') }}
+
+
-
+
-
-
-
-
+
+
{{ t('CAPTAIN.PLAYGROUND.GENERATING') }}
+
diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json b/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json
index 2b79dade..9688df1b 100644
--- a/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -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": {
diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json
index 872a6b6b..a70d08b3 100644
--- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json
+++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json
@@ -439,6 +439,9 @@
"MESSAGE_PLACEHOLDER": "输入您的消息...",
"HEADER": "试验场",
"DESCRIPTION": "使用此试验场向您的助手发送消息,并检查其是否能够准确、快速地以您期望的语气做出回应。",
+ "GENERATING": "正在生成回复…",
+ "ERROR": "助手暂时无法回复。请重试,或直接发送新消息。",
+ "RETRY": "重试",
"CREDIT_NOTE": "这里发送的消息将计入您的 Captain 积分。"
},
"PAYWALL": {