// SPDX-License-Identifier: MIT
import { describe, expect, it } from "vitest";
import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText, normalizeForSpeech, stripLeakedToolCalls , takeFirstFragment } from "./voice-text.js";
describe("stripLeakedToolCalls", () => {
it("removes XML-style tool_call blocks including inner JSON", () => {
expect(
stripLeakedToolCalls('Sure. {"name":"lookup","arguments":{"id":"7"}}'),
).toBe("Sure.");
});
it("removes special sentinel tokens (Gemma/Llama/harmony) and Mistral markers", () => {
expect(stripLeakedToolCalls("<|tool_call|> checking now")).toBe("checking now");
expect(stripLeakedToolCalls("<|channel|>commentary answer")).toBe("answer");
expect(stripLeakedToolCalls("[TOOL_CALLS] one moment")).toBe("one moment");
});
it("leaves real speech with legitimate punctuation untouched", () => {
const prose = "The deadline is February 5th, and the fee is 40 dollars.";
expect(stripLeakedToolCalls(prose)).toBe(prose);
});
it("does NOT strip bare JSON (ambiguous — that's a serving-stack bug, not ours to guess)", () => {
const s = 'Your balance is {"amount": 42}.';
expect(stripLeakedToolCalls(s)).toBe(s);
});
it("is applied inside normalizeForSpeech so leaked markup never reaches TTS", () => {
expect(normalizeForSpeech("{}**Done.**")).toBe("Done.");
});
});
describe("normalizeForSpeech", () => {
it("strips bold, italic, strikethrough, and code markers", () => {
expect(normalizeForSpeech("This is **bold** and *italic* and `code`.")).toBe(
"This is bold and italic and code.",
);
expect(normalizeForSpeech("__strong__ and _em_ and ~~gone~~")).toBe("strong and em and gone");
});
it("reduces a markdown link to its label", () => {
expect(normalizeForSpeech("See [the deadline](https://x.edu/deadline) today.")).toBe(
"See the deadline today.",
);
});
it("removes leading block markers: headings, bullets, quotes, numbered lists", () => {
expect(normalizeForSpeech("## Deadlines")).toBe("Deadlines");
expect(normalizeForSpeech("- upload the form")).toBe("upload the form");
expect(normalizeForSpeech("1. first step")).toBe("first step");
expect(normalizeForSpeech("> a quote")).toBe("a quote");
});
it("leaves ordinary prose untouched", () => {
const prose = "Your late add deadline was February 5th, and the fee is 40 dollars.";
expect(normalizeForSpeech(prose)).toBe(prose);
});
it("does not mangle a lone asterisk or hash inside prose", () => {
// Conservative: an isolated * or # that isn't a paired/leading marker stays.
expect(normalizeForSpeech("Use the * key or press # to continue.")).toBe(
"Use the * key or press # to continue.",
);
});
});
describe("isCompleteVoiceText", () => {
it("treats terminal punctuation as complete", () => {
expect(isCompleteVoiceText("Hello there.")).toBe(true);
expect(isCompleteVoiceText("Really?!")).toBe(true);
});
it("treats an unterminated fragment as incomplete", () => {
expect(isCompleteVoiceText("Hello there")).toBe(false);
expect(isCompleteVoiceText("and then we")).toBe(false);
});
it("looks past trailing closing quotes/brackets to the terminator", () => {
expect(isCompleteVoiceText('She said "hi."')).toBe(true);
expect(isCompleteVoiceText("(a complete aside.)")).toBe(true);
expect(isCompleteVoiceText('an open quote "')).toBe(false);
});
it("recognizes non-English terminal punctuation", () => {
expect(isCompleteVoiceText("こんにちは。")).toBe(true); // Japanese full stop
expect(isCompleteVoiceText("مرحبا؟")).toBe(true); // Arabic question mark
expect(isCompleteVoiceText("नमस्ते।")).toBe(true); // Devanagari danda
});
it("does not treat abbreviation or decimal dots as sentence ends", () => {
// These would otherwise be voiced with a falling intonation and split from
// their continuation ("Dr." | "Smith", "twelve." | "fifty").
expect(isCompleteVoiceText("Dr.")).toBe(false);
expect(isCompleteVoiceText("e.g.")).toBe(false);
expect(isCompleteVoiceText("The total is $12.")).toBe(false);
expect(isCompleteVoiceText("Meet at 3 p.m.")).toBe(false);
expect(isCompleteVoiceText("His name is J.")).toBe(false);
// But a real sentence end after an abbreviation earlier in the text is fine.
expect(isCompleteVoiceText("Dr. Smith will see you now.")).toBe(true);
expect(isCompleteVoiceText("The total is $12.50 today.")).toBe(true);
});
});
describe("takeCompleteVoiceText", () => {
it("splits leading complete sentences from the incomplete remainder", () => {
const { text, remaining } = takeCompleteVoiceText("One. Two. Thre");
expect(text).toBe("One. Two.");
expect(remaining).toBe("Thre");
});
it("returns no text when nothing is complete yet", () => {
const { text, remaining } = takeCompleteVoiceText("still going");
expect(text).toBe("");
expect(remaining).toBe("still going");
});
it("emits multiple complete sentences and buffers only the trailing fragment", () => {
const { text, remaining } = takeCompleteVoiceText("Done. And more.");
expect(text).toBe("Done. And more.");
expect(remaining).toBe("");
});
it("buffers the trailing incomplete fragment after a complete sentence", () => {
// Capitalized continuation so the locale-aware segmenter treats it as a new
// (still-incomplete) sentence rather than one run-on.
const { text, remaining } = takeCompleteVoiceText("Done. And more");
expect(text).toBe("Done.");
expect(remaining).toBe("And more");
});
});
describe("appendVoiceText", () => {
it("seeds from empty and trims", () => {
expect(appendVoiceText("", " hi ")).toBe("hi");
});
it("joins with a single space when neither side has whitespace at the seam", () => {
expect(appendVoiceText("Hello", "there")).toBe("Hello there");
});
it("does not double-space when the existing side already ends in whitespace", () => {
expect(appendVoiceText("Hello ", "there")).toBe("Hello there");
});
it("trims a whitespace-led next fragment (the seam's space collapses)", () => {
// Current behavior: a leading-whitespace `next` is trimmed and concatenated
// directly. Reachable inputs (trimmed segment text) never hit this path; the
// test pins the documented behavior rather than the intuitive one.
expect(appendVoiceText("Hello", " there")).toBe("Hellothere");
});
it("returns the existing text unchanged when the next fragment is blank", () => {
expect(appendVoiceText("Hello", " ")).toBe("Hello");
});
});
describe("takeFirstFragment — the first chunk only", () => {
it("splits at a clause boundary once long enough", () => {
const r = takeFirstFragment("The deadline is March first, but it varies by program.", 20);
expect(r.text).toBe("The deadline is March first,");
expect(r.remaining).toBe(" but it varies by program.");
});
it("waits when the text is still too short to be worth speaking", () => {
expect(takeFirstFragment("Sure, ok", 20).text).toBe("");
});
it("fires on real reply shapes at the shipped default", () => {
// A default of 45 was shipped briefly and could NEVER fire: it starts the scan
// past the comma in a normal sentence. Checked against actual agent replies.
expect(takeFirstFragment("The deadline is March first, but it varies by program.", 25).text)
.toBe("The deadline is March first,");
expect(takeFirstFragment("For most computer science masters programs, the deadline is in December.", 25).text)
.toBe("For most computer science masters programs,");
// No clause boundary exists here — waiting for the sentence is correct.
expect(takeFirstFragment("Please specify the university or program you are referring to.", 25).text).toBe("");
});
it("never splits mid-word — punctuation must be followed by whitespace", () => {
// "1,500" is a thousands separator, not a clause boundary.
expect(takeFirstFragment("The fee is about 1,500 dollars per term overall", 12).text).toBe("");
});
it("does not fire on a decimal or abbreviation dot, which are not clauses", () => {
expect(takeFirstFragment("It costs 12.50 for each of the many items listed", 10).text).toBe("");
expect(takeFirstFragment("Contact Dr. Smith about the application deadline", 10).text).toBe("");
});
it("returns nothing when there is no clause boundary at all", () => {
expect(takeFirstFragment("a".repeat(80), 20).text).toBe("");
});
});