Skip to content

Commit e1b03b8

Browse files
jamie-simularclaude
andcommitted
test(voice): a demo flow, paced like a real call
The harness could already drive the loop; it could not be watched. Turns arrived in the presenter as finished sentences appearing at machine speed, which is the tell that a rig is driving it. `speak` publishes one turn id growing a word at a time (~150 wpm), `beat` leaves a silence where a person would hear one, and `stashAttachment` puts a photo on the bridge the way a real capture does — so a recording of the harness reads as a recording of a call. DemoFlowTest drives that end to end against a real model and a real agent, behind SAI_DEMO=1 so CI never wakes a VM. Also a real bug the demo surfaced: the turn end was always scheduled on the virtual clock, but a live transport delivers on real time and nothing advances the virtual one — so the turn never ended, `modelSpeaking` stayed true, and every nudge deferred behind it. The model looked mute when it had simply never been told anything. Same shape as the bug LiveTurnGate was extracted for, and it spoiled a live demo before the cause was spotted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f638f36 commit e1b03b8

7 files changed

Lines changed: 345 additions & 4 deletions

File tree

meta-android-app/app/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,16 @@ tasks.withType<Test>().configureEach {
199199
// Mirroring a harness conversation to the presenter, so a test can be watched.
200200
"SAI_PRESENTER",
201201
"SAI_PRESENTER_PACE_MS",
202+
// The demo flow, which drives a real agent from a real model.
203+
"SAI_DEMO",
202204
)
203205
.forEach { name -> System.getenv(name)?.let { environment(name, it) } }
204206
// The eval's output IS its result — a scorecard, not an assertion count — so it has to reach the
205207
// terminal. Only while it is running: on the ordinary suite this would bury 285 tests in noise.
206208
// (A full run makes dozens of model calls and waits out per-minute rate limits between them, so it
207209
// takes minutes. Gradle sets no default test timeout, so there is nothing to raise.)
208-
if (System.getenv("SAI_CONVERSATION_EVAL") == "1" || System.getenv("SAI_PRESENTER") == "1") {
210+
if (System.getenv("SAI_CONVERSATION_EVAL") == "1" || System.getenv("SAI_PRESENTER") == "1" ||
211+
System.getenv("SAI_DEMO") == "1") {
209212
testLogging { showStandardStreams = true }
210213
}
211214
}

