Skip to content

Commit 728ed61

Browse files
committed
feat(showcase/voice): D5 mapping + sample-button bypasses /transcribe
The langgraph-python voice cell sat at D4 even when its d5-voice probe row was green. Root cause: the dashboard's CATALOG_TO_D5_KEY mirror in showcase/shell-dashboard/src/lib/live-status.ts was missing voice -> ["voice"], so computeMaxPossible capped voice at D4 regardless of probe state. The harness REGISTRY_TO_D5 already had the entry; only the dashboard mirror was out of sync. Separately, the "Play sample" button used to fetch sample.wav and POST it to /transcribe. With aimock that meant both the sample button AND the mic returned the same canned response, which made it impossible to demo the mic path locally without conflating the two affordances. Reworked the button into a synchronous static-text injector (onTranscribed(sampleText)) so: - Sample button = deterministic test/demo affordance, no runtime calls. - Mic = real Whisper transcription via /transcribe. Synced across all 18 voice-enabled integrations. Phrase stays "What is the weather in Tokyo?" so aimock's "weather in Tokyo" substring fixture still matches. Also adds the missing d5-voice.test.ts companion (every other d5-* probe script has one) and trims the langgraph-python qa/voice.md + e2e steps that depended on the now-removed async behavior.
1 parent 23d4770 commit 728ed61

40 files changed

