Consolidate search into a primary entry experience

This commit is contained in:
master
2026-03-07 17:44:54 +02:00
parent b429341f10
commit b689146785
5 changed files with 362 additions and 763 deletions

View File

@@ -19,7 +19,7 @@
## Delivery Tracker
### FE-ZL-001 - Search-first top-bar entry with secondary chat launcher
Status: TODO
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
@@ -27,12 +27,12 @@ Task description:
- Opening chat must inherit page context and current query when present.
Completion criteria:
- [ ] Search remains the primary focus target in the header.
- [ ] AdvisoryAI launches from a secondary icon/button beside search.
- [ ] Existing chat handoff still works from result cards and answer panels.
- [x] Search remains the primary focus target in the header.
- [x] AdvisoryAI launches from a secondary icon/button beside search.
- [x] Existing chat handoff still works from result cards and answer panels.
### FE-ZL-002 - Remove explicit mode/scope/recovery controls
Status: TODO
Status: DONE
Dependency: FE-ZL-001
Owners: Developer (FE)
Task description:
@@ -40,13 +40,13 @@ Task description:
- Move `Did you mean` immediately under the input.
Completion criteria:
- [ ] No explicit mode control remains in the search panel.
- [ ] No explicit scope toggle remains in the search panel.
- [ ] No recovery panel remains in empty-result states.
- [ ] `Did you mean` renders directly under the input when present.
- [x] No explicit mode control remains in the search panel.
- [x] No explicit scope toggle remains in the search panel.
- [x] No recovery panel remains in empty-result states.
- [x] `Did you mean` renders directly under the input when present.
### FE-ZL-003 - History cleanup and low-emphasis clear action
Status: TODO
Status: DONE
Dependency: FE-ZL-002
Owners: Developer (FE)
Task description:
@@ -54,32 +54,34 @@ Task description:
- Replace the current clear-history button with a discrete icon action.
Completion criteria:
- [ ] Recent history excludes searches with zero results.
- [ ] Clear-history affordance is icon-based and visually low emphasis.
- [ ] Search history tests cover the new behavior.
- [x] Recent history excludes searches with zero results.
- [x] Clear-history affordance is icon-based and visually low emphasis.
- [x] Search history tests cover the new behavior.
### FE-ZL-004 - Focused FE verification
Status: TODO
Status: DONE
Dependency: FE-ZL-003
Owners: Test Automation
Task description:
- Add or update Angular and Playwright tests for the consolidated UI model.
Completion criteria:
- [ ] Unit tests cover removed controls and new placement rules.
- [ ] Playwright covers the new top-bar, history, and `Did you mean` behavior.
- [ ] Tests do not rely on deprecated `mode` or `scope` UI controls.
- [x] Unit tests cover removed controls and new placement rules.
- [x] Playwright covers the new top-bar, history, and `Did you mean` behavior.
- [x] Tests do not rely on deprecated `mode` or `scope` UI controls.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created from the zero-learning search strategy. | Project Manager |
| 2026-03-07 | Implemented the search-first shell: added the secondary AdvisoryAI launcher, removed explicit mode/scope/recovery controls, moved `Did you mean` under the input, migrated recent history to success-only storage, and updated focused Angular plus Playwright coverage. Commands: `npm test -- --include src/tests/global_search/global-search.component.spec.ts`; `npx playwright test tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts tests/e2e/unified-search-experience-quality.e2e.spec.ts --config playwright.config.ts`. Results: `22/22` unit tests passed and `11/11` Playwright tests passed. | Developer / Test Automation |
## Decisions & Risks
- Decision: the user should not be asked to choose a search mode before entering a query.
- Decision: page scope becomes implicit UX, not an explicit control.
- Risk: removing explicit controls may expose gaps in backend ranking that modes were previously masking.
- Mitigation: phase 2 adds implicit-scope weighting and answer blending on the backend.
- Verification note: focused Playwright runs still log an unrelated Angular `NG0602` console error from `PlatformContextUrlSyncService.initialize` plus several refused background requests. The targeted search flows remained green, but that runtime issue should be handled separately by the stabilization workstream.
## Next Checkpoints
- 2026-03-08: Implement FE-ZL-001 through FE-ZL-003.

View File

