Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
196 lines
7.1 KiB
TypeScript
196 lines
7.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
isUnspeakableSpeechText,
|
|
narrateTool,
|
|
speakable,
|
|
speechFromBlocks,
|
|
spokenDecision,
|
|
toUtterances,
|
|
} from "./speech-text.js";
|
|
|
|
describe("speakable", () => {
|
|
it("names a code block instead of reading it", () => {
|
|
const out = speakable(
|
|
"Here's the fix:\n\n```ts\nconst x: number = 1;\nif (x) throw new Error('no');\n```\n\nThat's it.",
|
|
);
|
|
expect(out).toContain("TypeScript code block");
|
|
expect(out).not.toContain("const x");
|
|
expect(out).not.toContain("throw");
|
|
expect(out).toContain("That's it.");
|
|
});
|
|
|
|
it("closes an unterminated fence rather than swallowing the rest", () => {
|
|
const out = speakable("Working on it:\n\n```sh\nnpm test");
|
|
expect(out).toContain("Working on it");
|
|
expect(out).toContain("code block");
|
|
expect(out).not.toContain("npm test");
|
|
});
|
|
|
|
it("keeps a link's words and drops its URL", () => {
|
|
expect(speakable("See [the README](https://example.com/a/b?c=d) for more")).toBe(
|
|
"See the README for more",
|
|
);
|
|
});
|
|
|
|
it("turns a bare URL into a noun", () => {
|
|
expect(speakable("Deployed to https://status.example.com/health now")).toBe(
|
|
"Deployed to a link now",
|
|
);
|
|
});
|
|
|
|
it("says the file, not the path to it", () => {
|
|
expect(speakable("I changed server/drivers/acp/core.ts today")).toBe("I changed core.ts today");
|
|
});
|
|
|
|
it("keeps short inline code but not long snippets", () => {
|
|
expect(speakable("Run `pnpm test` first")).toBe("Run pnpm test first");
|
|
const long = speakable(`Use \`${"x".repeat(60)}\` here`);
|
|
expect(long).toBe("Use that snippet here");
|
|
});
|
|
|
|
it("strips list scaffolding but keeps the pause between items", () => {
|
|
const out = speakable("- first thing\n- second thing\n- third thing");
|
|
expect(out).toBe("first thing. second thing. third thing");
|
|
});
|
|
|
|
it("reads a table as rows, not pipes", () => {
|
|
const out = speakable("| Name | State |\n| --- | --- |\n| Scout | idle |");
|
|
expect(out).not.toContain("|");
|
|
expect(out).not.toContain("---");
|
|
expect(out).toContain("Scout, idle");
|
|
});
|
|
|
|
it("drops emphasis markers, emoji and checkboxes", () => {
|
|
expect(speakable("**Done** ✅ — [x] shipped the _thing_")).toBe("Done — shipped the thing");
|
|
});
|
|
|
|
it("gives a heading a full stop so the voice breathes", () => {
|
|
expect(speakable("## Results\nAll green")).toBe("Results. All green");
|
|
});
|
|
|
|
it("collapses the punctuation its own substitutions create", () => {
|
|
expect(speakable("Done.\n\n\n- one\n\n- two")).toBe("Done. one. two");
|
|
});
|
|
|
|
it("is empty for empty input", () => {
|
|
expect(speakable("")).toBe("");
|
|
expect(speakable(" \n\n ")).toBe("");
|
|
});
|
|
|
|
it("never speaks pasted handoff tool JSON", () => {
|
|
const raw =
|
|
'Since the handoff is given:\n{"name": "handoff_to_bot", "parameters": {"message": "same task", "confirm_name": "Boss"}}';
|
|
expect(speakable(raw)).toBe("");
|
|
expect(speakable("assistant: " + raw)).toBe("");
|
|
});
|
|
|
|
it("joins message blocks into something a voice can read", () => {
|
|
expect(
|
|
speechFromBlocks([
|
|
{ kind: "text", text: "Done." },
|
|
{ kind: "ask", text: "Ship it?", status: "pending" },
|
|
]),
|
|
).toContain("Ship it?");
|
|
});
|
|
|
|
it("drops tool-call JSON and tool-activity blocks from voice text", () => {
|
|
const toolJson = JSON.stringify({
|
|
name: "handoff_to_bot",
|
|
arguments: { targetBotId: "bot-b", message: "Take over" },
|
|
});
|
|
expect(isUnspeakableSpeechText(toolJson)).toBe(true);
|
|
expect(
|
|
speechFromBlocks([
|
|
{ kind: "text", text: toolJson },
|
|
{ kind: "progress", text: "Using browser", activity: true },
|
|
{ kind: "text", text: "I'll hand this off." },
|
|
]),
|
|
).toBe("I'll hand this off.");
|
|
});
|
|
|
|
it("drops XML tool_call payloads", () => {
|
|
expect(isUnspeakableSpeechText('<tool_call>{"name":"bash","arguments":{}}</tool_call>')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("toUtterances", () => {
|
|
it("splits on sentences", () => {
|
|
const out = toUtterances("The tests pass now. I changed two files. Want me to push it?");
|
|
expect(out).toHaveLength(3);
|
|
expect(out[0]).toBe("The tests pass now.");
|
|
expect(out[2]).toBe("Want me to push it?");
|
|
});
|
|
|
|
it("does not split inside a decimal or an abbreviation", () => {
|
|
const out = toUtterances(
|
|
"It dropped to 11.7 seconds per step, i.e. about half of what it was before.",
|
|
);
|
|
expect(out).toHaveLength(1);
|
|
});
|
|
|
|
it("glues a fragment onto its neighbour", () => {
|
|
const out = toUtterances("Yes. The whole suite is green and nothing else changed.");
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toContain("Yes.");
|
|
});
|
|
|
|
it("breaks a runaway sentence at a clause, never mid-word", () => {
|
|
const long = `I looked at ${Array.from({ length: 40 }, (_, i) => `item ${i}`).join(", ")} and finished.`;
|
|
const out = toUtterances(long, { maxChars: 120 });
|
|
expect(out.length).toBeGreaterThan(1);
|
|
for (const piece of out) expect(piece.length).toBeLessThanOrEqual(140);
|
|
expect(out.join(" ")).toContain("item 39");
|
|
});
|
|
|
|
it("runs its input through the spoken register first", () => {
|
|
const out = toUtterances("Fixed it.\n\n```js\nconsole.log(1)\n```\n\nShipped.");
|
|
expect(out.join(" ")).not.toContain("console.log");
|
|
});
|
|
|
|
it("is empty for text that speaks to nothing", () => {
|
|
expect(toUtterances("```\ncode only\n```")).not.toContain("code only");
|
|
expect(toUtterances("")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("narrateTool", () => {
|
|
it("turns tool names into something worth hearing", () => {
|
|
expect(narrateTool("Bash")).toBe("running a command");
|
|
expect(narrateTool("Read")).toBe("reading a file");
|
|
expect(narrateTool("Edit")).toBe("editing a file");
|
|
expect(narrateTool("WebSearch")).toBe("searching the web");
|
|
expect(narrateTool("screenshot")).toBe("looking at the screen");
|
|
expect(narrateTool("computer_observe")).toBe("looking at the screen");
|
|
expect(narrateTool("browser_snapshot")).toBe("using the page");
|
|
expect(narrateTool("write_file")).toBe("writing a file");
|
|
expect(narrateTool("run_subagent")).toBe("starting a subagent");
|
|
});
|
|
|
|
it("sees through an MCP tool prefix", () => {
|
|
expect(narrateTool("mcp__computer__click")).toBe("using the computer");
|
|
expect(narrateTool("mcp__agents__ask_bot")).toBe("asking a teammate");
|
|
});
|
|
|
|
it("stays quiet for chips the user is already told about another way", () => {
|
|
expect(narrateTool("auto-approved Bash:git (always allowed)")).toBeNull();
|
|
expect(narrateTool("error: claude exited 1")).toBeNull();
|
|
expect(narrateTool("")).toBeNull();
|
|
});
|
|
|
|
it("falls back to naming an unknown tool, without reading its argv", () => {
|
|
expect(narrateTool("deploy_thing")).toBe("running deploy_thing");
|
|
expect(narrateTool('curl -X POST "https://x/y" --data @{}')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("spokenDecision", () => {
|
|
it("accepts a clear yes or no and refuses a sentence that merely contains one", () => {
|
|
expect(spokenDecision("yes")).toBe("yes");
|
|
expect(spokenDecision("No thanks")).toBe("no");
|
|
expect(spokenDecision("I'm sure that's fine, go look at the logs")).toBeNull();
|
|
expect(spokenDecision("")).toBeNull();
|
|
});
|
|
});
|