// @vitest-environment jsdom /** * Visibility-gate coverage for the assistant feedback widget. It should * appear after any successfully completed turn, and stay hidden for * streaming turns, failed runs, and empty responses. */ import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AssistantMessage } from '../../src/components/AssistantMessage'; import type { ChatMessage, ProjectFile } from '../../src/types'; beforeAll(() => { const store = new Map(); Object.defineProperty(window, 'localStorage', { configurable: true, value: { clear: () => store.clear(), getItem: (key: string) => store.get(key) ?? null, removeItem: (key: string) => store.delete(key), setItem: (key: string, value: string) => store.set(key, value), }, }); }); afterEach(() => { cleanup(); window.localStorage.clear(); }); beforeEach(() => { window.localStorage.clear(); }); function baseMessage(overrides: Partial = {}): ChatMessage { return { id: 'msg-1', role: 'assistant', content: 'Done.', runStatus: 'succeeded', startedAt: 1700000000, endedAt: 1700000005, events: [{ kind: 'text', text: 'Done.' } as ChatMessage['events'][number]], producedFiles: [], ...overrides, } as ChatMessage; } function producedFile(name: string): ProjectFile { return { name, path: name, size: 100, mtime: 1700000005, kind: 'html', mime: 'text/html', } as ProjectFile; } describe('AssistantMessage feedback gate', () => { it('shows the feedback widget after a successful turn that produced files', () => { render( , ); expect(screen.getByRole('group', { name: 'Feedback' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Helpful' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Not helpful' })).toBeTruthy(); }); it('shows the feedback widget for a successful text-only turn with no producedFiles', () => { render( , ); expect(screen.getByRole('group', { name: 'Feedback' })).toBeTruthy(); }); it('hides the feedback widget while the turn is still streaming', () => { render( , ); expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull(); }); it('hides the feedback widget when the run failed', () => { render( , ); expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull(); }); it('hides the feedback widget when the run ended with an empty_response status', () => { render( , ); expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull(); }); }); describe('AssistantMessage status badge updates (Bug A)', () => { // Regression coverage for the model-badge stale-detail bug. ACP agents // emit two `status: 'model'` events per turn: // 1. After session/new returns — the agent's initial default model // (e.g. `swe-1-6-fast` for Devin for Terminal) // 2. After session/set_config_option (or legacy session/set_model) // succeeds — the user-selected model (e.g. `claude-opus-4-7-max`) // // The previous `buildBlocks` dedupe SKIPPED the second event and the // badge stayed stuck on the initial default, even though the running // model and the conversation header were already correct. The fix // updates the existing block's detail to the latest value so the badge // tracks the most recent model the daemon reported. it('renders the most recent detail when multiple status events share a label', () => { render( , ); // Latest detail should be rendered in the badge. expect(screen.getByText('claude-opus-4-7-max')).toBeTruthy(); // The initial default must not be present — if it is, the stale-detail // bug is back. expect(screen.queryByText('swe-1-6-fast')).toBeNull(); }); it('still collapses repeated status events with the same label and detail into a single badge', () => { render( , ); const matches = screen.queryAllByText('claude-opus-4-7-max'); expect(matches.length).toBe(1); }); it('renders bare URLs in status details as links', () => { render( , ); const link = screen.getByRole('link', { name: 'https://open-design.ai/amr/wallet' }); expect(link.getAttribute('href')).toBe('https://open-design.ai/amr/wallet'); expect(link.classList.contains('md-link')).toBe(true); }); }); describe('AssistantMessage question forms', () => { it('renders only the first question form for a repeated form id in one assistant turn', () => { const firstForm = [ '', JSON.stringify({ questions: [ { id: 'audience', label: 'Who is this for?', type: 'text', }, ], }), '', ].join('\n'); const duplicateForm = [ '', JSON.stringify({ questions: [ { id: 'output', label: 'What are we making?', type: 'radio', required: true, options: ['Slide deck / pitch', 'Dashboard / tool UI'], }, ], }), '', ].join('\n'); render( , ); expect(screen.getByText('Quick brief — tailored')).toBeTruthy(); expect(screen.getByText('Who is this for?')).toBeTruthy(); expect(screen.queryByText('Quick brief — 30 seconds')).toBeNull(); expect(screen.queryByText('What are we making?')).toBeNull(); }); }); describe('AssistantMessage recovered produced files', () => { it('shows files modified during a sparse completed assistant turn', () => { render( , ); expect(screen.getByText('iphone-device-reveal.mp4')).toBeTruthy(); }); }); describe('AssistantMessage linked repo changes', () => { it('shows linked repo changes as run output', () => { render( , ); expect(screen.getByTestId('linked-repo-changes')).toBeTruthy(); expect(screen.getByText('Linked repo changes')).toBeTruthy(); expect(screen.getByText('2 changed files · 1 untracked file')).toBeTruthy(); expect(screen.getByText('repo/app')).toBeTruthy(); expect(screen.getByText(/src\/app\.ts/)).toBeTruthy(); }); });