Skip to content

Commit 881a52b

Browse files
ktrandevtools-frontend-scoped@luci-project-accounts.iam.gserviceaccount.com
authored andcommitted
Sanitize suggestion chips for Ai Assistance
This truncates and adds basic sanitization of the suggestion chips. We check if it is indeed an array of strings, and remove all unnecessary extra whitespace. We set the max length of a chip to be 200 characters, as chips are suggestions that shouldn't be too long anyway. And we now also respect the max length of the input text area to avoid users not seeing what they insert via clicking on a chip. Bug: 513747800 Change-Id: Ib0edcf30981d8446d0a691853308160740d0436c Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7864200 Reviewed-by: Alina Varkki <alinavarkki@chromium.org> Commit-Queue: Kim-Anh Tran <kimanh@chromium.org>
1 parent b504cd5 commit 881a52b

4 files changed

Lines changed: 126 additions & 6 deletions

File tree

front_end/models/ai_assistance/agents/AiAgent.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,4 +551,88 @@ describeWithEnvironment('AiAgent', () => {
551551
});
552552
});
553553
});
554+
555+
describe('parseTextResponseForSuggestions', () => {
556+
it('should parse valid suggestions', () => {
557+
const agent = new AiAgentMock({
558+
aidaClient: mockAidaClient(),
559+
});
560+
const parsed = agent.parseTextResponseForSuggestions('SUGGESTIONS: ["how to fix", "why it fails"]');
561+
assert.deepEqual(parsed.suggestions, ['how to fix', 'why it fails']);
562+
});
563+
564+
it('should filter out non-string suggestions', () => {
565+
const agent = new AiAgentMock({
566+
aidaClient: mockAidaClient(),
567+
});
568+
const parsed = agent.parseTextResponseForSuggestions('SUGGESTIONS: ["valid", 123, null, {"key": "val"}]');
569+
assert.deepEqual(parsed.suggestions, ['valid']);
570+
});
571+
572+
it('should truncate long suggestions', () => {
573+
const agent = new AiAgentMock({
574+
aidaClient: mockAidaClient(),
575+
});
576+
const longSuggestion = 'a'.repeat(300);
577+
const parsed = agent.parseTextResponseForSuggestions(`SUGGESTIONS: ["${longSuggestion}"]`);
578+
assert.isDefined(parsed.suggestions);
579+
assert.lengthOf(parsed.suggestions![0], 200);
580+
assert.strictEqual(parsed.suggestions![0], 'a'.repeat(200));
581+
});
582+
583+
it('should sanitize whitespace and newlines in suggestions', () => {
584+
const agent = new AiAgentMock({
585+
aidaClient: mockAidaClient(),
586+
});
587+
const parsed = agent.parseTextResponseForSuggestions(
588+
'SUGGESTIONS: ["line1\\nline2", "word1\\r\\nword2", "excessive spaces"]');
589+
assert.deepEqual(parsed.suggestions, ['line1 line2', 'word1 word2', 'excessive spaces']);
590+
});
591+
592+
it('should reject non-array suggestions', () => {
593+
const agent = new AiAgentMock({
594+
aidaClient: mockAidaClient(),
595+
});
596+
const parsed = agent.parseTextResponseForSuggestions('SUGGESTIONS: "not an array"');
597+
assert.isUndefined(parsed.suggestions);
598+
});
599+
600+
it('should remove empty suggestions after sanitization', () => {
601+
const agent = new AiAgentMock({
602+
aidaClient: mockAidaClient(),
603+
});
604+
const parsed = agent.parseTextResponseForSuggestions('SUGGESTIONS: ["", " ", "\\n\\n"]');
605+
assert.isUndefined(parsed.suggestions);
606+
});
607+
608+
it('should parse suggestions from a multi-line response containing both answer and suggestions', () => {
609+
const agent = new AiAgentMock({
610+
aidaClient: mockAidaClient(),
611+
});
612+
const responseText = [
613+
'Here is the first line of the answer.',
614+
'SUGGESTIONS: ["suggestion 1", "suggestion 2"]',
615+
'Here is the second line of the answer.',
616+
].join('\n');
617+
const parsed = agent.parseTextResponseForSuggestions(responseText);
618+
assert.strictEqual(
619+
parsed.answer, 'Here is the first line of the answer.\nHere is the second line of the answer.');
620+
assert.deepEqual(parsed.suggestions, ['suggestion 1', 'suggestion 2']);
621+
});
622+
623+
it('should handle multiple SUGGESTIONS lines by keeping the last valid one', () => {
624+
const agent = new AiAgentMock({
625+
aidaClient: mockAidaClient(),
626+
});
627+
const responseText = [
628+
'Answer text.',
629+
'SUGGESTIONS: ["first suggestion"]',
630+
'More answer text.',
631+
'SUGGESTIONS: ["second suggestion"]',
632+
].join('\n');
633+
const parsed = agent.parseTextResponseForSuggestions(responseText);
634+
assert.strictEqual(parsed.answer, 'Answer text.\nMore answer text.');
635+
assert.deepEqual(parsed.suggestions, ['second suggestion']);
636+
});
637+
});
554638
});

