@specific.dev/spectest 0.130.0 → 0.131.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -478,8 +478,11 @@ time, and ends when that step completes. Both `call(destination, { waitFor })`
478
478
  and `say(text, { waitFor })` poll application state, then wait for incoming
479
479
  silence. For `call`, polling starts after the call connects, allowing the greeting
480
480
  to finish within the call step. For `say`, polling starts after outgoing playback.
481
- Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The quiet
482
- period (700 ms), poll interval (100 ms), and wait timeout (30 seconds) are
481
+ Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The
482
+ wait has no timeout of its own: the test case's `timeoutMs` is the only ceiling,
483
+ and a bound on the reply belongs inside the predicate (for example a `ctx.poll`
484
+ with its own `timeoutMs`), where it appears in the timeline. The quiet
485
+ period (700 ms) and poll interval (100 ms) are
483
486
  internal defaults for now. `waitFor` may return a nonempty transcript string
484
487
  instead of `true`; after the silence wait, it appears as **Response** below **Caller** in an indented chat row beneath the waveform. Return `false` or `""` to keep waiting. Your test supplies the text
485
488
  from application state or a fake; an async callback can wait and then return it.
@@ -14,7 +14,7 @@ import time
14
14
  import wave
15
15
  import math
16
16
  from array import array
17
- from phone_recording import StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
17
+ from phone_recording import RecordingError, StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
18
18
 
19
19
  # PJSIP writes directly to fd 1. Keep the protocol on a private duplicate.
20
20
  protocol = os.fdopen(os.dup(1), "w", buffering=1)
@@ -179,22 +179,29 @@ class Call(pj.Call):
179
179
  result = {"event": "recording", "sessionId": self.session_id,
180
180
  "startedAtMs": self.started_at_ms, "duration": 0, "bytes": 0,
181
181
  "contentType": "audio/webm", "peaks": []}
182
+ errors = [self.capture_error] if self.capture_error else []
182
183
  try:
183
184
  self.clock.stopTransmit(self.recorder)
184
- if self.media is not None:
185
- try:
186
- self.media.stopTransmit(self.recorder)
187
- self.media.stopTransmit(self.recipient_meter)
188
- except Exception:
189
- pass # The remote peer may already have removed its port.
185
+ except Exception as error:
186
+ # Native error text can contain SIP identities or credentials.
187
+ errors.append(f"Could not disconnect recording clock ({type(error).__name__})")
188
+ if self.media is not None:
189
+ try:
190
+ self.media.stopTransmit(self.recorder)
191
+ self.media.stopTransmit(self.recipient_meter)
192
+ except Exception:
193
+ pass # The remote peer may already have removed its port.
194
+ try:
195
+ # Always close the sink and reap the encoder, even if detach failed.
190
196
  result.update(self.recorder.finish())
191
197
  peaks = result["peaks"]
192
198
  for side, track in self.recorder.tracks.items():
193
199
  result[side] = (track + [0.0] * max(0, len(peaks) - len(track)))[:len(peaks)]
194
- if self.capture_error:
195
- result["error"] = self.capture_error
196
- except Exception:
197
- result["error"] = "Call recording could not be finalized"
200
+ except Exception as error:
201
+ detail = str(error) if isinstance(error, RecordingError) else type(error).__name__
202
+ errors.append(f"Call recording could not be finalized: {detail}")
203
+ if errors:
204
+ result["error"] = "; ".join(errors)
198
205
  protocol.write(json.dumps(result) + "\n")
199
206
 
200
207
 
@@ -271,12 +278,14 @@ def main():
271
278
  else:
272
279
  time.sleep(0.01)
273
280
 
274
- def wait(predicate, timeout, live=False):
275
- deadline = time.monotonic() + timeout
281
+ def wait(predicate, timeout=None, live=False):
282
+ # No timeout: run until satisfied, the call ends, or the SDK closes
283
+ # stdin (which happens at case timeout and cleanup).
284
+ deadline = None if timeout is None else time.monotonic() + timeout
276
285
  while not predicate():
277
286
  if live and call.disconnected:
278
287
  raise ValueError(f"SIP call ended ({call.status})")
279
- if time.monotonic() >= deadline:
288
+ if deadline is not None and time.monotonic() >= deadline:
280
289
  raise ValueError("SIP operation timed out")
281
290
  pump()
282
291
 
@@ -374,14 +383,14 @@ def main():
374
383
  if not call or call.disconnected or call.media is None:
375
384
  raise ValueError("No connected call; cannot wait for silence")
