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 => {
-