blob: 14c5d6076c886bed492f05677e3359663ebc28e0 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Host from '../../core/host/host.js';
import {
describeWithEnvironment,
updateHostConfig,
} from '../../testing/EnvironmentHelpers.js';
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
import * as AiCodeCompletion from './ai_code_completion.js';
const DEFAULT_CURSOR_POSITION = 0;
function makeState(doc: string, extensions: CodeMirror.Extension = []) {
return CodeMirror.EditorState.create({
doc,
extensions: [
extensions,
TextEditor.Config.baseConfiguration(doc),
TextEditor.Config.autocompletion.instance(),
],
selection: CodeMirror.EditorSelection.cursor(DEFAULT_CURSOR_POSITION),
});
}
function createCallbacks(editor: TextEditor.TextEditor.TextEditor): AiCodeCompletion.AiCodeCompletion.Callbacks {
return {
getSelectionHead: () => editor.editor.state.selection.main.head,
getCompletionHint: () => editor.editor.plugin(TextEditor.Config.showCompletionHint)?.currentHint,
setAiAutoCompletion: (args: {
text: string,
from: number,
startTime: number,
onImpression: (rpcGlobalId: Host.AidaClient.RpcGlobalId, latency: number, sampleId?: number) => void,
clearCachedRequest: () => void,
rpcGlobalId?: Host.AidaClient.RpcGlobalId,
sampleId?: number,
}|null) => editor.dispatch({effects: TextEditor.Config.setAiAutoCompleteSuggestion.of(args)}),
};
}
describeWithEnvironment('AiCodeCompletion', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers();
updateHostConfig({
devToolsAiCodeCompletion: {
enabled: true,
temperature: 0.5,
modelId: 'test-model',
userTier: 'BETA',
},
});
});
afterEach(() => {
clock.restore();
});
it('builds a request and calls the AIDA client on text changed', async () => {
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve(null),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(sinon.createStubInstance(TextEditor.TextEditor.TextEditor)),
['\n'],
);
aiCodeCompletion.onTextChanged('prefix', 'suffix', 6);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
const request = mockAidaClient.completeCode.firstCall.args[0];
assert.strictEqual(request.client, 'CHROME_DEVTOOLS');
assert.strictEqual(request.prefix, '\nprefix');
assert.strictEqual(request.suffix, 'suffix');
assert.deepEqual(request.options, {
temperature: 0.5,
model_id: 'test-model',
inference_language: Host.AidaClient.AidaInferenceLanguage.JAVASCRIPT,
stop_sequences: ['\n'],
});
});
it('dispatches a suggestion to the editor when AIDA returns one', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const dispatchSpy = sinon.spy(editor, 'dispatch');
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {rpcGlobalId: 1},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
aiCodeCompletion.onTextChanged('prefix', '\n', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
const suggestion = editor.editor.state.field(TextEditor.Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'suggestion');
assert.strictEqual(suggestion?.from, DEFAULT_CURSOR_POSITION);
assert.strictEqual(suggestion?.sampleId, 1);
assert.strictEqual(suggestion?.rpcGlobalId, 1);
sinon.assert.calledOnce(dispatchSpy);
});
it('trims a suggestion with suffix overlap and dispatches it to the editor', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const dispatchSpy = sinon.spy(editor, 'dispatch');
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'Hello World");',
sampleId: 1,
score: 1,
}],
metadata: {rpcGlobalId: 1},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
aiCodeCompletion.onTextChanged('console.log("', '");\n', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
const suggestion = editor.editor.state.field(TextEditor.Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'Hello World');
assert.strictEqual(suggestion?.from, DEFAULT_CURSOR_POSITION);
assert.strictEqual(suggestion?.sampleId, 1);
assert.strictEqual(suggestion?.rpcGlobalId, 1);
sinon.assert.calledOnce(dispatchSpy);
});
it('debounces requests to AIDA', async () => {
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve(null),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(sinon.createStubInstance(TextEditor.TextEditor.TextEditor)),
);
aiCodeCompletion.onTextChanged('p', '', 1);
aiCodeCompletion.onTextChanged('pr', '', 2);
aiCodeCompletion.onTextChanged('pre', '', 3);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
assert.strictEqual(mockAidaClient.completeCode.firstCall.args[0].prefix, '\npre');
});
it('does not dispatch suggestion or citation if recitation action is BLOCK', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const dispatchSpy = sinon.spy(editor, 'dispatch');
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
attributionMetadata: {
attributionAction: Host.AidaClient.RecitationAction.BLOCK,
citations: [{uri: 'https://www.example.com'}],
}
}],
metadata: {},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
const dispatchEventSpy = sinon.spy(aiCodeCompletion, 'dispatchEventToListeners');
aiCodeCompletion.onTextChanged('prefix', '\n', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.notCalled(dispatchSpy);
sinon.assert.calledWith(
dispatchEventSpy, sinon.match(AiCodeCompletion.AiCodeCompletion.Events.RESPONSE_RECEIVED), sinon.match({}));
});
it('does not dispatch suggestion or citation if generated suggestion repeats existing text', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const dispatchSpy = sinon.spy(editor, 'dispatch');
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
const dispatchEventSpy = sinon.spy(aiCodeCompletion, 'dispatchEventToListeners');
aiCodeCompletion.onTextChanged('prefix suggestion', '\n', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.notCalled(dispatchSpy);
sinon.assert.calledWith(
dispatchEventSpy, sinon.match(AiCodeCompletion.AiCodeCompletion.Events.RESPONSE_RECEIVED), sinon.match({}));
});
it('does not dispatch if cursor position changes', async () => {
const editor =
new TextEditor.TextEditor.TextEditor(makeState('prefix', TextEditor.Config.aiAutoCompleteSuggestion));
const dispatchSpy = sinon.spy(editor, 'dispatch');
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
const dispatchEventSpy = sinon.spy(aiCodeCompletion, 'dispatchEventToListeners');
aiCodeCompletion.onTextChanged('prefix', '\n', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
editor.editor.dispatch({
selection: CodeMirror.EditorSelection.cursor(1),
});
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.notCalled(dispatchSpy);
sinon.assert.calledWith(
dispatchEventSpy, sinon.match(AiCodeCompletion.AiCodeCompletion.Events.RESPONSE_RECEIVED), sinon.match({}));
});
it('dispatches response received event with citations', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const citations = [{uri: 'https://example.com'}];
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
attributionMetadata: {
attributionAction: Host.AidaClient.RecitationAction.CITE,
citations,
},
}],
metadata: {},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
const dispatchSpy = sinon.spy(aiCodeCompletion, 'dispatchEventToListeners');
aiCodeCompletion.onTextChanged('prefix', '\n', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
assert.deepEqual(dispatchSpy.secondCall.args[0], AiCodeCompletion.AiCodeCompletion.Events.RESPONSE_RECEIVED);
assert.deepEqual(dispatchSpy.secondCall.args[1], {citations});
});
it('caches suggestions from AIDA', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const dispatchSpy = sinon.spy(editor, 'dispatch');
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {
rpcGlobalId: 1,
},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
aiCodeCompletion.onTextChanged('prefix', 'suffix', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
let suggestion = editor.editor.state.field(TextEditor.Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'suggestion');
assert.strictEqual(suggestion?.from, DEFAULT_CURSOR_POSITION);
assert.strictEqual(suggestion?.sampleId, 1);
assert.strictEqual(suggestion?.rpcGlobalId, 1);
aiCodeCompletion.onTextChanged('prefix', 'suffix', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
suggestion = editor.editor.state.field(TextEditor.Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'suggestion');
assert.strictEqual(suggestion?.from, DEFAULT_CURSOR_POSITION);
assert.strictEqual(suggestion?.sampleId, 1);
assert.strictEqual(suggestion?.rpcGlobalId, 1);
sinon.assert.calledTwice(dispatchSpy);
});
it('caches suggestions from AIDA and returns only valid generated samples from cache', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const dispatchSpy = sinon.spy(editor, 'dispatch');
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [
{
generationString: 'suggestion',
sampleId: 1,
score: 1,
},
{
generationString: 'recommendation',
sampleId: 2,
score: 0.5,
}
],
metadata: {},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
aiCodeCompletion.onTextChanged('prefix ', 'suffix', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
let suggestion = editor.editor.state.field(TextEditor.Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'suggestion');
assert.strictEqual(suggestion?.from, DEFAULT_CURSOR_POSITION);
assert.strictEqual(suggestion?.sampleId, 1);
aiCodeCompletion.onTextChanged('prefix re', 'suffix', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.calledOnce(mockAidaClient.completeCode);
suggestion = editor.editor.state.field(TextEditor.Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'commendation');
assert.strictEqual(suggestion?.from, DEFAULT_CURSOR_POSITION);
assert.strictEqual(suggestion?.sampleId, 2);
sinon.assert.calledTwice(dispatchSpy);
});
it('does not use cache for different requests', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
aiCodeCompletion.onTextChanged('prefix', 'suffix', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
aiCodeCompletion.onTextChanged('prefix re', 'suffix', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledTwice(mockAidaClient.completeCode);
});
it('does not use cache for different suffix', async () => {
const editor = new TextEditor.TextEditor.TextEditor(makeState('', TextEditor.Config.aiAutoCompleteSuggestion));
const mockAidaClient = sinon.createStubInstance(Host.AidaClient.AidaClient, {
completeCode: Promise.resolve({
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {},
}),
});
const aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: mockAidaClient},
AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
createCallbacks(editor),
);
aiCodeCompletion.onTextChanged('prefix', 'suffix', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
aiCodeCompletion.onTextChanged('prefix', 'suffixes', DEFAULT_CURSOR_POSITION);
await clock.tickAsync(AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledTwice(mockAidaClient.completeCode);
});
});