Lines changed: 715 additions & 1322 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, it, expect } from "vitest";
2+
import { getD5Script, type D5BuildContext } from "../helpers/d5-registry.js";
3+
import type { Page } from "../helpers/conversation-runner.js";
4+
// Top-level import triggers the script's `registerD5Script` side effect
5+
// against the singleton registry. Mirrors the pattern in
6+
// `d5-tool-rendering.test.ts` — the registry is process-wide, modules
7+
// are cached, so we register once at import time and read it back via
8+
// `getD5Script` per assertion.
9+
import {
10+
buildTurns,
11+
SAMPLE_AUDIO_BUTTON_SELECTOR,
12+
} from "./d5-voice.js";
13+
14+
/**
15+
* Tests for the D5 voice script. Three concerns:
16+
*
17+
* 1. Side-effect registration — the import wires up `voice` in the
18+
* registry pointing at the bundled `d5-all.json` fixture (the
19+
* script reuses the canned transcription + weather fixtures that
20+
* already live there).
21+
* 2. `buildTurns` produces a single `skipFill` turn whose `preFill`
22+
* drives the sample-audio button click + textarea-poll path.
23+
* 3. The `assertions` callback fires (throws) when the assistant
24+
* transcript is empty/missing the weather/Tokyo content, and
25+
* passes through when the transcript contains the expected
26+
* keywords.
27+
*/
28+
29+
interface FakePageScript {
30+
/** Sequence of textContent values returned by readAssistantTranscript. */
31+
evaluateValues?: unknown[];
32+
/** Whether `waitForSelector` should throw (button not visible). */
33+
throwOnWaitForSelector?: boolean;
34+
/** Whether the click should throw. */
35+
throwOnClick?: boolean;
36+
/** Sequence of textarea values returned to the textarea poll. */
37+
textareaValues?: string[];
38+
/** Tracks how many times the click was invoked. */
39+
clickCalls?: { count: number };
40+
}
41+
42+
/**
43+
* Build a fake Page that satisfies the runner's `Page` interface plus the
44+
* additional `click` method d5-voice's preFill uses. The fake interleaves
45+
* two `evaluate` consumers: the textarea-value poll inside preFill, and
46+
* the assistant-transcript read inside the assertion. We split them by
47+
* tagging `evaluateValues` (used after preFill returns) and
48+
* `textareaValues` (consumed by the textarea poll). The fake hands out
49+
* textareaValues first, then falls through to evaluateValues.
50+
*/
51+
function makePage(script: FakePageScript = {}): Page & {
52+
click: (sel: string, opts?: { timeout?: number }) => Promise<void>;
53+
} {
54+
const transcripts = [...(script.evaluateValues ?? [])];
55+
const textareaQueue = [...(script.textareaValues ?? [])];
56+
return {
57+
async waitForSelector() {
58+
if (script.throwOnWaitForSelector) {
59+
throw new Error("waitForSelector timeout (test fake)");
60+
}
61+
},
62+
async fill() {
63+
// Unused — voice script uses skipFill: true.
64+
},
65+
async press() {
66+
// Unused — these tests assert on preFill + assertion, not the
67+
// runner's press step.
68+
},
69+
async click() {
70+
if (script.clickCalls) script.clickCalls.count += 1;
71+
if (script.throwOnClick) {
72+
throw new Error("click failed (test fake)");
73+
}
74+
},
75+
async evaluate<R>(): Promise<R> {
76+
// The textarea-value poll runs first (during preFill); after it
77+
// resolves, evaluateValues feeds the assistant-transcript reader.
78+
if (textareaQueue.length > 0) {
79+
const next = textareaQueue.shift();
80+
// The textarea poll evaluator returns a string. Wrap as the
81+
// generic R the caller asked for — the runner doesn't introspect.
82+
return next as unknown as R;
83+
}
84+
if (transcripts.length === 0) return undefined as R;
85+
if (transcripts.length === 1) return transcripts[0] as R;
86+
return transcripts.shift() as R;
87+
},
88+
};
89+
}
90+
91+
describe("d5-voice script", () => {
92+
describe("registration", () => {
93+
it("registers under featureType 'voice' with the bundled d5-all.json fixture", () => {
94+
const script = getD5Script("voice");
95+
expect(script).toBeDefined();
96+
expect(script?.featureTypes).toEqual(["voice"]);
97+
// Voice piggybacks on the bundled d5-all.json fixture because the
98+
// canned transcription + weather get_weather entries already live
99+
// there. No per-feature fixture file is needed.
100+
expect(script?.fixtureFile).toBe("d5-all.json");
101+
});
102+
103+
it("registers a buildTurns function that round-trips through the registry", () => {
104+
const script = getD5Script("voice");
105+
expect(script?.buildTurns).toBe(buildTurns);
106+
});
107+
});
108+
109+
describe("buildTurns", () => {
110+
const ctx: D5BuildContext = {
111+
integrationSlug: "langgraph-python",
112+
featureType: "voice",
113+
baseUrl: "https://example.test",
114+
};
115+
116+
it("produces a single turn with skipFill=true and an empty input", () => {
117+
const turns = buildTurns(ctx);
118+
expect(turns).toHaveLength(1);
119+
const turn = turns[0]!;
120+
expect(turn.skipFill).toBe(true);
121+
// input is empty because preFill + the synchronous text injection
122+
// populate the textarea — `page.fill()` would overwrite it.
123+
expect(turn.input).toBe("");
124+
expect(typeof turn.preFill).toBe("function");
125+
expect(typeof turn.assertions).toBe("function");
126+
});
127+
128+
it("uses the canonical sample-audio-button testid", () => {
129+
// Pinning the selector here so a refactor that breaks the testid
130+
// surfaces as a test failure rather than a silent probe miss.
131+
expect(SAMPLE_AUDIO_BUTTON_SELECTOR).toBe(
132+
'[data-testid="voice-sample-audio-button"]',
133+
);
134+
});
135+
});
136+
137+
describe("preFill", () => {
138+
it("clicks the sample audio button and resolves once the textarea is populated", async () => {
139+
const turns = buildTurns({
140+
integrationSlug: "langgraph-python",
141+
featureType: "voice",
142+
baseUrl: "https://example.test",
143+
});
144+
const turn = turns[0]!;
145+
const clickCalls = { count: 0 };
146+
const page = makePage({
147+
clickCalls,
148+
// First poll returns "" (still empty), second returns the canned
149+
// phrase — the script must keep polling until non-empty rather
150+
// than returning on first read.
151+
textareaValues: ["", "What is the weather in Tokyo?"],
152+
});
153+
154+
await turn.preFill!(page);
155+
156+
expect(clickCalls.count).toBe(1);
157+
});
158+
159+
it("throws when the sample-audio button never becomes visible", async () => {
160+
const turns = buildTurns({
161+
integrationSlug: "langgraph-python",
162+
featureType: "voice",
163+
baseUrl: "https://example.test",
164+
});
165+
const turn = turns[0]!;
166+
const page = makePage({ throwOnWaitForSelector: true });
167+
168+
await expect(turn.preFill!(page)).rejects.toThrow(
169+
/sample audio button.*not visible/,
170+
);
171+
});
172+
});
173+
174+
describe("assertion", () => {
175+
it("passes when the assistant transcript mentions weather", async () => {
176+
const turns = buildTurns({
177+
integrationSlug: "langgraph-python",
178+
featureType: "voice",
179+
baseUrl: "https://example.test",
180+
});
181+
const turn = turns[0]!;
182+
const page = makePage({
183+
evaluateValues: ["the weather in tokyo is 22°c and partly cloudy"],
184+
});
185+
186+
await expect(turn.assertions!(page)).resolves.toBeUndefined();
187+
});
188+
189+
it("passes on temperature-only mention (matches the weather/tokyo/temperature OR cascade)", async () => {
190+
const turns = buildTurns({
191+
integrationSlug: "langgraph-python",
192+
featureType: "voice",
193+
baseUrl: "https://example.test",
194+
});
195+
const turn = turns[0]!;
196+
const page = makePage({
197+
evaluateValues: ["current temperature: 22 degrees"],
198+
});
199+
200+
await expect(turn.assertions!(page)).resolves.toBeUndefined();
201+
});
202+
203+
it(
204+
"throws when the assistant transcript is unrelated to weather",
205+
async () => {
206+
const turns = buildTurns({
207+
integrationSlug: "langgraph-python",
208+
featureType: "voice",
209+
baseUrl: "https://example.test",
210+
});
211+
const turn = turns[0]!;
212+
const page = makePage({
213+
evaluateValues: ["i don't know how to answer that question"],
214+
});
215+
216+
// The assertion polls for up to 5s before giving up — give the
217+
// test enough headroom to observe the rejection rather than
218+
// racing it against vitest's default 5000ms timeout.
219+
await expect(turn.assertions!(page)).rejects.toThrow(
220+
/assistant transcript missing weather\/Tokyo content/,
221+
);
222+
},
223+
10_000,
224+
);
225+
});
226+
});