376
385
  silence = float(data["silenceMs"]) / 1000
377
- timeout = float(data["timeoutMs"]) / 1000
378
- if not math.isfinite(silence) or not math.isfinite(timeout) or silence <= 0 or timeout <= 0:
379
- raise ValueError("Silence duration and timeout must be positive")
386
+ if not math.isfinite(silence) or silence <= 0:
387
+ raise ValueError("Silence duration must be positive")
380
388
  # A fresh quiet window starts AFTER the application predicate
381
389
  # succeeds; quiet heard before that does not satisfy this wait.
390
+ # The wait is bounded only by the test case's own timeout.
382
391
  began = time.monotonic()
383
392
  wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
384
- timeout, live=True)
393
+ live=True)
385
394
  result["offset"] = call.recorder.offset
386
395
  elif action == "position":
387
396
  if not call:
@@ -10,6 +10,10 @@ MEDIA_RATE = 48000
10
10
  PEAK_SAMPLES = MEDIA_RATE // 20
11
11
 
12
12
 
13
+ class RecordingError(RuntimeError):
14
+ """SDK-authored capture diagnostics, safe to include in test results."""
15
+
16
+
13
17
  class StreamingRecording:
14
18
  def __init__(self, path):
15
19
  self.path = path
@@ -85,7 +89,7 @@ class StreamingRecording:
85
89
  self.encoder.kill()
86
90
  self.writer.join(timeout=1)
87
91
  if self.error:
88
- raise RuntimeError(self.error)
92
+ raise RecordingError(self.error)
89
93
  return {
90
94
  "duration": self.frames / MEDIA_RATE,
91
95
  "bytes": os.path.getsize(self.path),
package/dist/phone.js CHANGED
@@ -116,7 +116,9 @@ class PhoneSession {
116
116
  return Promise.reject(new Error("Phone is closed"));
117
117
  const id = ++this.nextId;
118
118
  return new Promise((resolve, reject) => {
119
- const timer = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
119
+ const timer = Number.isFinite(timeout)
120
+ ? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
121
+ : undefined;
120
122
  this.pending.set(id, { resolve, reject, timer, position });
121
123
  this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
122
124
  });
@@ -128,15 +130,15 @@ class PhoneSession {
128
130
  await this.request("open", { ...this.options, directory: this.directory }, 60_000);
129
131
  return this;
130
132
  }
133
+ // The predicate and the silence wait have no timeout of their own: a bound on
134
+ // the reply belongs inside the predicate (a poll with its own timeout), and the
135
+ // case timeout closes the phone, which aborts this wait.
131
136
  async waitForResponse(options) {
132
- const timeout = 30_000;
133
- const deadline = performance.now() + timeout;
134
137
  const controller = new AbortController();
135
138
  const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
136
139
  this.cancellation.signal.addEventListener("abort", cancel, { once: true });
137
140
  if (this.closed)
138
141
  cancel();
139
- const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
140
142
  const aborted = new Promise((_, reject) => {
141
143
  const rejectAbort = () => reject(controller.signal.reason);
142
144
  controller.signal.addEventListener("abort", rejectAbort, { once: true });
@@ -163,13 +165,11 @@ class PhoneSession {
163
165
  }
164
166
  }
165
167
  controller.signal.throwIfAborted();
166
- const remaining = Math.max(1, deadline - performance.now());
167
- const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
168
+ const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
168
169
  return { ...reply, response };
169
170
  })]);
170
171
  }
