Add browser-side voice tests to the owned gate

41efbdc7553a · Christopher David · · parent a82d9465c997

Add browser-side voice tests to the owned gate

Port the voice-state and recording suites, expose them through npm and Mix, and record the completed Gate 0 work in the hardening plan.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added assets/package.json
  • added assets/test/voice_recording_test.mjs
  • added assets/test/voice_state_test.mjs
  • modified docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • modified mix.exs

Diff

5 files changed, +363 -1

assets/package.json added +7

@@ -0,0 +1,7 @@

1
{
2
  "name": "openagents-assets",
3
  "private": true,
4
  "scripts": {
5
    "test": "node --test test/*.mjs"
6
  }
7
}
assets/test/voice_recording_test.mjs added +258

@@ -0,0 +1,258 @@

1
import assert from "node:assert/strict"
2
import {readFileSync} from "node:fs"
3
import test from "node:test"
4
5
import {
6
  CallRecorder,
7
  RECORDING_MIME_CANDIDATES,
8
  recordingMayStart,
9
  supportedRecordingMimeType,
10
} from "../js/voice_recording.mjs"
11
12
const supported = types => ({isTypeSupported: type => types.includes(type)})
13
14
// Fakes rather than mocks: the point is the state machine's ordering and its
15
// refusal to take a call down, neither of which needs a real AudioContext.
16
const fakeGraph = () => {
17
  const node = () => ({connect: () => {}})
18
19
  return class FakeAudioContext {
20
    constructor() {
21
      this.state = "running"
22
      this.closed = false
23
    }
24
25
    createMediaStreamDestination() {
26
      return {stream: "mixed-stream"}
27
    }
28
29
    createChannelMerger() {
30
      return node()
31
    }
32
33
    createMediaStreamSource() {
34
      return node()
35
    }
36
37
    close() {
38
      this.closed = true
39
      this.state = "closed"
40
    }
41
  }
42
}
43
44
// Mirrors the one MediaRecorder behavior the upload ordering depends on:
45
// stopping flushes a final `dataavailable` and only then reports `onstop`.
46
class FakeRecorder {
47
  constructor(stream, options) {
48
    this.stream = stream
49
    this.options = options
50
    this.state = "inactive"
51
    this.ondataavailable = null
52
    this.onstop = null
53
    this.onerror = null
54
    this.tailSize = 0
55
  }
56
57
  start(timeslice) {
58
    this.state = "recording"
59
    this.timeslice = timeslice
60
  }
61
62
  stop() {
63
    this.state = "inactive"
64
65
    setTimeout(() => {
66
      if (this.tailSize) this.emit(this.tailSize)
67
      if (this.onstop) this.onstop()
68
    }, 0)
69
  }
70
71
  emit(size) {
72
    if (this.ondataavailable) this.ondataavailable({data: {size}})
73
  }
74
}
75
76
const build = (overrides = {}) => {
77
  const uploads = []
78
  const finalizes = []
79
  let recorder = null
80
81
  const call = new CallRecorder({
82
    microphone: "mic-stream",
83
    remoteStream: "sarah-stream",
84
    generation: "3",
85
    timesliceMs: 5000,
86
    mimeType: "audio/webm;codecs=opus",
87
    audioContextClass: fakeGraph(),
88
    recorderClass: class extends FakeRecorder {
89
      constructor(...args) {
90
        super(...args)
91
        recorder = this
92
      }
93
    },
94
    upload: async options => {
95
      uploads.push(options)
96
      if (overrides.uploadFails) throw new Error("refused")
97
    },
98
    finalize: async options => finalizes.push(options),
99
    now: () => 1000,
100
    ...overrides,
101
  })
102
103
  return {call, uploads, finalizes, recorderRef: () => recorder}
104
}
105
106
test("candidate containers are ordered so Opus in WebM wins when available", () => {
107
  assert.equal(RECORDING_MIME_CANDIDATES[0], "audio/webm;codecs=opus")
108
  assert.equal(supportedRecordingMimeType(supported(["audio/mp4"])), "audio/mp4")
109
110
  assert.equal(
111
    supportedRecordingMimeType(supported(["audio/webm", "audio/webm;codecs=opus"])),
112
    "audio/webm;codecs=opus",
113
  )
114
})
115
116
test("a browser without MediaRecorder yields no container rather than throwing", () => {
117
  assert.equal(supportedRecordingMimeType(undefined), null)
118
  assert.equal(supportedRecordingMimeType({}), null)
119
})
120
121
test("capture requires both audio sources, a container, and a fenced generation", () => {
122
  const ready = {
123
    enabled: true,
124
    mimeType: "audio/webm;codecs=opus",
125
    hasMicrophone: true,
126
    hasRemoteStream: true,
127
    generation: "7",
128
    alreadyStarted: false,
129
  }
130
131
  assert.equal(recordingMayStart(ready), true)
132
133
  for (const missing of [
134
    {...ready, enabled: false},
135
    {...ready, mimeType: null},
136
    {...ready, hasMicrophone: false},
137
    {...ready, hasRemoteStream: false},
138
    {...ready, generation: null},
139
    {...ready, alreadyStarted: true},
140
  ]) {
141
    assert.equal(recordingMayStart(missing), false)
142
  }
143
})
144
145
test("slices upload in order and only advance the sequence on success", async () => {
146
  const {call, uploads, recorderRef} = build()
147
148
  assert.equal(call.start(), true)
149
  assert.equal(call.capturing, true)
150
151
  recorderRef().emit(120)
152
  recorderRef().emit(240)
153
  await call.queue
154
155
  assert.deepEqual(
156
    uploads.map(upload => upload.sequence),
157
    [1, 2],
158
  )
159
  assert.equal(call.sequence, 2)
160
})
161
162
test("empty slices are dropped so an idle timeslice cannot burn a sequence number", async () => {
163
  const {call, uploads, recorderRef} = build()
164
  call.start()
165
166
  recorderRef().emit(0)
167
  await call.queue
168
169
  assert.deepEqual(uploads, [])
170
  assert.equal(call.sequence, 0)
171
})
172
173
test("a refused upload stops capture, finalizes as failed, and closes the graph", async () => {
174
  const {call, finalizes, recorderRef} = build({uploadFails: true})
175
  call.start()
176
  const context = call.context
177
178
  recorderRef().emit(64)
179
  await call.queue
180
  // The failure path finalizes on its own; give its promise chain a turn.
181
  await new Promise(resolve => setTimeout(resolve, 0))
182
183
  assert.equal(call.state, "failed")
184
  assert.equal(call.capturing, false)
185
  assert.equal(context.closed, true)
186
  assert.deepEqual(
187
    finalizes.map(finalize => finalize.status),
188
    ["failed"],
189
  )
190
})
191
192
test("a clean stop commits the flushed tail slice before finalizing", async () => {
193
  let clock = 1000
194
  const {call, uploads, finalizes, recorderRef} = build({now: () => clock})
195
  call.start()
196
197
  recorderRef().emit(32)
198
  // The slice MediaRecorder flushes on stop is the end of the conversation; a
199
  // finalize that raced ahead of it would close the recording one slice short.
200
  recorderRef().tailSize = 48
201
  clock = 8500
202
  await call.stop()
203
204
  assert.deepEqual(
205
    uploads.map(upload => upload.sequence),
206
    [1, 2],
207
  )
208
  assert.deepEqual(finalizes, [{status: "complete", generation: "3", durationMs: 7500}])
209
  assert.equal(call.state, "complete")
210
})
211
212
test("a finalize that never lands does not throw at the caller", async () => {
213
  const {call} = build({
214
    finalize: async () => {
215
      throw new Error("offline")
216
    },
217
  })
218
219
  call.start()
220
  await call.stop()
221
222
  assert.equal(call.state, "complete")
223
})
224
225
test("a broken audio graph leaves the call alone instead of raising", () => {
226
  const {call, finalizes} = build({
227
    audioContextClass: class {
228
      constructor() {
229
        throw new Error("AudioContext blocked")
230
      }
231
    },
232
  })
233
234
  assert.equal(call.start(), false)
235
  assert.equal(call.capturing, false)
236
  assert.deepEqual(finalizes, [])
237
})
238
239
test("the hook uploads audio with its generation and sequence, never a client session id", () => {
240
  const source = readFileSync(new URL("../js/voice_controller.js", import.meta.url), "utf8")
241
242
  assert.match(source, /["']x-voice-generation["']/)
243
  assert.match(source, /["']x-voice-recording-sequence["']/)
244
  assert.doesNotMatch(source, /voice_session_id/)
245
})
246
247
test("a remote track that outruns the server generation is retried from updated()", () => {
248
  const source = readFileSync(new URL("../js/voice_controller.js", import.meta.url), "utf8")
249
250
  // The track event fires during setRemoteDescription, before the LiveView
251
  // diff delivers data-server-generation, so the hook must keep the stream
252
  // and attempt recording again on every update.
253
  assert.match(source, /this\.remoteRecordingStream = event\.streams\[0\]/)
254
  assert.match(
255
    source,
256
    /this\.reflectServerActivity\(\)\s*\n\s*this\.startRecording\(this\.remoteRecordingStream\)/,
257
  )
258
})
assets/test/voice_state_test.mjs added +71

@@ -0,0 +1,71 @@

1
import assert from "node:assert/strict"
2
import {readFileSync} from "node:fs"
3
import test from "node:test"
4
5
import {
6
  ACTIVE_SERVER_STATES,
7
  admissionErrorMessage,
8
  closeVoiceResources,
9
  microphoneErrorMessage,
10
  microphoneMayTransmit,
11
} from "../js/voice_state.mjs"
12
13
test("the browser admission request does not negotiate through Phoenix's HTML accept gate", () => {
14
  const source = readFileSync(new URL("../js/voice_controller.js", import.meta.url), "utf8")
15
16
  assert.doesNotMatch(source, /["']accept["']\s*:\s*["']application\/sdp["']/)
17
  assert.match(source, /["']content-type["']\s*:\s*["']application\/sdp["']/)
18
})
19
20
test("microphone transmits only after browser and fenced server readiness", () => {
21
  const ready = {
22
    localPhase: "ready",
23
    serverStatus: "listening",
24
    peerConnectionState: "connected",
25
    channelState: "open",
26
    userMuted: false,
27
    playbackBlocked: false,
28
  }
29
30
  assert.equal(microphoneMayTransmit(ready), true)
31
32
  for (const unsafe of [
33
    {...ready, serverStatus: "connecting"},
34
    {...ready, serverStatus: "reconnecting"},
35
    {...ready, peerConnectionState: "disconnected"},
36
    {...ready, channelState: "closed"},
37
    {...ready, userMuted: true},
38
    {...ready, playbackBlocked: true},
39
  ]) {
40
    assert.equal(microphoneMayTransmit(unsafe), false)
41
  }
42
43
  assert.equal(ACTIVE_SERVER_STATES.has("reconnecting"), true)
44
  assert.equal(ACTIVE_SERVER_STATES.has("ended"), false)
45
})
46
47
test("permission, device, rate, and text conflict failures stay actionable", () => {
48
  assert.match(microphoneErrorMessage({name: "NotAllowedError"}), /ACCESS DENIED/)
49
  assert.match(microphoneErrorMessage({name: "NotFoundError"}), /NO MICROPHONE/)
50
  assert.match(admissionErrorMessage(429, "voice_rate_limited"), /WAIT A MINUTE/)
51
  assert.match(admissionErrorMessage(409, "text_turn_in_progress"), /TEXT RESPONSE/)
52
  assert.match(admissionErrorMessage(503, "voice_unavailable"), /TYPED CHAT/)
53
})
54
55
test("cleanup closes every media resource and detaches remote audio", () => {
56
  const calls = []
57
  const channel = {close: () => calls.push("channel")}
58
  const peer = {close: () => calls.push("peer")}
59
  const media = {
60
    getTracks: () => [
61
      {stop: () => calls.push("track-one")},
62
      {stop: () => calls.push("track-two")},
63
    ],
64
  }
65
  const audio = {srcObject: {id: "remote-stream"}}
66
67
  closeVoiceResources({channel, peer, media, audio})
68
69
  assert.deepEqual(calls, ["channel", "peer", "track-one", "track-two"])
70
  assert.equal(audio.srcObject, null)
71
})
docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +19

@@ -80,6 +80,20 @@ that result as historical evidence, but rerun the complete gate for each

80 80
candidate. Gate 0 remains blocked until the missing JavaScript suite exists and
81 81
runs.
82 82
83
### Gate 0 implementation status
84
85
Completed on 2026-08-20:
86
87
- Added Node suites for the voice admission state, media-resource cleanup,
88
  recording admission, upload ordering, finalization, failure containment, and
89
  generation fencing.
90
- Added `npm test` in `assets/package.json` and the `mix assets.test` alias.
91
- Added `mix assets.test` to `mix precommit`, so the standard repository gate
92
  fails when browser-side voice behavior regresses.
93
94
Gate 0 still requires the merged default and cluster coverage report, release
95
startup proof against a disposable database, and an exact-SHA gate receipt.
96
83 97
Do not use the current green suite as evidence for untested code. The updated
84 98
coverage audit records strong Issues and Projects coverage and the defects it
85 99
found. Recovery workers and release-only entry points still need direct

@@ -1054,6 +1068,11 @@ no JavaScript tests in this repository.** `assets/` contains no test files and

1054 1068
in the port. That is a porting gap, not a step someone forgot to run. Gate 0 now
1055 1069
names creation of the missing suite as blocking work.
1056 1070
1071
**Resolved after this measurement:** `assets/test/voice_state_test.mjs` and
1072
`assets/test/voice_recording_test.mjs` now cover these behaviors,
1073
`assets/package.json` provides `npm test`, and `mix precommit` runs the suite
1074
through `mix assets.test`.
1075
1057 1076
## A2. Blocker: staging is not isolated from production today
1058 1077
1059 1078
Gate 12 lists staging isolation as a requirement and Gate 15 calls for failure
mix.exs modified +8 -1

@@ -102,12 +102,19 @@ defmodule OpenAgents.MixProject do

102 102
      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
103 103
      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
104 104
      "assets.build": ["compile", "tailwind openagents", "esbuild openagents"],
105
      "assets.test": ["cmd --cd assets npm test"],
105 106
      "assets.deploy": [
106 107
        "tailwind openagents --minify",
107 108
        "esbuild openagents --minify",
108 109
        "phx.digest"
109 110
      ],
110
      precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
111
      precommit: [
112
        "compile --warnings-as-errors",
113
        "deps.unlock --unused",
114
        "format",
115
        "assets.test",
116
        "test"
117
      ]
111 118
    ]
112 119
  end
113 120
end

This page updates live while a promote is in flight · changelog