showcase/integrations/ag2/src/app/demos/voice/page.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import { SampleAudioButton } from "./sample-audio-button";
77

88
const RUNTIME_URL = "/api/copilotkit-voice";
99
const AGENT_ID = "voice-demo";
10-
const SAMPLE_AUDIO_PATH = "/demo-audio/sample.wav";
11-
const SAMPLE_LABEL = "What is the weather in Tokyo?";
10+
const SAMPLE_TEXT = "What is the weather in Tokyo?";
1211

1312
// Voice demo (AG2). Two affordances on this page:
1413
//
1514
// 1. The default mic button rendered by <CopilotChat /> when the runtime at
1615
// RUNTIME_URL advertises `audioFileTranscriptionEnabled: true`.
17-
// 2. A "Play sample" button that POSTs a bundled clip to /transcribe and
18-
// injects the transcript into the chat composer (bypasses mic perms so
19-
// Playwright + screenshot flows work too).
16+
// 2. A "Play sample" button that synchronously injects a canned phrase into
17+
// the composer (bypasses mic perms and the runtime entirely so Playwright
18+
// + screenshot flows work too). Real transcription only happens through
19+
// the mic path.
2020
// @region[voice-page]
2121
export default function VoiceDemoPage() {
2222
const handleTranscribed = useCallback((text: string) => {
@@ -59,9 +59,7 @@ export default function VoiceDemoPage() {
5959
</header>
6060
<SampleAudioButton
6161
onTranscribed={handleTranscribed}
62-
runtimeUrl={RUNTIME_URL}
63-
audioSrc={SAMPLE_AUDIO_PATH}
64-
sampleLabel={SAMPLE_LABEL}
62+
sampleText={SAMPLE_TEXT}
6563
/>
6664
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-black/10 dark:border-white/10">
6765
<CopilotChat agentId={AGENT_ID} className="h-full" />

showcase/integrations/ag2/src/app/demos/voice/sample-audio-button.tsx

Lines changed: 20 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,32 @@
11
"use client";
22

3-
import { useState } from "react";
4-
53
/**
6-
* Sample-audio button for the voice demo (AG2).
4+
* Sample-audio button for the voice demo.
75
*
8-
* Bypasses the microphone entirely: fetches a bundled audio clip from
9-
* /public and POSTs it as multipart/form-data to the runtime's
10-
* `/transcribe` endpoint. On success, invokes `onTranscribed(text)` so the
11-
* caller can populate the chat composer.
6+
* Pure test/demo affordance: clicking the button synchronously injects a
7+
* canned phrase into the chat composer via `onTranscribed(sampleText)`.
8+
* No microphone permission, no audio fetch, no `/transcribe` round trip
9+
* — those concerns belong to the mic button rendered by `<CopilotChat />`.
10+
* Keeping this affordance deterministic means the d5-voice probe and the
11+
* Playwright e2e never depend on the runtime's transcription endpoint
12+
* being healthy or on any specific aimock fixture surviving across
13+
* environments.
1214
*/
1315
export interface SampleAudioButtonProps {
16+
/** Called with the canned sample text when the button is clicked. */
1417
onTranscribed: (text: string) => void;
15-
runtimeUrl: string;
16-
audioSrc: string;
17-
sampleLabel: string;
18+
/**
19+
* Phrase that doubles as the visible caption AND the text injected
20+
* into the composer when the button is clicked.
21+
*/
22+
sampleText: string;
1823
}
1924

2025
// @region[sample-audio-button]
2126
export function SampleAudioButton({
2227
onTranscribed,
23-
runtimeUrl,
24-
audioSrc,
25-
sampleLabel,
28+
sampleText,
2629
}: SampleAudioButtonProps) {
27-
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
28-
29-
async function handleClick() {
30-
setStatus("loading");
31-
try {
32-
const audioRes = await fetch(audioSrc);
33-
if (!audioRes.ok) {
34-
throw new Error(`Failed to fetch sample audio: ${audioRes.status}`);
35-
}
36-
const blob = await audioRes.blob();
37-
const formData = new FormData();
38-
formData.append("audio", blob, "sample.wav");
39-
const base = runtimeUrl.replace(/\/$/, "");
40-
const transcribeRes = await fetch(`${base}/transcribe`, {
41-
method: "POST",
42-
body: formData,
43-
});
44-
if (!transcribeRes.ok) {
45-
throw new Error(`Transcribe failed: ${transcribeRes.status}`);
46-
}
47-
const json = (await transcribeRes.json()) as { text?: string };
48-
if (!json.text) {
49-
throw new Error("Transcribe returned no text");
50-
}
51-
onTranscribed(json.text);
52-
setStatus("idle");
53-
} catch (err) {
54-
console.error("[voice-demo] sample transcription failed", err);
55-
setStatus("error");
56-
}
57-
}
58-
5930
return (
6031
<div
6132
data-testid="voice-sample-audio"
@@ -64,23 +35,14 @@ export function SampleAudioButton({
6435
<button
6536
type="button"
6637
data-testid="voice-sample-audio-button"
67-
onClick={handleClick}
68-
disabled={status === "loading"}
69-
className="rounded border border-black/10 bg-white px-3 py-1 text-xs font-medium hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:bg-black/30 dark:hover:bg-white/10"
38+
onClick={() => onTranscribed(sampleText)}
39+
className="rounded border border-black/10 bg-white px-3 py-1 text-xs font-medium hover:bg-black/5 dark:border-white/10 dark:bg-black/30 dark:hover:bg-white/10"
7040
>
71-
{status === "loading" ? "Transcribing..." : "Play sample"}
41+
Play sample
7242
</button>
7343
<span className="text-black/60 dark:text-white/60">
74-
Sample: &ldquo;{sampleLabel}&rdquo;
44+
Sample: &ldquo;{sampleText}&rdquo;
7545
</span>
76-
{status === "error" && (
77-
<span
78-
data-testid="voice-sample-audio-error"
79-
className="ml-auto text-red-600 dark:text-red-400"
80-
>
81-
Error — see console
82-
</span>
83-
)}
8446
</div>
8547
);
8648
}

showcase/integrations/agno/src/app/demos/voice/page.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@ import { SampleAudioButton } from "./sample-audio-button";
66

77
const RUNTIME_URL = "/api/copilotkit-voice";
88
const AGENT_ID = "voice-demo";
9-
const SAMPLE_AUDIO_PATH = "/demo-audio/sample.wav";
10-
const SAMPLE_LABEL = "What is the weather in Tokyo?";
9+
const SAMPLE_TEXT = "What is the weather in Tokyo?";
1110

1211
// Voice demo (Agno).
1312
//
1413
// The mic button on <CopilotChat /> appears when the runtime advertises
1514
// `audioFileTranscriptionEnabled: true`. Click it, speak, click again — text
1615
// is transcribed into the composer. The <SampleAudioButton /> below the
17-
// chat fetches a bundled wav and POSTs it to /transcribe so screenshot &
18-
// playwright runs work without mic permissions.
16+
// chat synchronously injects a canned phrase so screenshot & playwright runs
17+
// work without mic permissions; the runtime is not involved on that path.
1918
// @region[voice-page]
2019
export default function VoiceDemoPage() {
2120
const handleTranscribed = useCallback((text: string) => {
@@ -58,9 +57,7 @@ export default function VoiceDemoPage() {
5857
</header>
5958
<SampleAudioButton
6059
onTranscribed={handleTranscribed}
61-
runtimeUrl={RUNTIME_URL}
62-
audioSrc={SAMPLE_AUDIO_PATH}
63-
sampleLabel={SAMPLE_LABEL}
60+
sampleText={SAMPLE_TEXT}
6461
/>
6562
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-black/10 dark:border-white/10">
6663
<CopilotChat agentId={AGENT_ID} className="h-full" />

0 commit comments

Comments
 (0)