Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
43eeb8c
feat(voice): implement speech-friendly response formatter
ayush31010 Mar 3, 2026
1ae0f72
fix(voice): fix Windows path regex to support all drive letters
ayush31010 Mar 3, 2026
273afbe
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 4, 2026
c8168e1
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 5, 2026
68a0be5
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 8, 2026
d00324f
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 9, 2026
322d3b2
fix(voice): add @ to UNIX_PATH_RE to support scoped npm package paths
ayush31010 Mar 9, 2026
0d4bcf9
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 9, 2026
bf25f57
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 9, 2026
84eb97b
fix(voice): address PR review comments on responseFormatter
ayush31010 Mar 10, 2026
225cc1f
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 10, 2026
ecd623d
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 10, 2026
1933bad
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 10, 2026
2d8c2f5
fix(voice): prevent polynomial regex ReDoS on ANSI and path matching
ayush31010 Mar 10, 2026
81c9457
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 10, 2026
a75fa35
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 10, 2026
58cafac
Merge branch 'main' into fix/voice-response-formatter
ayush31010 Mar 10, 2026
b78f5c1
Merge branch 'main' into fix/voice-response-formatter
spencer426 Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions packages/core/src/voice/responseFormatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { formatForSpeech } from './responseFormatter.js';

describe('formatForSpeech', () => {
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');
});
});

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');
});
});

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');
});
});
});
Loading