171
172
  finally {
172
- clearTimeout(timer);
173
173
  this.cancellation.signal.removeEventListener("abort", cancel);
174
174
  controller.abort();
175
175
  branch.finish(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.130.0",
3
+ "version": "0.131.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,7 @@ import time
14
14
  import wave
15
15
  import math
16
16
  from array import array
17
- from phone_recording import StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
17
+ from phone_recording import RecordingError, StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
18
18
 
19
19
  # PJSIP writes directly to fd 1. Keep the protocol on a private duplicate.
20
20
  protocol = os.fdopen(os.dup(1), "w", buffering=1)
@@ -179,22 +179,29 @@ class Call(pj.Call):
179
179
  result = {"event": "recording", "sessionId": self.session_id,
180
180
  "startedAtMs": self.started_at_ms, "duration": 0, "bytes": 0,
181
181
  "contentType": "audio/webm", "peaks": []}
182
+ errors = [self.capture_error] if self.capture_error else []
182
183
  try:
183
184
  self.clock.stopTransmit(self.recorder)
184
- if self.media is not None:
185
- try:
186
- self.media.stopTransmit(self.recorder)
187
- self.media.stopTransmit(self.recipient_meter)
188
- except Exception:
189
- pass # The remote peer may already have removed its port.
185
+ except Exception as error:
186
+ # Native error text can contain SIP identities or credentials.
187
+ errors.append(f"Could not disconnect recording clock ({type(error).__name__})")
188
+ if self.media is not None:
189
+ try:
190
+ self.media.stopTransmit(self.recorder)
191
+ self.media.stopTransmit(self.recipient_meter)
192
+ except Exception:
193
+ pass # The remote peer may already have removed its port.
194
+ try:
195
+ # Always close the sink and reap the encoder, even if detach failed.
190
196
  result.update(self.recorder.finish())
191
197
  peaks = result["peaks"]
192
198
  for side, track in self.recorder.tracks.items():
193
199
  result[side] = (track + [0.0] * max(0, len(peaks) - len(track)))[:len(peaks)]
194
- if self.capture_error:
195
- result["error"] = self.capture_error
196
- except Exception:
197
- result["error"] = "Call recording could not be finalized"
200
+ except Exception as error:
201
+ detail = str(error) if isinstance(error, RecordingError) else type(error).__name__
202
+ errors.append(f"Call recording could not be finalized: {detail}")
203
+ if errors:
204
+ result["error"] = "; ".join(errors)
198
205
  protocol.write(json.dumps(result) + "\n")
199
206
 
200
207
 
@@ -271,12 +278,14 @@ def main():
271
278
  else:
272
279
  time.sleep(0.01)
273
280
 
274
- def wait(predicate, timeout, live=False):
275
- deadline = time.monotonic() + timeout
281
+ def wait(predicate, timeout=None, live=False):
282
+ # No timeout: run until satisfied, the call ends, or the SDK closes
283
+ # stdin (which happens at case timeout and cleanup).
284
+ deadline = None if timeout is None else time.monotonic() + timeout
276
285
  while not predicate():
277
286
  if live and call.disconnected:
278
287
  raise ValueError(f"SIP call ended ({call.status})")
279
- if time.monotonic() >= deadline:
288
+ if deadline is not None and time.monotonic() >= deadline:
280
289
  raise ValueError("SIP operation timed out")
281
290
  pump()
282
291
 
@@ -374,14 +383,14 @@ def main():
374
383
  if not call or call.disconnected or call.media is None:
375
384
  raise ValueError("No connected call; cannot wait for silence")
376
385
  silence = float(data["silenceMs"]) / 1000
377
- timeout = float(data["timeoutMs"]) / 1000
378
- if not math.isfinite(silence) or not math.isfinite(timeout) or silence <= 0 or timeout <= 0:
379
- raise ValueError("Silence duration and timeout must be positive")
386
+ if not math.isfinite(silence) or silence <= 0:
387
+ raise ValueError("Silence duration must be positive")
380
388
  # A fresh quiet window starts AFTER the application predicate
381
389
  # succeeds; quiet heard before that does not satisfy this wait.
390
+ # The wait is bounded only by the test case's own timeout.
382
391
  began = time.monotonic()
383
392
  wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
384
- timeout, live=True)
393
+ live=True)
385
394
  result["offset"] = call.recorder.offset
386
395
  elif action == "position":
387
396
  if not call:
@@ -10,6 +10,10 @@ MEDIA_RATE = 48000
10
10
  PEAK_SAMPLES = MEDIA_RATE // 20
11
11
 
12
12
 
13
+ class RecordingError(RuntimeError):
14
+ """SDK-authored capture diagnostics, safe to include in test results."""
15
+
16
+
13
17
  class StreamingRecording:
14
18
  def __init__(self, path):
15
19
  self.path = path
@@ -85,7 +89,7 @@ class StreamingRecording:
85
89
  self.encoder.kill()
86
90
  self.writer.join(timeout=1)
87
91
  if self.error:
88
- raise RuntimeError(self.error)
92
+ raise RecordingError(self.error)
89
93
  return {
90
94
  "duration": self.frames / MEDIA_RATE,
91
95
  "bytes": os.path.getsize(self.path),
package/src/phone.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { afterAll, afterEach, beforeAll, expect, spyOn, test } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
2
2
  import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -16,7 +16,7 @@ beforeAll(async () => {
16
16
  runtime,
17
17
  `#!/usr/bin/env bun
18
18
  import { createInterface } from "node:readline";
19
- let connected = false, directory, sessionId, started, clockRate = 1;
19
+ let connected = false, directory, sessionId, started, recordingError, clockRate = 1;
20
20
  const offset = () => (Date.now() - started) / 1000 * clockRate;
21
21
  function finish() {
22
22
  if (!sessionId) return;
@@ -24,7 +24,7 @@ function finish() {
24
24
  const bytes = Buffer.alloc(128);
25
25
  bytes.set([0x1a,0x45,0xdf,0xa3]);
26
26
  require("node:fs").writeFileSync(directory + "/" + sessionId + ".webm", bytes);
27
- console.log(JSON.stringify({event:"recording", sessionId, startedAtMs:started, duration, contentType:"audio/webm", encodingFinalizeMs:1, bytes:bytes.length, peaks:[.2,.8], callerPeaks:[.2,0], recipientPeaks:[0,.8]}));
27
+ console.log(JSON.stringify({event:"recording", sessionId, startedAtMs:started, duration, contentType:"audio/webm", encodingFinalizeMs:1, bytes:bytes.length, peaks:[.2,.8], callerPeaks:[.2,0], recipientPeaks:[0,.8], error:recordingError}));
28
28
  sessionId = undefined;
29
29
  }
30
30
  createInterface({ input: process.stdin }).on("line", async line => {
@@ -34,6 +34,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
34
34
  if (data.action === "open") directory = data.directory;
35
35
  if (data.action === "call") {
36
36
  sessionId = data.sessionId; started = Date.now();
37
+ recordingError = data.destination === "recording-failure"
38
+ ? "Call recording could not be finalized: Phone recording encoder could not keep up with audio"
39
+ : undefined;
37
40
  clockRate = data.destination === "clock-drift" ? 0.25 : 1;
38
41
  console.log(JSON.stringify({event:"operation_started", id:data.id, offset:0}));
39
42
  if (data.destination === "timeout") return;
@@ -55,10 +58,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
55
58
  }
56
59
  }
57
60
  if (data.action === "wait_silence") {
58
- await Bun.sleep(Math.min(data.silenceMs, data.timeoutMs));
59
- result = data.silenceMs > data.timeoutMs
60
- ? {error:"SIP operation timed out"}
61
- : {offset:offset()};
61
+ if (data.timeoutMs !== undefined) throw new Error("wait_silence must not carry a timeout");
62
+ await Bun.sleep(data.silenceMs);
63
+ result = {offset:offset()};
62
64
  }
63
65
  if (data.action === "position") result = {offset:offset()};
64
66
  console.log(JSON.stringify({ id: data.id, ...result }));
@@ -363,17 +365,17 @@ test("waits for the predicate then a fresh silence window inside one serialized
363
365
  expect(recording!.duration - b.end).toBeGreaterThanOrEqual(.04);
364
366
  });
365
367
 
366
- test("predicate errors and hung predicates fail the say step without waiting for silence", async () => {
368
+ test("predicate errors fail the say step without waiting for silence, and a hung predicate waits until the phone closes", async () => {
367
369
  startRecording();
368
370
  const phone = await scope.open(options);
369
371
  await phone.call("123");
370
372
  await expect(phone.say("Throw", { waitFor: () => { throw new Error("Application failed"); } })).rejects.toThrow("Application failed");
371
- const nativeTimeout = globalThis.setTimeout;
372
- const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn: any, ms: number, ...args: any[]) =>
373
- nativeTimeout(fn, ms === 30_000 ? 40 : ms, ...args)) as typeof setTimeout);
374
- try {
375
- await expect(phone.say("Hang", { waitFor: () => new Promise(() => {}) })).rejects.toThrow("response wait timed out");
376
- } finally { timer.mockRestore(); }
373
+ // No response-wait timeout of its own: only closing the phone (case timeout
374
+ // or cleanup) ends a predicate that never returns.
375
+ const hung = phone.say("Hang", { waitFor: () => new Promise(() => {}) }).catch(error => error);
376
+ await Bun.sleep(200);
377
+ await phone.close();
378
+ expect(String(await hung)).toContain("closed");
377
379
  await scope.finish();
378
380
  const events = stopRecording() as any[];
379
381
  expect(events.slice(1).map(e => e.status)).toEqual(["failed", "failed"]);
@@ -413,10 +415,10 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
413
415
  await writeFile(join(root, "spectest/index.ts"), `
414
416
  import { defineEnvironment } from ${JSON.stringify(sdk)};
415
417
  const env = defineEnvironment({ name: "phone-integration", services: {} });
416
- const cases = ["success", "failure", "timeout"].map(outcome => env.test(outcome,
418
+ const cases = ["success", "failure", "timeout", "recording-failure"].map(outcome => env.test(outcome,
417
419
  { timeoutMs: outcome === "timeout" ? 350 : 2000 }, async ctx => {
418
420
  const phone = await ctx.phone(${JSON.stringify(options)});
419
- await phone.call("123");
421
+ await phone.call(outcome === "recording-failure" ? outcome : "123");
420
422
  await phone.say("One. Two.");
421
423
  await new Promise(r => setTimeout(r, 30));
422
424
  if (outcome === "failure") throw new Error("application assertion failed");
@@ -429,7 +431,7 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
429
431
  import { strict as assert } from "node:assert";
430
432
  import { METHODS } from ${JSON.stringify(daemon)};
431
433
  const loaded = await METHODS.load({});
432
- assert.equal(loaded.cases.length, 3);
434
+ assert.equal(loaded.cases.length, 4);
433
435
  for (const [index, test] of loaded.cases.entries()) {
434
436
  const result = await METHODS.run({caseId:test.id});
435
437
  assert.equal(result.status, index === 0 ? "passed" : "failed", JSON.stringify(result));
@@ -437,6 +439,13 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
437
439
  if (index === 2) assert.match(result.error.message, /timed out/);
438
440
  assert.equal(result.phoneRecordings.length, 1);
439
441
  const recording = result.phoneRecordings[0];
442
+ if (index === 3) {
443
+ const message = "Call recording could not be finalized: Phone recording encoder could not keep up with audio";
444
+ assert.equal(recording.error, message);
445
+ assert.equal(result.error.message, message);
446
+ await assert.rejects(METHODS.phoneRecordingChunk({caseId:recording.id}), /Unknown/);
447
+ continue;
448
+ }
440
449
  assert.equal(recording.error, undefined);
441
450
  assert.ok(recording.duration > 0);
442
451
  assert.deepEqual(recording.callerPeaks, [.2,0]);
package/src/phone.ts CHANGED
@@ -83,7 +83,7 @@ export async function releasePhoneRecording(id: string) {
83
83
  }
84
84
 
85
85
  class PhoneSession implements Phone {
86
- private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; position?: (event: string, offset: number) => void }>();
86
+ private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer?: ReturnType<typeof setTimeout>; position?: (event: string, offset: number) => void }>();
87
87
  private nextId = 0;
88
88
  private closed = false;
89
89
  private failure?: Error;
@@ -138,7 +138,9 @@ class PhoneSession implements Phone {
138
138
  if (this.closed) return Promise.reject(new Error("Phone is closed"));
139
139
  const id = ++this.nextId;
140
140
  return new Promise((resolve, reject) => {
141
- const timer = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
141
+ const timer = Number.isFinite(timeout)
142
+ ? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
143
+ : undefined;
142
144
  this.pending.set(id, { resolve, reject, timer, position });
143
145
  this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
144
146
  });
@@ -149,14 +151,14 @@ class PhoneSession implements Phone {
149
151
  await this.request("open", { ...this.options, directory: this.directory }, 60_000);
150
152
  return this;
151
153
  }
154
+ // The predicate and the silence wait have no timeout of their own: a bound on
155
+ // the reply belongs inside the predicate (a poll with its own timeout), and the
156
+ // case timeout closes the phone, which aborts this wait.
152
157
  private async waitForResponse(options: CallOptions): Promise<Reply & { response?: string }> {
153
- const timeout = 30_000;
154
- const deadline = performance.now() + timeout;
155
158
  const controller = new AbortController();
156
159
  const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
157
160
  this.cancellation.signal.addEventListener("abort", cancel, { once: true });
158
161
  if (this.closed) cancel();
159
- const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
160
162
  const aborted = new Promise<never>((_, reject) => {
161
163
  const rejectAbort = () => reject(controller.signal.reason);
162
164
  controller.signal.addEventListener("abort", rejectAbort, { once: true });
@@ -181,12 +183,10 @@ class PhoneSession implements Phone {
181
183
  }
182
184
  }
183
185
  controller.signal.throwIfAborted();
184
- const remaining = Math.max(1, deadline - performance.now());
185
- const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
186
+ const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
186
187
  return { ...reply, response };
187
188
  })]);
188
189
  } finally {
189
- clearTimeout(timer);
190
190
  this.cancellation.signal.removeEventListener("abort", cancel);
191
191
  controller.abort();
192
192
  branch.finish(false);