meta-android-app/app/src/test/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/saispike/conversation/ConversationHarness.kt

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ class ConversationHarness(
131131
*/
132132
suspend fun deliverAgentEvent(event: AgentEvent) = onAgentEvent(event)
133133

134+
/**
135+
* Leave a photo on the bridge for whatever writes next — what a real capture does when it lands.
136+
*
137+
* The stash belongs to the next write, which is exactly the immediate path's rule: the adapter
138+
* drains it on `forwardTask`. A held task takes its photos with it instead (see
139+
* `takePendingAttachments`), which is why a queued request cannot end up carrying someone else's.
140+
*/
141+
fun stashAttachment(attachment: com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.fsm.TaskAttachment) =
142+
bridge.addPendingAttachment(attachment)
143+
144+
/** Let the presenter show a beat of silence, when one is what a person would hear. */
145+
fun beat(ms: Long) = presenter?.pause(ms)
146+
134147
val concierge =
135148
Concierge(
136149
agent = bridge,
@@ -165,7 +178,7 @@ class ConversationHarness(
165178
suspend fun user(utterance: String) {
166179
lastUserSpeechAt = clock.now
167180
transcript += Line("you", utterance)
168-
presenter?.turn("you", utterance)
181+
presenter?.speak("you", utterance)
169182
gate.onUserTranscript(utterance)
170183
modelTurn(utterance)
171184
}
@@ -222,13 +235,26 @@ class ConversationHarness(
222235
if (!turn.speech.isNullOrBlank()) {
223236
runGate(gate.onSaiTranscript(turn.speech))
224237
transcript += Line("sai", turn.speech)
225-
presenter?.turn("sai", turn.speech)
238+
presenter?.speak("sai", turn.speech)
226239
}
227240
routeCalls(turn.calls)
228241
// The turn ends after the model has finished speaking, not instantly: the gap is the window in
229242
// which a completion landing mid-sentence is held, which is the race worth testing.
230-
clock.scheduleSuspending(if (turn.speech.isNullOrBlank()) 0 else speakingMs) {
243+
//
244+
// WHICH clock matters, and getting it wrong is silent. A live transport delivers on real time and
245+
// nothing advances the virtual one, so scheduling the turn end there meant it never fired:
246+
// `modelSpeaking` stayed true for the rest of the call and every nudge was deferred behind a turn
247+
// that would never end. The model looked mute when it had simply never been told anything — the
248+
// same shape as the bug LiveTurnGate was extracted for, reproduced in the harness, and it spoiled
249+
// a live demo before the cause was spotted.
250+
val speakFor = if (turn.speech.isNullOrBlank()) 0L else speakingMs
251+
if (transport != null) {
252+
kotlinx.coroutines.delay(speakFor)
231253
runGate(gate.onGenerationOrTurnEnd(generationEnded = false, turnEnded = true))
254+
} else {
255+
clock.scheduleSuspending(speakFor) {
256+
runGate(gate.onGenerationOrTurnEnd(generationEnded = false, turnEnded = true))
257+
}
232258
}
233259
}
234260

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/* sai-fi — voice concierge. */
2+
3+
// The demo flow: ONE call, the real model, a real agent, mirrored to the presenter.
4+
//
5+
// SAI_DEMO=1 SAI_PRESENTER=1 GEMINI_API_KEY=… \
6+
// SAI_CONCIERGE_URL=… SAI_MACHINE_ID=… SAI_ID_TOKEN=… \
7+
// ./gradlew :app:testDebugUnitTest --tests "*DemoFlowTest*" --rerun
8+
//
9+
// Everything is real except the microphone and the camera: the model is Gemini running the prompt
10+
// and tools the app ships, the FSM is the app's, the bridge and its wire are the app's, and the
11+
// agent is a real machine doing real work. What is stood in for is the audio — the user's turns are
12+
// injected as text at speaking pace — and the glasses camera, which uploads a labelled frame through
13+
// the real upload endpoint.
14+
//
15+
// **One call, not a series.** Each beat depends on the state the last one left: a task is queued only
16+
// because another is running, the status question is only interesting with two things outstanding,
17+
// and the goodbye only tests anything with work still in flight. Restarting between beats would
18+
// destroy the very thing being shown, and it is also how a demo ends up proving less than it appears
19+
// to — every step passing in isolation while the sequence has never once been run end to end.
20+
//
21+
// Paced for watching, not for speed. The user's lines stream in at ordinary speech (~150 wpm) rather
22+
// than appearing whole, because a line that lands complete and instantly is the tell that a rig is
23+
// driving it.
24+
25+
package com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.conversation
26+
27+
import com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.eval.LiveBrain
28+
import kotlinx.coroutines.CoroutineScope
29+
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.SupervisorJob
31+
import kotlinx.coroutines.runBlocking
32+
import kotlinx.coroutines.withTimeoutOrNull
33+
import org.json.JSONObject
34+
import org.junit.Assume.assumeTrue
35+
import org.junit.Test
36+
37+
class DemoFlowTest {
38+
39+
private val apiKey = System.getenv("GEMINI_API_KEY") ?: System.getenv("GOOGLE_API_KEY") ?: ""
40+
private val model = System.getenv("EVAL_MODEL") ?: "gemini-3-flash-preview"
41+
42+
@Test
43+
fun `the glasses demo, one conversation, live agent`() {
44+
assumeTrue("set SAI_DEMO=1 to run the demo flow (it drives a real agent)", System.getenv("SAI_DEMO") == "1")
45+
assumeTrue("set GEMINI_API_KEY", apiKey.isNotEmpty())
46+
val config = LiveAgentConfig.fromEnv().getOrElse { throw IllegalStateException(it.message, it) }
47+
48+
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
49+
lateinit var h: ConversationHarness
50+
51+
val brain =
52+
LiveBrain(
53+
apiKey = apiKey,
54+
model = model,
55+
resolveLocalTool = { name, _ ->
56+
when (name) {
57+
"getSaiStatus" -> JSONObject().put("status", h.status())
58+
// The camera. Uploads the frame and leaves it on the bridge, so the next forward
59+
// carries it exactly as a real capture would — the immediate path drains the stash.
60+
"captureImage" ->
61+
runBlocking {
62+
runCatching {
63+
val a = DummyCamera.capture(config.baseUrl, config.idToken)
64+
h.stashAttachment(a)
65+
println(" [camera] uploaded a simulated frame (${a.name})")
66+
JSONObject()
67+
.put("result", "captured")
68+
.put(
69+
"note",
70+
"The photo is saved on the device and has NOT been sent " +
71+
"anywhere; it goes only when a request carries it.")
72+
}
73+
.getOrElse {
74+
JSONObject().put("result", "failed").put("error", it.message ?: "camera error")
75+
}
76+
}
77+
else -> JSONObject().put("result", "ok")
78+
}
79+
},
80+
log = { println(" $it") },
81+
)
82+
83+
h = ConversationHarness(brain, speakingMs = 600)
84+
val live =
85+
LiveAgent(config, scope, deliver = { h.deliverAgentEvent(it) }, log = { println(" $it") })
86+
h.useTransport(live)
87+
88+
runBlocking {
89+
h.start()
90+
h.beat(1_500)
91+
92+
// 1 — the greeting. Sai speaks first, unprompted, exactly as it does when a call opens.
93+
say(h, "Hey Sai, are you there?")
94+
settle(h, live, 6_000)
95+
96+
// 2 — a real task on a real machine, and the approval it trips.
97+
//
98+
// The first run of this demo had no approval beat, and it distorted everything after it:
99+
// the listing tripped a guardrail, Sai asked "would you like me to approve that?", the
100+
// script ignored the question and moved on, and the task sat blocked while the rest of the
101+
// conversation talked around a result that never came. A demo that cannot answer a
102+
// guardrail cannot show a task finishing.
103+
say(h, "Can you check what's in my downloads folder?")
104+
settle(h, live, 45_000)
105+
answerAnyApproval(h, live)
106+
107+
// 3 — a second ask while the first is still going. This is the queue, and it only exists
108+
// because beat 2 left something running.
109+
say(h, "Oh, and also — what time is it right now?")
110+
h.beat(2_000)
111+
112+
// 4 — with two outstanding, ask what is happening. The interesting part is that they are
113+
// accounted for separately.
114+
say(h, "What's going on with all that?")
115+
settle(h, live, 45_000)
116+
117+
// 5 — the camera.
118+
say(h, "Have a look at this and tell me what it says.")
119+
settle(h, live, 45_000)
120+
121+
// 6 — barge-in, mid-answer.
122+
h.beat(800)
123+
bargeIn(h, "Sorry — actually, what can you do for me?")
124+
settle(h, live, 20_000)
125+
126+
// 7 — the goodbye.
127+
say(h, "That's everything, thanks — bye.")
128+
settle(h, live, 15_000)
129+
130+
println("\n=== the conversation ===")
131+
h.transcript.forEach { println(" ${it.speaker.padEnd(6)} ${it.text}") }
132+
println("\n=== what the agent ran ===")
133+
live.started.forEach { println("$it") }
134+
println("\nevents off the wire: ${live.received.map { it::class.simpleName }}")
135+
if (live.errors.isNotEmpty()) println("errors: ${live.errors}")
136+
}
137+
}
138+
139+
/**
140+
* If the agent is waiting on a guardrail, answer it the way a wearer would.
141+
*
142+
* Checked rather than scripted blind: whether a given task trips an approval depends on the
143+
* machine's settings, so a fixed "yes, go ahead" beat would be an answer to nothing on a machine
144+
* with guardrails off — and the model, told yes for no reason, has to invent what it is agreeing to.
145+
*/
146+
private suspend fun answerAnyApproval(h: ConversationHarness, live: LiveAgent) {
147+
if (h.state.pendingApprovalId == null) {
148+
println(" (no approval was raised — nothing to answer)")
149+
return
150+
}
151+
say(h, "Yes, go ahead — you have my approval.")
152+
settle(h, live, 60_000)
153+
}
154+
155+
/** A user turn, at speaking pace, with a breath before it. */
156+
private suspend fun say(h: ConversationHarness, line: String) {
157+
h.beat(900)
158+
println("\n>>> $line")
159+
h.user(line)
160+
}
161+
162+
private suspend fun bargeIn(h: ConversationHarness, line: String) {
163+
println("\n>>> (cutting in) $line")
164+
h.bargeIn(line)
165+
}
166+
167+
/**
168+
* Let the agent get on with it.
169+
*
170+
* Real time, not the virtual clock: a live agent takes as long as it takes, and the demo is
171+
* watchable precisely because those pauses are real. Bounded so a cold machine cannot hang the run.
172+
*/
173+
private suspend fun settle(h: ConversationHarness, live: LiveAgent, budgetMs: Long) {
174+
withTimeoutOrNull(budgetMs) { live.awaitTurn() }
175+
h.beat(1_200)
176+
}
177+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* sai-fi — voice concierge. */
2+
3+
// A glasses camera, without glasses.
4+
//
5+
// The camera is the one part of a call a scripted double cannot stand in for, because the photo has
6+
// to physically EXIST somewhere the agent can fetch it: the device uploads it and the task carries a
7+
// reference. So this puts a real JPEG through the real `/v1/agents/upload` and leaves the real
8+
// attachment on the bridge. From the agent's side it is indistinguishable from a glasses photo —
9+
// only the photons are fake.
10+
//
11+
// The frame is a committed resource rather than something drawn here: Android unit tests compile
12+
// against `android.jar`, which has no `java.awt` and no `javax.imageio`, so there is nothing to draw
13+
// with. It was generated on a Mac (see the PR) and says SIMULATED CAPTURE across the bottom, because
14+
// a demo recording showing a photograph invites the question "did that really come off the glasses?"
15+
// and a frame that answers it on its face cannot mislead anyone watching.
16+
17+
package com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.conversation
18+
19+
import com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.ConciergeClient
20+
import com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.fsm.TaskAttachment
21+
import com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.toTaskAttachment
22+
23+
object DummyCamera {
24+
25+
const val WIDTH = 1024
26+
const val HEIGHT = 768
27+
28+
/** What the frame depicts, so a scenario can check the answer against something knowable. */
29+
const val DEPICTS = "A red notebook and a coffee mug"
30+
31+
fun frame(): ByteArray =
32+
checkNotNull(DummyCamera::class.java.getResourceAsStream("/capture/simulated-capture.jpg")) {
33+
"missing /capture/simulated-capture.jpg — the simulated glasses frame"
34+
}
35+
.use { it.readBytes() }
36+
37+
/**
38+
* Take the frame, upload it, and hand back the attachment the next forward should carry.
39+
*
40+
* Same endpoint and same shape as the device's own path, including the width and height the
41+
* uploader adds afterwards — the server does not measure the image, so a client that omits them
42+
* leaves the agent guessing at the aspect ratio.
43+
*/
44+
suspend fun capture(baseUrl: String, bearerToken: String): TaskAttachment =
45+
ConciergeClient.uploadAttachment(baseUrl, bearerToken, frame(), "glasses.jpg")
46+
.put("width", WIDTH)
47+
.put("height", HEIGHT)
48+
.toTaskAttachment()
49+
}

meta-android-app/app/src/test/java/com/meta/wearable/dat/externalsampleapps/cameraaccess/saispike/conversation/PresenterPublisher.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,36 @@ class PresenterPublisher(url: String, private val paceMs: Long) {
6767
fun turn(role: String, text: String) =
6868
send(JSONObject().put("t", "turn").put("id", nextId++).put("role", role).put("text", text))
6969

70+
/**
71+
* Publish a turn the way a real one arrives: one id, growing a word at a time.
72+
*
73+
* A real call's transcript is a stream of deltas, so the presenter renders a line filling in as it
74+
* is spoken. Publishing a finished sentence in a single frame is the tell that a rig is driving it
75+
* — the words appear all at once, at machine speed, and no one is fooled. This paces at ordinary
76+
* speech (~150 wpm by default) so a recording of the harness looks like a recording of a call.
77+
*
78+
* Deliberately not pretending to be a perfect impression: the delta boundaries here are words,
79+
* whereas a real model streams whatever chunk the API hands over. What matters is the cadence.
80+
*/
81+
fun speak(role: String, text: String, wordsPerMinute: Int = 150) {
82+
val id = nextId++
83+
val words = text.split(' ').filter { it.isNotEmpty() }
84+
if (words.isEmpty()) return
85+
val perWord = (60_000L / wordsPerMinute).coerceAtLeast(1)
86+
val sb = StringBuilder()
87+
for ((i, w) in words.withIndex()) {
88+
if (i > 0) sb.append(' ')
89+
sb.append(w)
90+
ws?.send(JSONObject().put("t", "turn").put("id", id).put("role", role).put("text", sb.toString()).toString())
91+
// A touch longer after a comma or a full stop, which is where a speaker actually breathes.
92+
val pause = if (w.endsWith(",") || w.endsWith(".") || w.endsWith("?")) perWord * 2 else perWord
93+
Thread.sleep(pause)
94+
}
95+
}
96+
97+
/** A silence, for the gaps a conversation actually has. */
98+
fun pause(ms: Long) = Thread.sleep(ms)
99+
70100
fun log(text: String) =
71101
send(JSONObject().put("t", "log").put("id", nextId++).put("text", text))
72102

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/* sai-fi — voice concierge. TEMPORARY live verification of the finish-summary fix. */
2+
package com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.conversation
3+
4+
import com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.conversation.ScriptedBrain.Companion.whenNudged
5+
import com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.conversation.ScriptedBrain.Companion.whenSaid
6+
import com.meta.wearable.dat.externalsampleapps.cameraaccess.saispike.fsm.AgentEvent
7+
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.SupervisorJob
10+
import kotlinx.coroutines.runBlocking
11+
import kotlinx.coroutines.withTimeoutOrNull
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Assume.assumeTrue
14+
import org.junit.Test
15+
16+
class SummaryFixLiveTest {
17+
@Test
18+
fun `a real task that answers in text deltas is actually reported to the user`() {
19+
assumeTrue(System.getenv("SAI_LIVE_AGENT") == "1")
20+
val config = LiveAgentConfig.fromEnv().getOrElse { throw IllegalStateException(it.message, it) }
21+
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
22+
val task = "reply with exactly the words banana pancakes, do not use any tools"
23+
24+
val brain =
25+
ScriptedBrain.of(
26+
whenSaid("banana") { _, _ -> BrainTurn("on it", callsOf(fc("forwardToAgent", "text" to task))) },
27+
// Relay whatever the completion nudge carries — which, before the fix, was an
28+
// instruction to say nothing came back.
29+
whenNudged("[agent]") { input, _ -> BrainTurn("the agent says: $input") },
30+
)
31+
32+
runBlocking {
33+
val h = ConversationHarness(brain)
34+
val live = LiveAgent(config, scope, deliver = { h.deliverAgentEvent(it) }, log = { println(" $it") })
35+
h.useTransport(live)
36+
h.start()
37+
h.user(task)
38+
withTimeoutOrNull(120_000) { live.awaitTurn() }
39+
40+
val completes = live.received.filterIsInstance<AgentEvent.Complete>()
41+
println("\n=== events: ${live.received.map { it::class.simpleName }}")
42+
println("=== complete.summary: ${completes.map { it.summary }}")
43+
println("=== heard: ${h.heard()}")
44+
if (live.errors.isNotEmpty()) println("=== errors: ${live.errors}")
45+
46+
assertTrue("nothing was refused: ${live.errors}", live.errors.isEmpty())
47+
assertTrue("a completion arrived", completes.isNotEmpty())
48+
assertTrue(
49+
"THE FIX: the completion must carry the agent's answer, not an empty summary — got ${completes.map { it.summary }}",
50+
completes.any { !it.summary.isNullOrBlank() })
51+
assertTrue(
52+
"and the concierge must NOT have been told nothing came back: ${h.heard()}",
53+
!h.heard().contains("without reporting"))
54+
}
55+
}
56+
}
183 KB
Loading

0 commit comments

Comments
 (0)