-
Notifications
You must be signed in to change notification settings - Fork 13k
feat(voice): implement speech-friendly response formatter #20989
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
spencer426
merged 18 commits into
google-gemini:main
from
ayush31010:fix/voice-response-formatter
Mar 10, 2026
Merged
Changes from 7 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
43eeb8c
feat(voice): implement speech-friendly response formatter
ayush31010 1ae0f72
fix(voice): fix Windows path regex to support all drive letters
ayush31010 273afbe
Merge branch 'main' into fix/voice-response-formatter
ayush31010 c8168e1
Merge branch 'main' into fix/voice-response-formatter
ayush31010 68a0be5
Merge branch 'main' into fix/voice-response-formatter
ayush31010 d00324f
Merge branch 'main' into fix/voice-response-formatter
ayush31010 322d3b2
fix(voice): add @ to UNIX_PATH_RE to support scoped npm package paths
ayush31010 0d4bcf9
Merge branch 'main' into fix/voice-response-formatter
ayush31010 bf25f57
Merge branch 'main' into fix/voice-response-formatter
ayush31010 84eb97b
fix(voice): address PR review comments on responseFormatter
ayush31010 225cc1f
Merge branch 'main' into fix/voice-response-formatter
ayush31010 ecd623d
Merge branch 'main' into fix/voice-response-formatter
ayush31010 1933bad
Merge branch 'main' into fix/voice-response-formatter
ayush31010 2d8c2f5
fix(voice): prevent polynomial regex ReDoS on ANSI and path matching
ayush31010 81c9457
Merge branch 'main' into fix/voice-response-formatter
ayush31010 a75fa35
Merge branch 'main' into fix/voice-response-formatter
ayush31010 58cafac
Merge branch 'main' into fix/voice-response-formatter
ayush31010 b78f5c1
Merge branch 'main' into fix/voice-response-formatter
spencer426 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,288 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, it, expect } from 'vitest'; | ||
| import { formatForSpeech } from './responseFormatter.js'; | ||
|
|
||
| describe('formatForSpeech', () => { | ||
ayush31010 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| describe('edge cases', () => { | ||
| it('should return empty string for empty input', () => { | ||
| expect(formatForSpeech('')).toBe(''); | ||
| }); | ||
|
|
||
| it('should return plain text unchanged', () => { | ||
| expect(formatForSpeech('Hello world')).toBe('Hello world'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('ANSI escape codes', () => { | ||
| it('should strip color codes', () => { | ||
| expect(formatForSpeech('\x1b[31mError\x1b[0m')).toBe('Error'); | ||
| }); | ||
|
|
||
| it('should strip bold/dim codes', () => { | ||
| expect(formatForSpeech('\x1b[1mBold\x1b[22m text')).toBe('Bold text'); | ||
| }); | ||
|
|
||
| it('should strip cursor movement codes', () => { | ||
| expect(formatForSpeech('line1\x1b[2Kline2')).toBe('line1line2'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('markdown stripping', () => { | ||
| it('should strip bold markers **text**', () => { | ||
| expect(formatForSpeech('**Error**: something went wrong')).toBe( | ||
| 'Error: something went wrong', | ||
| ); | ||
| }); | ||
|
|
||
| it('should strip bold markers __text__', () => { | ||
| expect(formatForSpeech('__Error__: something')).toBe('Error: something'); | ||
| }); | ||
|
|
||
| it('should strip italic markers *text*', () => { | ||
| expect(formatForSpeech('*note*: pay attention')).toBe( | ||
| 'note: pay attention', | ||
| ); | ||
| }); | ||
|
|
||
| it('should strip inline code backticks', () => { | ||
| expect(formatForSpeech('Run `npm install` first')).toBe( | ||
| 'Run npm install first', | ||
| ); | ||
| }); | ||
|
|
||
| it('should strip blockquote prefix', () => { | ||
| expect(formatForSpeech('> This is a quote')).toBe('This is a quote'); | ||
| }); | ||
|
|
||
| it('should strip heading markers', () => { | ||
| expect(formatForSpeech('# Results\n## Details')).toBe('Results\nDetails'); | ||
| }); | ||
|
|
||
| it('should replace markdown links with link text', () => { | ||
| expect(formatForSpeech('[Gemini API](https://ai.google.dev)')).toBe( | ||
| 'Gemini API', | ||
| ); | ||
| }); | ||
|
|
||
| it('should strip unordered list markers', () => { | ||
| expect(formatForSpeech('- item one\n- item two')).toBe( | ||
| 'item one\nitem two', | ||
| ); | ||
| }); | ||
|
|
||
| it('should strip ordered list markers', () => { | ||
| expect(formatForSpeech('1. first\n2. second')).toBe('first\nsecond'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('fenced code blocks', () => { | ||
| it('should unwrap a plain code block', () => { | ||
| expect(formatForSpeech('```\nconsole.log("hi")\n```')).toBe( | ||
| 'console.log("hi")', | ||
| ); | ||
| }); | ||
|
|
||
| it('should unwrap a language-tagged code block', () => { | ||
| expect(formatForSpeech('```typescript\nconst x = 1;\n```')).toBe( | ||
| 'const x = 1;', | ||
| ); | ||
| }); | ||
|
|
||
| it('should summarise a JSON object code block above threshold', () => { | ||
| const json = JSON.stringify({ status: 'ok', count: 42, items: [] }); | ||
| // Pass jsonThreshold lower than the json string length (38 chars) | ||
| const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``, { | ||
| jsonThreshold: 10, | ||
| }); | ||
| expect(result).toBe('(JSON object with 3 keys)'); | ||
| }); | ||
|
|
||
| it('should summarise a JSON array code block above threshold', () => { | ||
| const json = JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); | ||
| // Pass jsonThreshold lower than the json string length (23 chars) | ||
| const result = formatForSpeech(`\`\`\`\n${json}\n\`\`\``, { | ||
| jsonThreshold: 10, | ||
| }); | ||
| expect(result).toBe('(JSON array with 10 items)'); | ||
| }); | ||
|
|
||
| it('should summarise a large JSON object using default threshold', () => { | ||
| // Build a JSON object whose stringified form exceeds the default 80-char threshold | ||
| const big = { | ||
| status: 'success', | ||
| count: 42, | ||
| items: ['alpha', 'beta', 'gamma'], | ||
| meta: { page: 1, totalPages: 10 }, | ||
| timestamp: '2026-03-03T00:00:00Z', | ||
| }; | ||
| const json = JSON.stringify(big); | ||
| expect(json.length).toBeGreaterThan(80); | ||
| const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``); | ||
| expect(result).toBe('(JSON object with 5 keys)'); | ||
| }); | ||
|
|
||
| it('should not summarise a tiny JSON value', () => { | ||
| // Below the default 80-char threshold → keep as-is | ||
| const result = formatForSpeech('```json\n{"a":1}\n```', { | ||
| jsonThreshold: 80, | ||
| }); | ||
| expect(result).toBe('{"a":1}'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('path abbreviation', () => { | ||
| it('should abbreviate a deep Unix path (default depth 3)', () => { | ||
| const result = formatForSpeech( | ||
| 'at /home/user/project/packages/core/src/tools/file.ts', | ||
| ); | ||
| expect(result).toContain('\u2026/src/tools/file.ts'); | ||
| expect(result).not.toContain('/home/user/project'); | ||
| }); | ||
|
|
||
| it('should convert :line suffix to "line N"', () => { | ||
| const result = formatForSpeech( | ||
| 'Error at /home/user/project/src/tools/file.ts:142', | ||
| ); | ||
| expect(result).toContain('line 142'); | ||
| }); | ||
|
|
||
| it('should drop column from :line:col suffix', () => { | ||
| const result = formatForSpeech( | ||
| 'Error at /home/user/project/src/tools/file.ts:142:7', | ||
| ); | ||
| expect(result).toContain('line 142'); | ||
| expect(result).not.toContain(':7'); | ||
| }); | ||
|
|
||
| it('should respect custom pathDepth option', () => { | ||
| const result = formatForSpeech( | ||
| '/home/user/project/packages/core/src/file.ts', | ||
| { pathDepth: 2 }, | ||
| ); | ||
| expect(result).toContain('\u2026/src/file.ts'); | ||
| }); | ||
|
|
||
| it('should not abbreviate a short path within depth', () => { | ||
| const result = formatForSpeech('/src/file.ts', { pathDepth: 3 }); | ||
| // Only 2 segments — no abbreviation needed | ||
| expect(result).toBe('/src/file.ts'); | ||
| }); | ||
|
|
||
| it('should abbreviate a Windows path on a non-C drive', () => { | ||
| const result = formatForSpeech( | ||
| 'D:\\Users\\project\\packages\\core\\src\\file.ts', | ||
| { pathDepth: 3 }, | ||
| ); | ||
| expect(result).toContain('\u2026/core/src/file.ts'); | ||
| expect(result).not.toContain('D:\\Users\\project'); | ||
| }); | ||
|
|
||
| it('should convert :line on a Windows path on a non-C drive', () => { | ||
| const result = formatForSpeech( | ||
| 'Error at D:\\Users\\project\\src\\tools\\file.ts:55', | ||
| ); | ||
| expect(result).toContain('line 55'); | ||
| expect(result).not.toContain('D:\\Users\\project'); | ||
| }); | ||
|
|
||
| it('should abbreviate a Unix path containing a scoped npm package segment', () => { | ||
| const result = formatForSpeech( | ||
| 'at /home/user/project/node_modules/@google/gemini-cli-core/src/index.ts:12:3', | ||
| { pathDepth: 5 }, | ||
| ); | ||
| expect(result).toContain('line 12'); | ||
| expect(result).not.toContain(':3'); | ||
| expect(result).toContain('@google'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('stack trace collapsing', () => { | ||
| it('should collapse a multi-frame stack trace', () => { | ||
| const trace = [ | ||
| 'Error: ENOENT', | ||
| ' at Object.open (/project/src/file.ts:10:5)', | ||
| ' at Module._load (/project/node_modules/loader.js:20:3)', | ||
| ' at Function.Module._load (/project/node_modules/loader.js:30:3)', | ||
| ].join('\n'); | ||
|
|
||
| const result = formatForSpeech(trace); | ||
| expect(result).toContain('and 2 more frames'); | ||
| expect(result).not.toContain('Module._load'); | ||
| }); | ||
|
|
||
| it('should not collapse a single stack frame', () => { | ||
| const trace = | ||
| 'Error: ENOENT\n at Object.open (/project/src/file.ts:10:5)'; | ||
| const result = formatForSpeech(trace); | ||
| expect(result).not.toContain('more frames'); | ||
| }); | ||
|
|
||
| it('should preserve surrounding text when collapsing a stack trace', () => { | ||
| const input = [ | ||
| 'Operation failed.', | ||
| ' at Object.open (/project/src/file.ts:10:5)', | ||
| ' at Module._load (/project/node_modules/loader.js:20:3)', | ||
| ' at Function.load (/project/node_modules/loader.js:30:3)', | ||
| 'Please try again.', | ||
| ].join('\n'); | ||
|
|
||
| const result = formatForSpeech(input); | ||
| expect(result).toContain('Operation failed.'); | ||
| expect(result).toContain('Please try again.'); | ||
| expect(result).toContain('and 2 more frames'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('truncation', () => { | ||
| it('should truncate output longer than maxLength', () => { | ||
| const long = 'word '.repeat(200); | ||
| const result = formatForSpeech(long, { maxLength: 50 }); | ||
| expect(result.length).toBeLessThanOrEqual( | ||
| 50 + '\u2026 (1000 chars total)'.length, | ||
| ); | ||
| expect(result).toContain('\u2026'); | ||
| expect(result).toContain('chars total'); | ||
| }); | ||
|
|
||
| it('should not truncate output within maxLength', () => { | ||
| const short = 'Hello world'; | ||
| expect(formatForSpeech(short, { maxLength: 500 })).toBe('Hello world'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('whitespace normalisation', () => { | ||
| it('should collapse more than two consecutive blank lines', () => { | ||
| const result = formatForSpeech('para1\n\n\n\n\npara2'); | ||
| expect(result).toBe('para1\n\npara2'); | ||
| }); | ||
|
|
||
| it('should trim leading and trailing whitespace', () => { | ||
| expect(formatForSpeech(' hello ')).toBe('hello'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('real-world examples', () => { | ||
| it('should clean an ENOENT error with markdown and path', () => { | ||
| const input = | ||
| '**Error**: `ENOENT: no such file or directory`\n> at /home/user/project/packages/core/src/tools/file-utils.ts:142:7'; | ||
| const result = formatForSpeech(input); | ||
| expect(result).not.toContain('**'); | ||
| expect(result).not.toContain('`'); | ||
| expect(result).not.toContain('>'); | ||
| expect(result).toContain('Error'); | ||
| expect(result).toContain('ENOENT'); | ||
| expect(result).toContain('line 142'); | ||
| }); | ||
|
|
||
| it('should clean a heading + list response', () => { | ||
| const input = '# Results\n- item one\n- item two\n- item three'; | ||
| const result = formatForSpeech(input); | ||
| expect(result).toBe('Results\nitem one\nitem two\nitem three'); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.