Skip to content

Commit 9eae91a

Browse files
Ayush Debnathspencer426
andauthored
feat(voice): implement speech-friendly response formatter (#20989)
Co-authored-by: Spencer <spencertang@google.com>
1 parent 1b69637 commit 9eae91a

File tree

3 files changed

+476
-0
lines changed

3 files changed

+476
-0
lines changed

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,5 +219,8 @@ export * from './agents/types.js';
219219
export * from './utils/stdio.js';
220220
export * from './utils/terminal.js';
221221

222+
// Export voice utilities
223+
export * from './voice/responseFormatter.js';
224+
222225
// Export types from @google/genai
223226
export type { Content, Part, FunctionCall } from '@google/genai';
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { formatForSpeech } from './responseFormatter.js';
9+
10+
describe('formatForSpeech', () => {
11+
describe('edge cases', () => {
12+
it('should return empty string for empty input', () => {
13+
expect(formatForSpeech('')).toBe('');
14+
});
15+
16+
it('should return plain text unchanged', () => {
17+
expect(formatForSpeech('Hello world')).toBe('Hello world');
18+
});
19+
});
20+
21+
describe('ANSI escape codes', () => {
22+
it('should strip color codes', () => {
23+
expect(formatForSpeech('\x1b[31mError\x1b[0m')).toBe('Error');
24+
});
25+
26+
it('should strip bold/dim codes', () => {
27+
expect(formatForSpeech('\x1b[1mBold\x1b[22m text')).toBe('Bold text');
28+
});
29+
30+
it('should strip cursor movement codes', () => {
31+
expect(formatForSpeech('line1\x1b[2Kline2')).toBe('line1line2');
32+
});
33+
});
34+
35+
describe('markdown stripping', () => {
36+
it('should strip bold markers **text**', () => {
37+
expect(formatForSpeech('**Error**: something went wrong')).toBe(
38+
'Error: something went wrong',
39+
);
40+
});
41+
42+
it('should strip bold markers __text__', () => {
43+
expect(formatForSpeech('__Error__: something')).toBe('Error: something');
44+
});
45+
46+
it('should strip italic markers *text*', () => {
47+
expect(formatForSpeech('*note*: pay attention')).toBe(
48+
'note: pay attention',
49+
);
50+
});
51+
52+
it('should strip inline code backticks', () => {
53+
expect(formatForSpeech('Run `npm install` first')).toBe(
54+
'Run npm install first',
55+
);
56+
});
57+
58+
it('should strip blockquote prefix', () => {
59+
expect(formatForSpeech('> This is a quote')).toBe('This is a quote');
60+
});
61+
62+
it('should strip heading markers', () => {
63+
expect(formatForSpeech('# Results\n## Details')).toBe('Results\nDetails');
64+
});
65+
66+
it('should replace markdown links with link text', () => {
67+
expect(formatForSpeech('[Gemini API](https://ai.google.dev)')).toBe(
68+
'Gemini API',
69+
);
70+
});
71+
72+
it('should strip unordered list markers', () => {
73+
expect(formatForSpeech('- item one\n- item two')).toBe(
74+
'item one\nitem two',
75+
);
76+
});
77+
78+
it('should strip ordered list markers', () => {
79+
expect(formatForSpeech('1. first\n2. second')).toBe('first\nsecond');
80+
});
81+
});
82+
83+
describe('fenced code blocks', () => {
84+
it('should unwrap a plain code block', () => {
85+
expect(formatForSpeech('```\nconsole.log("hi")\n```')).toBe(
86+
'console.log("hi")',
87+
);
88+
});
89+
90+
it('should unwrap a language-tagged code block', () => {
91+
expect(formatForSpeech('```typescript\nconst x = 1;\n```')).toBe(
92+
'const x = 1;',
93+
);
94+
});
95+
96+
it('should summarise a JSON object code block above threshold', () => {
97+
const json = JSON.stringify({ status: 'ok', count: 42, items: [] });
98+
// Pass jsonThreshold lower than the json string length (38 chars)
99+
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``, {
100+
jsonThreshold: 10,
101+
});
102+
expect(result).toBe('(JSON object with 3 keys)');
103+
});
104+
105+
it('should summarise a JSON array code block above threshold', () => {
106+
const json = JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
107+
// Pass jsonThreshold lower than the json string length (23 chars)
108+
const result = formatForSpeech(`\`\`\`\n${json}\n\`\`\``, {
109+
jsonThreshold: 10,
110+
});
111+
expect(result).toBe('(JSON array with 10 items)');
112+
});
113+
114+
it('should summarise a large JSON object using default threshold', () => {
115+
// Build a JSON object whose stringified form exceeds the default 80-char threshold
116+
const big = {
117+
status: 'success',
118+
count: 42,
119+
items: ['alpha', 'beta', 'gamma'],
120+
meta: { page: 1, totalPages: 10 },
121+
timestamp: '2026-03-03T00:00:00Z',
122+
};
123+
const json = JSON.stringify(big);
124+
expect(json.length).toBeGreaterThan(80);
125+
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``);
126+
expect(result).toBe('(JSON object with 5 keys)');
127+
});
128+
129+
it('should not summarise a tiny JSON value', () => {
130+
// Below the default 80-char threshold → keep as-is
131+
const result = formatForSpeech('```json\n{"a":1}\n```', {
132+
jsonThreshold: 80,
133+
});
134+
expect(result).toBe('{"a":1}');
135+
});
136+
});
137+
138+
describe('path abbreviation', () => {
139+
it('should abbreviate a deep Unix path (default depth 3)', () => {
140+
const result = formatForSpeech(
141+
'at /home/user/project/packages/core/src/tools/file.ts',
142+
);
143+
expect(result).toContain('\u2026/src/tools/file.ts');
144+
expect(result).not.toContain('/home/user/project');
145+
});
146+
147+
it('should convert :line suffix to "line N"', () => {
148+
const result = formatForSpeech(
149+
'Error at /home/user/project/src/tools/file.ts:142',
150+
);
151+
expect(result).toContain('line 142');
152+
});
153+
154+
it('should drop column from :line:col suffix', () => {
155+
const result = formatForSpeech(
156+
'Error at /home/user/project/src/tools/file.ts:142:7',
157+
);
158+
expect(result).toContain('line 142');
159+
expect(result).not.toContain(':7');
160+
});
161+
162+
it('should respect custom pathDepth option', () => {
163+
const result = formatForSpeech(
164+
'/home/user/project/packages/core/src/file.ts',
165+
{ pathDepth: 2 },
166+
);
167+
expect(result).toContain('\u2026/src/file.ts');
168+
});
169+
170+
it('should not abbreviate a short path within depth', () => {
171+
const result = formatForSpeech('/src/file.ts', { pathDepth: 3 });
172+
// Only 2 segments — no abbreviation needed
173+
expect(result).toBe('/src/file.ts');
174+
});
175+
176+
it('should abbreviate a Windows path on a non-C drive', () => {
177+
const result = formatForSpeech(
178+
'D:\\Users\\project\\packages\\core\\src\\file.ts',
179+
{ pathDepth: 3 },
180+
);
181+
expect(result).toContain('\u2026/core/src/file.ts');
182+
expect(result).not.toContain('D:\\Users\\project');
183+
});
184+
185+
it('should convert :line on a Windows path on a non-C drive', () => {
186+
const result = formatForSpeech(
187+
'Error at D:\\Users\\project\\src\\tools\\file.ts:55',
188+
);
189+
expect(result).toContain('line 55');
190+
expect(result).not.toContain('D:\\Users\\project');
191+
});
192+
193+
it('should abbreviate a Unix path containing a scoped npm package segment', () => {
194+
const result = formatForSpeech(
195+
'at /home/user/project/node_modules/@google/gemini-cli-core/src/index.ts:12:3',
196+
{ pathDepth: 5 },
197+
);
198+
expect(result).toContain('line 12');
199+
expect(result).not.toContain(':3');
200+
expect(result).toContain('@google');
201+
});
202+
});
203+
204+
describe('stack trace collapsing', () => {
205+
it('should collapse a multi-frame stack trace', () => {
206+
const trace = [
207+
'Error: ENOENT',
208+
' at Object.open (/project/src/file.ts:10:5)',
209+
' at Module._load (/project/node_modules/loader.js:20:3)',
210+
' at Function.Module._load (/project/node_modules/loader.js:30:3)',
211+
].join('\n');
212+
213+
const result = formatForSpeech(trace);
214+
expect(result).toContain('and 2 more frames');
215+
expect(result).not.toContain('Module._load');
216+
});
217+
218+
it('should not collapse a single stack frame', () => {
219+
const trace =
220+
'Error: ENOENT\n at Object.open (/project/src/file.ts:10:5)';
221+
const result = formatForSpeech(trace);
222+
expect(result).not.toContain('more frames');
223+
});
224+
225+
it('should preserve surrounding text when collapsing a stack trace', () => {
226+
const input = [
227+
'Operation failed.',
228+
' at Object.open (/project/src/file.ts:10:5)',
229+
' at Module._load (/project/node_modules/loader.js:20:3)',
230+
' at Function.load (/project/node_modules/loader.js:30:3)',
231+
'Please try again.',
232+
].join('\n');
233+
234+
const result = formatForSpeech(input);
235+
expect(result).toContain('Operation failed.');
236+
expect(result).toContain('Please try again.');
237+
expect(result).toContain('and 2 more frames');
238+
});
239+
});
240+
241+
describe('truncation', () => {
242+
it('should truncate output longer than maxLength', () => {
243+
const long = 'word '.repeat(200);
244+
const result = formatForSpeech(long, { maxLength: 50 });
245+
expect(result.length).toBeLessThanOrEqual(
246+
50 + '\u2026 (1000 chars total)'.length,
247+
);
248+
expect(result).toContain('\u2026');
249+
expect(result).toContain('chars total');
250+
});
251+
252+
it('should not truncate output within maxLength', () => {
253+
const short = 'Hello world';
254+
expect(formatForSpeech(short, { maxLength: 500 })).toBe('Hello world');
255+
});
256+
});
257+
258+
describe('whitespace normalisation', () => {
259+
it('should collapse more than two consecutive blank lines', () => {
260+
const result = formatForSpeech('para1\n\n\n\n\npara2');
261+
expect(result).toBe('para1\n\npara2');
262+
});
263+
264+
it('should trim leading and trailing whitespace', () => {
265+
expect(formatForSpeech(' hello ')).toBe('hello');
266+
});
267+
});
268+
269+
describe('real-world examples', () => {
270+
it('should clean an ENOENT error with markdown and path', () => {
271+
const input =
272+
'**Error**: `ENOENT: no such file or directory`\n> at /home/user/project/packages/core/src/tools/file-utils.ts:142:7';
273+
const result = formatForSpeech(input);
274+
expect(result).not.toContain('**');
275+
expect(result).not.toContain('`');
276+
expect(result).not.toContain('>');
277+
expect(result).toContain('Error');
278+
expect(result).toContain('ENOENT');
279+
expect(result).toContain('line 142');
280+
});
281+
282+
it('should clean a heading + list response', () => {
283+
const input = '# Results\n- item one\n- item two\n- item three';
284+
const result = formatForSpeech(input);
285+
expect(result).toBe('Results\nitem one\nitem two\nitem three');
286+
});
287+
});
288+
});

0 commit comments

Comments
 (0)