@@ -6,7 +6,6 @@ import type { EntityCard } from '../../app/core/api/unified-search.models';
import { UnifiedSearchClient } from '../../app/core/api/unified-search.client';
import { AmbientContextService } from '../../app/core/services/ambient-context.service';
import { SearchChatContextService } from '../../app/core/services/search-chat-context.service';
import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service';
import { I18nService } from '../../app/core/i18n';
import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component';
@@ -18,7 +17,6 @@ describe('GlobalSearchComponent', () => {
let routerEvents: Subject<unknown>;
let router: { url: string; events: Subject<unknown>; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
let searchChatContext: jasmine.SpyObj<SearchChatContextService>;
let searchExperienceMode: SearchExperienceModeService;
beforeEach(async () => {
localStorage.clear();
@@ -168,7 +166,6 @@ describe('GlobalSearchComponent', () => {
fixture = TestBed.createComponent(GlobalSearchComponent);
component = fixture.componentInstance;
searchExperienceMode = TestBed.inject(SearchExperienceModeService);
fixture.detectChanges();
});
@@ -302,12 +299,12 @@ describe('GlobalSearchComponent', () => {
it('navigates to assistant host with openChat intent from Ask AI card action', () => {
const card = createCard('findings', '/triage/findings/fnd-1');
searchExperienceMode.setMode('act');
component.onAskAiFromCard(card);
expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({
mode: 'act',
query: 'findings sample',
suggestedPrompt: jasmine.stringMatching(/why it matters/i),
}));
expect(router.navigate).toHaveBeenCalledWith(
['/security/triage'],
@@ -413,30 +410,30 @@ describe('GlobalSearchComponent', () => {
expect(component.isFocused()).toBeTrue();
});
it('keeps the search panel open when focus moves into experience controls', async () => {
it('keeps the search panel open when focus moves to the chat launcher', async () => {
component.onFocus();
component.onQueryChange('critical findings');
await waitForDebounce();
fixture.detectChanges();
const explainButton = fixture.nativeElement.querySelectorAll('.search__segment')[1] as HTMLButtonElement | undefined;
expect(explainButton).toBeDefined();
const chatLauncher = fixture.nativeElement.querySelector('.search__chat-launcher') as HTMLButtonElement | null;
expect(chatLauncher).not.toBeNull();
explainButton!.focus();
chatLauncher!.focus();
component.onBlur();
await new Promise((resolve) => setTimeout(resolve, 250));
expect(component.isFocused()).toBeTrue();
});
it('renders rescue actions when a query returns no results', async () => {
it('does not render recovery actions when a query returns no results', async () => {
component.onFocus();
component.onQueryChange('no results');
await waitForDebounce();
fixture.detectChanges();
const rescueCards = fixture.nativeElement.querySelectorAll('.search__rescue-card');
expect(rescueCards.length).toBe(4);
expect(rescueCards.length).toBe(0);
});
it('renders a grounded answer panel before search results', async () => {
@@ -498,40 +495,31 @@ describe('GlobalSearchComponent', () => {
expect(answerQuestions).toContain('Should I focus on reachable, production, or unresolved findings?');
});
it('retries the active query globally when scope rescue toggles off page filtering', async () => {
it('does not hard-filter search requests to the current route scope', async () => {
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
component.onFocus();
component.onQueryChange('critical findings');
await waitForDebounce();
const scopedCall = searchClient.search.calls.mostRecent();
expect(scopedCall).toBeDefined();
expect(scopedCall!.args[1]).toEqual({ domains: ['findings'] });
searchClient.search.calls.reset();
component.runRescueAction('scope');
await waitForDebounce();
const globalCall = searchClient.search.calls.mostRecent();
expect(globalCall).toBeDefined();
expect(globalCall!.args[1]).toBeUndefined();
expect(ambientContext.recordAction).toHaveBeenCalledWith(jasmine.objectContaining({
action: 'search_rescue_scope',
}));
const searchCall = searchClient.search.calls.mostRecent();
expect(searchCall).toBeDefined();
expect(searchCall!.args[1]).toBeUndefined();
});
it('opens AdvisoryAI reformulation with the current mode and query context', () => {
searchExperienceMode.setMode('explain');
it('opens AdvisoryAI from the search bar with page and query context', () => {
component.onFocus();
component.query.set('mismatch');
component.runRescueAction('reformulate');
const chatLauncher = fixture.nativeElement.querySelector('.search__chat-launcher') as HTMLButtonElement | null;
expect(chatLauncher).not.toBeNull();
chatLauncher!.click();
fixture.detectChanges();
expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({
query: 'mismatch',
mode: 'explain',
suggestedPrompt: jasmine.stringMatching(/Reformulate the search query "mismatch"/),
suggestedPrompt: jasmine.stringMatching(/searched for "mismatch"/i),
}));
expect(router.navigate).toHaveBeenCalledWith(
['/security/triage'],
@@ -544,24 +532,67 @@ describe('GlobalSearchComponent', () => {
);
});
it('drops the route filter when search scope is toggled to global', async () => {
it('does not render explicit mode or scope controls', async () => {
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
component.onFocus();
component.onQueryChange('CVE-2024-21626');
await waitForDebounce();
const pageScopedCall = searchClient.search.calls.mostRecent();
expect(pageScopedCall).toBeDefined();
expect(pageScopedCall!.args[1]).toEqual({ domains: ['findings'] });
searchClient.search.calls.reset();
component.toggleSearchScope();
await waitForDebounce();
expect(fixture.nativeElement.querySelector('.search__segment')).toBeNull();
expect(fixture.nativeElement.querySelector('.search__scope-chip')).toBeNull();
});
expect(component.searchScope()).toBe('global');
const unscopedCall = searchClient.search.calls.mostRecent();
expect(unscopedCall).toBeDefined();
expect(unscopedCall!.args[1]).toBeUndefined();
it('filters zero-result entries from server-backed recent history', () => {
searchClient.getHistory.and.returnValue(of([
{
historyId: 'hist-1',
query: 'critical findings',
resultCount: 3,
createdAt: '2026-03-07T10:00:00Z',
},
{
historyId: 'hist-2',
query: 'database connectivity',
resultCount: 0,
createdAt: '2026-03-07T10:01:00Z',
},
] as any));
component.onFocus();
fixture.detectChanges();
expect(component.recentSearches()).toEqual(['critical findings']);
});
it('ignores legacy mixed-result local history keys and persists the successful-only key', () => {
localStorage.setItem('stella-recent-searches', JSON.stringify(['old zero result', 'old success']));
searchClient.search.and.returnValue(of({
query: 'critical findings',
topK: 10,
cards: [createCard('findings', '/triage/findings/fnd-legacy')],
synthesis: null,
diagnostics: {
ftsMatches: 1,
vectorMatches: 0,
entityCardCount: 1,
durationMs: 2,
usedVector: false,
mode: 'fts-only',
},
}));
component.onFocus();
fixture.detectChanges();
expect(component.recentSearches()).toEqual([]);
component.onQueryChange('critical findings');
return waitForDebounce().then(() => {
expect(JSON.parse(localStorage.getItem('stella-successful-searches-v2') ?? '[]')).toEqual(['critical findings']);
expect(localStorage.getItem('stella-recent-searches')).toBe(JSON.stringify(['old zero result', 'old success']));
});
});
function createCard(domain: EntityCard['domain'], route: string): EntityCard {

View File

@@ -31,23 +31,10 @@ const criticalFindingResponse = buildResponse(
},
);
const broadenedScopeResponse = buildResponse(
'scope sensitive outage',
[
policyCard({
ruleId: 'DENY-CRITICAL-PROD',
title: 'DENY-CRITICAL-PROD',
snippet: 'Production deny rule linked to the active incident.',
}),
],
{
summary: 'The broader search found a policy blocker outside the page scope.',
template: 'policy_overview',
confidence: 'high',
sourceCount: 1,
domainsCovered: ['policy'],
},
);
const correctionResponse = {
...emptyResponse('critcal findings'),
suggestions: [{ text: 'critical findings', reason: 'Close match in the active corpus.' }],
};
const policyBlockerResponse = buildResponse(
'policy blockers for CVE-2024-21626',
@@ -73,7 +60,8 @@ test.describe('Unified Search - Experience Quality UX', () => {
await setupAuthenticatedSession(page);
});
test('keeps keyboard-selected mode when handing off from search to AdvisoryAI', async ({ page }) => {
test('opens AdvisoryAI from the secondary search-bar launcher with page and query context', async ({ page }) => {
const capturedTurnBodies: Array<Record<string, unknown>> = [];
await mockSearchResponses(page, (query) => {
if (query.includes('critical findings')) {
return criticalFindingResponse;
@@ -82,9 +70,10 @@ test.describe('Unified Search - Experience Quality UX', () => {
return emptyResponse(query);
});
await mockChatConversation(page, {
content: 'AdvisoryAI is ready to explain the finding and cite evidence.',
content: 'AdvisoryAI is ready to expand the answer and explain the next step.',
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
groundingScore: 0.94,
onTurnCreate: (body) => capturedTurnBodies.push(body),
});
await page.goto('/security/triage');
@@ -94,37 +83,25 @@ test.describe('Unified Search - Experience Quality UX', () => {
await waitForResults(page);
await waitForEntityCards(page, 1);
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
const explainButton = page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' });
await explainButton.focus();
await explainButton.press('Enter');
await expect(explainButton).toHaveClass(/search__segment--active/);
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.locator('.entity-card__action--ask-ai').first().click();
await page.locator('.search__chat-launcher').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('');
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
const turnBody = capturedTurnBodies.at(-1) ?? {};
expect(String(turnBody['content'] ?? '')).toMatch(/critical findings/i);
});
test('broadens zero-result searches to all domains and reruns the same query', async ({ page }) => {
test('renders did-you-mean directly below the search bar and removes teaching controls', async ({ page }) => {
const capturedRequests: Array<Record<string, unknown>> = [];
await page.route('**/search/query**', async (route) => {
const request = route.request().postDataJSON() as Record<string, unknown>;
capturedRequests.push(request);
const query = String(request['q'] ?? '').toLowerCase();
const filters = request['filters'] as Record<string, unknown> | undefined;
const hasPageScope = Array.isArray(filters?.['domains']) && filters!['domains'].length > 0;
const response = query.includes('scope sensitive outage')
? hasPageScope
? emptyResponse('scope sensitive outage')
: broadenedScopeResponse
: emptyResponse(query);
const response = query.includes('critcal findings')
? correctionResponse
: criticalFindingResponse;
return route.fulfill({
status: 200,
@@ -136,28 +113,63 @@ test.describe('Unified Search - Experience Quality UX', () => {
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await typeInSearch(page, 'scope sensitive outage');
await typeInSearch(page, 'critcal findings');
await waitForResults(page);
await expect(page.locator('.search__empty')).toContainText(/no results found/i);
await expect(page.locator('.search__rescue-card')).toHaveCount(4);
await page.locator('[data-rescue-action="scope"]').click();
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await expect(page.locator('[data-role="search-scope"]')).toContainText(/All domains/i);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('scope sensitive outage');
await expect(page.locator('.search__cards')).toContainText(/DENY-CRITICAL-PROD/i);
const searchBar = page.locator('.search__input-wrapper');
const didYouMean = page.locator('.did-you-mean--inline');
expect(capturedRequests[0]?.['q']).toBe('scope sensitive outage');
await expect(didYouMean).toBeVisible();
await expect(page.locator('.search__segment')).toHaveCount(0);
await expect(page.locator('.search__scope-chip')).toHaveCount(0);
await expect(page.locator('.search__rescue-card')).toHaveCount(0);
const searchBarBox = await searchBar.boundingBox();
const didYouMeanBox = await didYouMean.boundingBox();
expect(searchBarBox).not.toBeNull();
expect(didYouMeanBox).not.toBeNull();
expect(didYouMeanBox!.y).toBeGreaterThan(searchBarBox!.y);
expect(didYouMeanBox!.y - (searchBarBox!.y + searchBarBox!.height)).toBeLessThan(20);
const request = capturedRequests[0] ?? {};
expect(request['filters']).toBeUndefined();
});
test('opens AdvisoryAI reformulation from the zero-result rescue flow', async ({ page }) => {
await mockSearchResponses(page, (query) =>
query.includes('mystery remediation') ? emptyResponse('mystery remediation') : emptyResponse(query));
await mockChatConversation(page, {
content: 'I can reformulate that query for better recall.',
citations: [{ type: 'docs', path: 'modules/ui/search-chip-context-contract.md', verified: true }],
groundingScore: 0.91,
test('shows only successful history entries and clears them with the icon action', async ({ page }) => {
let historyCleared = false;
await mockSearchResponses(page, (query) => {
if (query.includes('critical findings')) {
return criticalFindingResponse;
}
return emptyResponse(query);
});
await page.route('**/api/v1/advisory-ai/search/history', async (route) => {
if (route.request().method() === 'DELETE') {
historyCleared = true;
return route.fulfill({ status: 204, body: '' });
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
entries: [
{
historyId: 'history-1',
query: 'critical findings',
resultCount: 2,
createdAt: '2026-03-07T11:00:00Z',
},
{
historyId: 'history-2',
query: 'database connectivity',
resultCount: 0,
createdAt: '2026-03-07T11:01:00Z',
},
],
}),
});
});
await page.goto('/security/triage');
@@ -165,18 +177,19 @@ test.describe('Unified Search - Experience Quality UX', () => {
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' }).click();
await typeInSearch(page, 'mystery remediation');
await waitForResults(page);
await expect(page.locator('.search__empty')).toContainText(/no results found/i);
await page.locator('[data-rescue-action="reformulate"]').click();
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toContainText('critical findings');
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).not.toContainText('database connectivity');
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(
/Reformulate the search query "mystery remediation"/i,
);
const clearButton = page.locator('.search__clear-history');
await expect(clearButton).toBeVisible();
await expect(clearButton.locator('svg')).toBeVisible();
await expect(clearButton).toHaveText('');
await clearButton.click();
await expect.poll(() => historyCleared).toBe(true);
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toHaveCount(0);
});
test('returns structured next-step policy searches back into global search with metadata', async ({ page }) => {
@@ -211,11 +224,6 @@ test.describe('Unified Search - Experience Quality UX', () => {
await typeInSearch(page, 'critical findings');
await waitForResults(page);
await waitForEntityCards(page, 1);
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.locator('.entity-card__action--ask-ai').first().click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
@@ -226,10 +234,10 @@ test.describe('Unified Search - Experience Quality UX', () => {
await expect(page.locator('.assistant-drawer')).toBeHidden({ timeout: 10_000 });
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(/policy blockers for CVE-2024-21626/i);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(/policy .*CVE-2024-21626/i);
const policyRequest = capturedRequests.find((request) =>
String(request['q'] ?? '').toLowerCase().includes('policy blockers for cve-2024-21626'));
/policy .*cve-2024-21626/i.test(String(request['q'] ?? '')));
const ambient = policyRequest?.['ambient'] as Record<string, unknown> | undefined;
const lastAction = ambient?.['lastAction'] as Record<string, unknown> | undefined;
@@ -260,6 +268,8 @@ async function mockChatConversation(
content: string;
citations: Array<{ type: string; path: string; verified: boolean }>;
groundingScore: number;
onConversationCreate?: (body: Record<string, unknown>) => void;
onTurnCreate?: (body: Record<string, unknown>) => void;
},
): Promise<void> {
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
@@ -271,6 +281,9 @@ async function mockChatConversation(
});
}
const requestBody = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {};
response.onConversationCreate?.(requestBody);
return route.fulfill({
status: 200,
contentType: 'application/json',
@@ -291,6 +304,9 @@ async function mockChatConversation(
return route.continue();
}
const requestBody = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {};
response.onTurnCreate?.(requestBody);
const events = [
'event: progress',
'data: {"stage":"searching"}',

View File

@@ -133,21 +133,20 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
);
});
test('opens AdvisoryAI from the answer panel with mode-aware context', async ({ page }) => {
test('opens AdvisoryAI from the answer panel with grounded answer context', async ({ page }) => {
const capturedTurnBodies: Array<Record<string, unknown>> = [];
await mockSearchResponses(page, (query) =>
query.includes('critical findings') ? groundedFindingResponse : emptyResponse(query));
await mockChatConversation(page, {
content: 'I can expand the grounded answer, explain the evidence, and recommend the safest next step.',
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
groundingScore: 0.95,
onTurnCreate: (body) => capturedTurnBodies.push(body),
});
await page.goto('/security/triage');
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
await typeInSearch(page, 'critical findings');
await waitForResults(page);
await waitForEntityCards(page, 1);
@@ -155,9 +154,11 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
await page.locator('[data-answer-action="ask-ai"]').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Act/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/Expand the grounded answer/i);
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/critical findings/i);
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/Expand the grounded answer/i);
expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/critical findings/i);
});
});
@@ -182,6 +183,7 @@ async function mockChatConversation(
content: string;
citations: Array<{ type: string; path: string; verified: boolean }>;
groundingScore: number;
onTurnCreate?: (body: Record<string, unknown>) => void;
},
): Promise<void> {
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
@@ -213,6 +215,9 @@ async function mockChatConversation(
return route.continue();
}
const requestBody = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {};
response.onTurnCreate?.(requestBody);
const events = [
'event: progress',
'data: {"stage":"searching"}',