front_end/models/ai_assistance/agents/AiAgent.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type * as Trace from '../../trace/trace.js';
1313
import type * as Workspace from '../../workspace/workspace.js';
1414
import {debugLog, isStructuredLogEnabled} from '../debug.js';
1515

16+
const MAX_SUGGESTION_LENGTH = 200;
17+
1618
export const enum ResponseType {
1719
CONTEXT = 'context',
1820
TITLE = 'title',
@@ -575,8 +577,7 @@ export abstract class AiAgent<T> {
575577
const trimmed = line.trim();
576578
if (trimmed.startsWith('SUGGESTIONS:')) {
577579
try {
578-
// TODO: Do basic validation this is an array with strings
579-
suggestions = JSON.parse(trimmed.substring('SUGGESTIONS:'.length).trim());
580+
suggestions = sanitizeSuggestions(trimmed.substring('SUGGESTIONS:'.length).trim());
580581
} catch {
581582
}
582583
} else {
@@ -589,8 +590,7 @@ export abstract class AiAgent<T> {
589590
if (!suggestions && answerLines.at(-1)?.includes('SUGGESTIONS:')) {
590591
const [answer, suggestionsText] = answerLines[answerLines.length - 1].split('SUGGESTIONS:', 2);
591592
try {
592-
// TODO: Do basic validation this is an array with strings
593-
suggestions = JSON.parse(suggestionsText.trim().substring('SUGGESTIONS:'.length).trim());
593+
suggestions = sanitizeSuggestions(suggestionsText.trim());
594594
} catch {
595595
}
596596
answerLines[answerLines.length - 1] = answer;
@@ -997,3 +997,26 @@ export abstract class AiAgent<T> {
997997
};
998998
}
999999
}
1000+
1001+
function sanitizeSuggestions(suggestions: string): [string, ...string[]]|undefined {
1002+
const parsed = JSON.parse(suggestions);
1003+
if (!Array.isArray(parsed)) {
1004+
return undefined;
1005+
}
1006+
const sanitized: string[] = [];
1007+
for (const item of parsed) {
1008+
if (typeof item !== 'string') {
1009+
continue;
1010+
}
1011+
// Collapse multiple whitespace/newlines into a single space.
1012+
const noExtraWhitespace = item.replace(/\s+/g, ' ').trim();
1013+
if (noExtraWhitespace.length === 0) {
1014+
continue;
1015+
}
1016+
sanitized.push(noExtraWhitespace.substring(0, MAX_SUGGESTION_LENGTH));
1017+
}
1018+
if (sanitized.length === 0) {
1019+
return undefined;
1020+
}
1021+
return sanitized as [string, ...string[]];
1022+
}

front_end/panels/ai_assistance/components/ChatInput.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ describeWithEnvironment('ChatInput', () => {
6969
assert.isTrue(view.input.isReadOnly);
7070
});
7171

72+
it('should truncate input value to maxlength in setInputValue', async () => {
73+
const [view, component] = createComponent();
74+
const mockTextArea = document.createElement('textarea');
75+
mockTextArea.maxLength = 10;
76+
assert.isDefined(view.input.textAreaRef);
77+
(view.input.textAreaRef as {value: HTMLTextAreaElement}).value = mockTextArea;
78+
79+
component.setInputValue('a'.repeat(20));
80+
assert.strictEqual(mockTextArea.value, 'a'.repeat(10));
81+
});
82+
7283
describe('multimodal input', () => {
7384
let target: SDK.Target.Target;
7485
let model: SDK.ScreenCaptureModel.ScreenCaptureModel;

front_end/panels/ai_assistance/components/ChatInput.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,9 +530,11 @@ export class ChatInput extends UI.Widget.Widget implements SDK.TargetManager.Obs
530530

531531
setInputValue(text: string): void {
532532
if (this.#textAreaRef.value) {
533-
this.#textAreaRef.value.value = text;
533+
const maxLength = this.#textAreaRef.value.maxLength;
534+
const truncatedText = (maxLength >= 0) ? text.substring(0, maxLength) : text;
535+
this.#textAreaRef.value.value = truncatedText;
534536
// Place the cursor at the end of the new value.
535-
this.#textAreaRef.value.setSelectionRange(text.length, text.length);
537+
this.#textAreaRef.value.setSelectionRange(truncatedText.length, truncatedText.length);
536538
}
537539
this.performUpdate();
538540
}

0 commit comments

Comments
 (0)