UNPKG

chrome-devtools-frontend

Version:
449 lines (391 loc) 21.4 kB
// Copyright 2025 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Common from '../../../core/common/common.js'; import * as Host from '../../../core/host/host.js'; import * as Platform from '../../../core/platform/platform.js'; import * as TimelineUtils from '../../../panels/timeline/utils/utils.js'; import {mockAidaClient} from '../../../testing/AiAssistanceHelpers.js'; import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js'; import {getInsightOrError} from '../../../testing/InsightHelpers.js'; import {TraceLoader} from '../../../testing/TraceLoader.js'; import * as Trace from '../../trace/trace.js'; import { type ActionResponse, InsightContext, PerformanceInsightFormatter, PerformanceInsightsAgent, ResponseType, TraceEventFormatter, } from '../ai_assistance.js'; const FAKE_LCP_MODEL = { insightKey: Trace.Insights.Types.InsightKeys.LCP_PHASES, strings: {}, title: 'LCP by phase' as Common.UIString.LocalizedString, description: 'some description' as Common.UIString.LocalizedString, category: Trace.Insights.Types.InsightCategory.ALL, state: 'fail', frameId: '123', } as const; const FAKE_INP_MODEL = { insightKey: Trace.Insights.Types.InsightKeys.INTERACTION_TO_NEXT_PAINT, strings: {}, title: 'INP by phase' as Common.UIString.LocalizedString, description: 'some description' as Common.UIString.LocalizedString, category: Trace.Insights.Types.InsightCategory.ALL, state: 'fail', frameId: '123', } as const; const FAKE_PARSED_TRACE = { Meta: {traceBounds: {min: 0, max: 10}}, } as unknown as Trace.Handlers.Types.ParsedTrace; describeWithEnvironment('PerformanceInsightsAgent', () => { it('uses the min and max bounds of the trace as the origin', async function() { const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const context = new InsightContext(activeInsight); assert.strictEqual(context.getOrigin(), 'trace-658799706428-658804825864'); }); it('outputs the right title for the selected insight', async () => { const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE); const context = new InsightContext(mockInsight); assert.strictEqual(context.getTitle(), 'Insight: LCP by phase'); }); // See b/405054694 for context on why we do this. describe('parsing text responses', () => { it('strips out 5 backticks if the response has them', async () => { const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()}); const response = agent.parseTextResponse('`````hello world`````'); assert.deepEqual(response, {answer: 'hello world'}); }); it('strips any newlines before the backticks', async () => { const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()}); const response = agent.parseTextResponse('\n\n`````hello world`````'); assert.deepEqual(response, {answer: 'hello world'}); }); it('does not strip the backticks if the response does not fully start and end with them', async () => { const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()}); const response = agent.parseTextResponse('answer: `````hello world`````'); assert.deepEqual(response, {answer: 'answer: `````hello world`````'}); }); it('does not strip the backticks in the middle of the response even if the response is also wrapped', async () => { const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()}); const response = agent.parseTextResponse('`````hello ````` world`````'); assert.deepEqual(response, {answer: 'hello ````` world'}); }); it('does not strip out inline code backticks', async () => { const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()}); const response = agent.parseTextResponse('This is code `console.log("hello")`'); assert.deepEqual(response, {answer: 'This is code `console.log("hello")`'}); }); it('does not strip out code block 3 backticks', async () => { const agent = new PerformanceInsightsAgent({aidaClient: mockAidaClient()}); const response = agent.parseTextResponse(`\`\`\` code \`\`\``); assert.deepEqual(response, { answer: `\`\`\` code \`\`\`` }); }); }); describe('handleContextDetails', () => { it('outputs the right context for the initial query from the user', async () => { const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE); const context = new InsightContext(mockInsight); const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient([[{ explanation: 'This is the answer', metadata: { rpcGlobalId: 123, } }]]) }); const expectedDetailText = new PerformanceInsightFormatter(mockInsight).formatInsight(); const responses = await Array.fromAsync(agent.run('test', {selected: context})); assert.deepEqual(responses, [ { type: ResponseType.USER_QUERY, query: 'test', imageInput: undefined, imageId: undefined, }, { type: ResponseType.CONTEXT, title: 'Analyzing insight: LCP by phase', details: [ {title: 'LCP by phase', text: expectedDetailText}, ], }, { type: ResponseType.QUERYING, }, { type: ResponseType.ANSWER, text: 'This is the answer', complete: true, suggestions: undefined, rpcId: 123, }, ]); }); }); describe('enhanceQuery', () => { it('adds the context to the query from the user', async () => { const agent = new PerformanceInsightsAgent({ aidaClient: {} as Host.AidaClient.AidaClient, }); const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE); const context = new InsightContext(mockInsight); const extraContext = new PerformanceInsightFormatter(mockInsight).formatInsight(); const finalQuery = await agent.enhanceQuery('What is this?', context); const expected = `${extraContext} # User question for you to answer: What is this?`; assert.strictEqual(finalQuery, expected); }); it('does not add the context for follow-up queries with the same context', async () => { const agent = new PerformanceInsightsAgent({ aidaClient: {} as Host.AidaClient.AidaClient, }); const mockInsight = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE); const context = new InsightContext(mockInsight); await agent.enhanceQuery('What is this?', context); const finalQuery = await agent.enhanceQuery('Help me understand?', context); const expected = `# User question for you to answer: Help me understand?`; assert.strictEqual(finalQuery, expected); }); it('does add context to queries if the insight context changes', async () => { const agent = new PerformanceInsightsAgent({ aidaClient: {} as Host.AidaClient.AidaClient, }); const mockInsight1 = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_LCP_MODEL, FAKE_PARSED_TRACE); const mockInsight2 = new TimelineUtils.InsightAIContext.ActiveInsight(FAKE_INP_MODEL, FAKE_PARSED_TRACE); const context1 = new InsightContext(mockInsight1); const context2 = new InsightContext(mockInsight2); const firstQuery = await agent.enhanceQuery('Q1', context1); const secondQuery = await agent.enhanceQuery('Q2', context1); const thirdQuery = await agent.enhanceQuery('Q3', context2); assert.include(firstQuery, '## Insight Title: LCP by phase'); assert.notInclude(secondQuery, '## Insight Title'); assert.include(thirdQuery, '## Insight Title: INP by phase'); }); }); describe('function calls', () => { it('calls getNetworkActivitySummary and logs the response bytes size', async function() { const metricsSpy = sinon.spy(Host.userMetrics, 'performanceAINetworkSummaryResponseSize'); const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient([ [{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}] ]) }); const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const context = new InsightContext(activeInsight); const responses = await Array.fromAsync(agent.run('test', {selected: context})); const action = responses.find(response => response.type === ResponseType.ACTION); // Find the requests we expect the handler to have returned. const expectedRequestUrls = [ 'https://chromedevtools.github.io/performance-stories/lcp-large-image/index.html', 'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@1,800', 'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css', 'https://via.placeholder.com/50.jpg', 'https://via.placeholder.com/2000.jpg' ]; const requests = expectedRequestUrls.map(url => { const match = parsedTrace.NetworkRequests.byTime.find(r => r.args.data.url === url); assert.isOk(match, `no request found for ${url}`); return match; }); const expectedRequestsOutput = requests.map(r => TraceEventFormatter.networkRequest(r, parsedTrace, {verbose: false})); const expectedBytesSize = Platform.StringUtilities.countWtf8Bytes(expectedRequestsOutput.join('\n')); sinon.assert.calledWith(metricsSpy, expectedBytesSize); const expectedOutput = JSON.stringify({requests: expectedRequestsOutput}); const titleResponse = responses.find(response => response.type === ResponseType.TITLE); assert.exists(titleResponse); assert.strictEqual(titleResponse.title, 'Investigating network activity…'); assert.exists(action); assert.deepEqual(action, { type: 'action' as ActionResponse['type'], output: expectedOutput, code: 'getNetworkActivitySummary()', canceled: false }); }); it('can call getNetworkRequestDetail to get detail about a single request', async function() { const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const requestUrl = 'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css'; const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient([ [{explanation: '', functionCalls: [{name: 'getNetworkRequestDetail', args: {url: requestUrl}}]}], [{explanation: 'done'}] ]) }); const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const context = new InsightContext(activeInsight); const responses = await Array.fromAsync(agent.run('test', {selected: context})); const titleResponse = responses.find(response => response.type === ResponseType.TITLE); assert.exists(titleResponse); assert.strictEqual(titleResponse.title, `Investigating network request ${requestUrl}…`); const action = responses.find(response => response.type === ResponseType.ACTION); const request = parsedTrace.NetworkRequests.byTime.find(r => r.args.data.url === requestUrl); assert.isOk(request); const expectedRequestOutput = TraceEventFormatter.networkRequest(request, parsedTrace, {verbose: true}); const expectedOutput = JSON.stringify({request: expectedRequestOutput}); assert.exists(action); assert.deepEqual(action, { type: 'action' as ActionResponse['type'], output: expectedOutput, code: `getNetworkRequestDetail('${requestUrl}')`, canceled: false }); }); it('calls getMainThreadActivity', async function() { const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient( [[{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}], [{explanation: 'done'}]]) }); const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const context = new InsightContext(activeInsight); const responses = await Array.fromAsync(agent.run('test', {selected: context})); const titleResponse = responses.find(response => response.type === ResponseType.TITLE); assert.exists(titleResponse); assert.strictEqual(titleResponse.title, 'Investigating main thread activity…'); const action = responses.find(response => response.type === ResponseType.ACTION); assert.exists(action); const expectedTree = TimelineUtils.InsightAIContext.AIQueries.mainThreadActivity(lcpPhases, parsedTrace); assert.isOk(expectedTree); const expectedOutput = JSON.stringify({activity: expectedTree.serialize()}); assert.deepEqual(action, { type: 'action' as ActionResponse['type'], output: expectedOutput, code: 'getMainThreadActivity()', canceled: false }); }); it('caches getNetworkActivitySummary calls and passes them to future requests as facts', async function() { const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient([ [{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}] ]) }); const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const context = new InsightContext(activeInsight); // Make the first query to trigger the getNetworkActivitySummary function const responses = await Array.fromAsync(agent.run('test', {selected: context})); const action = responses.find(response => response.type === ResponseType.ACTION); assert.exists(action); assert.strictEqual(action.code, 'getNetworkActivitySummary()'); // Trigger another request so that the agent populates the facts. await Array.fromAsync(agent.run('test 2', {selected: context})); assert.strictEqual(agent.currentFacts().size, 1); const networkSummaryFact = Array.from(agent.currentFacts()).at(0); assert.exists(networkSummaryFact); const expectedRequestUrls = [ 'https://chromedevtools.github.io/performance-stories/lcp-large-image/index.html', 'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@1,800', 'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css', 'https://via.placeholder.com/50.jpg', 'https://via.placeholder.com/2000.jpg' ]; // Ensure that each URL was in the fact as a way to validate the fact is accurate. assert.isTrue(expectedRequestUrls.every(url => { return networkSummaryFact.text.includes(url); })); // Now we make one more request; we do this to ensure that we don't add the same fact again. await Array.fromAsync(agent.run('test 3', {selected: context})); assert.strictEqual(agent.currentFacts().size, 1); }); it('caches getMainThreadActivity calls and passes them to future requests as facts', async function() { const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient( [[{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}], [{explanation: 'done'}]]) }); const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const context = new InsightContext(activeInsight); // Make the first query to trigger the getMainThreadActivity function const responses = await Array.fromAsync(agent.run('test', {selected: context})); const action = responses.find(response => response.type === ResponseType.ACTION); assert.exists(action); assert.strictEqual(action.code, 'getMainThreadActivity()'); // Trigger another request so that the agent populates the facts. await Array.fromAsync(agent.run('test 2', {selected: context})); assert.strictEqual(agent.currentFacts().size, 1); const mainThreadActivityFact = Array.from(agent.currentFacts()).at(0); assert.exists(mainThreadActivityFact); const expectedTree = TimelineUtils.InsightAIContext.AIQueries.mainThreadActivity(lcpPhases, parsedTrace); assert.isOk(expectedTree); assert.include(mainThreadActivityFact.text, expectedTree.serialize()); // Now we make one more request; we do this to ensure that we don't add the same fact again. await Array.fromAsync(agent.run('test 3', {selected: context})); assert.strictEqual(agent.currentFacts().size, 1); }); it('will not send facts from a previous insight if the context changes', async function() { const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const renderBlocking = getInsightOrError('RenderBlocking', insights, firstNav); const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient([ [{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}], ]) }); const lcpPhasesActiveInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const lcpContext = new InsightContext(lcpPhasesActiveInsight); const renderBlockingActiveInsight = new TimelineUtils.InsightAIContext.ActiveInsight(renderBlocking, parsedTrace); const renderBlockingContext = new InsightContext(renderBlockingActiveInsight); // Populate the function calls for the LCP Context await Array.fromAsync(agent.run('test 1 LCP', {selected: lcpContext})); await Array.fromAsync(agent.run('test 2 LCP', {selected: lcpContext})); assert.strictEqual(agent.currentFacts().size, 1); // Now change the context and send a request. await Array.fromAsync(agent.run('test 1 RenderBlocking', {selected: renderBlockingContext})); // Because the context changed, we should now not have any facts. assert.strictEqual(agent.currentFacts().size, 0); }); it('will send multiple facts', async function() { const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz'); assert.isOk(insights); const [firstNav] = parsedTrace.Meta.mainFrameNavigations; const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav); const agent = new PerformanceInsightsAgent({ aidaClient: mockAidaClient([ [{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}], [{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}] ]) }); const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace); const context = new InsightContext(activeInsight); // First query to populate the function calls await Array.fromAsync(agent.run('test 1', {selected: context})); // Second query should have two facts await Array.fromAsync(agent.run('test 2', {selected: context})); assert.deepEqual(Array.from(agent.currentFacts(), fact => { return fact.metadata.source; }), ['getMainThreadActivity()', 'getNetworkActivitySummary()']); }); }); });