@inetafrica/open-claudia 3.1.14 → 3.1.17
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/CHANGELOG.md +20 -0
- package/channels/kazee/call/session.js +28 -5
- package/core/runner.js +18 -5
- package/core/system-prompt.js +20 -1
- package/core/voice-policy.js +46 -1
- package/package.json +1 -1
- package/test-kazee-call-session.js +28 -0
- package/test-provider-enforcer.js +8 -4
- package/test-provider-language.js +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.1.17 — Call bridge joins through the caller's room-setup race (fixes "Call declined")
|
|
4
|
+
|
|
5
|
+
- Answering a Kazee web call could fail deterministically fast: the caller creates the Janus videoroom during its own dial-time setup, and the bot answers quicker than any human — its `join` hit Janus before the room existed, got 426 "No such room", and the whole call was rejected as `connection_failed` (surfacing as "Call declined" in the web UI).
|
|
6
|
+
- `session.start()` now polls the publisher join through transient 426 (10 attempts × 500ms), mirroring the web receiver's own retry loop in `CallContext.startVideoRoom`. Persistent 426 still fails the call.
|
|
7
|
+
- Janus error frames now carry `error.code` alongside the message for future discrimination.
|
|
8
|
+
- New assertions: join retries through transient no-such-room and still gives up when the room never appears.
|
|
9
|
+
|
|
10
|
+
## v3.1.16 — CI-deflake: no live network in unit tests, CI-safe enforcer deadlines
|
|
11
|
+
|
|
12
|
+
- Identical bot behaviour to v3.1.15 (which never shipped: its pipeline died twice on loaded-runner test flakes — the tag is retired unretagged per release hygiene).
|
|
13
|
+
- `test-telegram-conflict-heal-guard.js` stubbed `_probeTelegramReachable`/`_anyTurnActive`: the wedge-exit assertion used to probe the real api.telegram.org, so a slow/blocked CI egress sent `_heal` down the outage-pause path instead of the exit under test. It also now isolates `OPEN_CLAUDIA_CONFIG_DIR` — the exercised exit path was writing a bogus "polling wedged" `last-exit.json` into the real `~/.open-claudia` on every local test run.
|
|
14
|
+
- `test-provider-enforcer.js` deadlines made CI-safe: the judged-probe service backstop 2000→8000ms (children answer in ms; 2000 was missed by child boot under runner load), the deliberate-timeout probe 2500→5000ms boot headroom.
|
|
15
|
+
|
|
16
|
+
## v3.1.15 — Voice-note replies speak a conversational summary, not the chat text
|
|
17
|
+
|
|
18
|
+
- Replying to a voice note used to TTS the full formatted chat answer verbatim — bullets, emoji, pipeline IDs and all ("this isn't human hearable"). On voice-reply turns the provider is now asked to end its reply with a dedicated spoken script in `<voice-script>` markers: the chat message keeps the full text answer (script stripped), and only the script — two to four conversational sentences written for the ear — is synthesized into the voice note.
|
|
19
|
+
- If the provider omits the script, a sanitizing fallback (`speechFallback`) strips code blocks, HTML, URLs, emoji, and list markers, caps the speech at the opening sentences, and closes with "The rest is in the text message." — a verbatim readout can no longer reach TTS on text channels.
|
|
20
|
+
- The Kazee call bridge keeps its existing in-call guidance and streams `voiceScript || finalText`, so stray markers are never spoken there either.
|
|
21
|
+
- New extraction/fallback/wiring assertions in `test-media-speech.js`.
|
|
22
|
+
|
|
3
23
|
## v3.1.14 — Call bridge production hardening: serialized speech, barge-in, group attribution, limits
|
|
4
24
|
|
|
5
25
|
- **TTS is serialized through a queue with cancellation.** Concurrent `speak()` calls used to interleave two 20ms-frame RTP loops over the same sequence/timestamp counters — garbled audio whenever the provider emitted more than one message. Utterances now play strictly one at a time; `cancelSpeech()` (generation-based) stops the current utterance mid-frame and resolves everything queued, and `close()` cancels so no pending `speak()` can hang the adapter.
|
|
@@ -15,6 +15,8 @@ const PREBUFFER_FRAMES = 12; // 240ms of pre-onset audio so first syllables reac
|
|
|
15
15
|
const LIMIT_CHECK_MS = 10_000;
|
|
16
16
|
const DEFAULT_MAX_CALL_MS = 30 * 60_000;
|
|
17
17
|
const DEFAULT_IDLE_MS = 2 * 60_000;
|
|
18
|
+
const DEFAULT_JOIN_RETRIES = 10;
|
|
19
|
+
const DEFAULT_JOIN_RETRY_DELAY_MS = 500;
|
|
18
20
|
const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
|
|
19
21
|
const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
|
|
20
22
|
|
|
@@ -35,6 +37,8 @@ class KazeeCallSession {
|
|
|
35
37
|
this.synthesize = synthesize;
|
|
36
38
|
this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
|
|
37
39
|
this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
|
|
40
|
+
this.joinRetries = DEFAULT_JOIN_RETRIES;
|
|
41
|
+
this.joinRetryDelayMs = DEFAULT_JOIN_RETRY_DELAY_MS;
|
|
38
42
|
this.codec = new OpusAudioCodec();
|
|
39
43
|
this.publisher = null;
|
|
40
44
|
this.publisherHandle = null;
|
|
@@ -58,9 +62,20 @@ class KazeeCallSession {
|
|
|
58
62
|
await this.janus.connect();
|
|
59
63
|
this.publisherHandle = await this.janus.attach();
|
|
60
64
|
this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
// The caller creates the Janus room during its own call setup, which can
|
|
66
|
+
// land a beat after the invite reaches us — and a bot answers faster than
|
|
67
|
+
// any human. Poll join through transient 426 "No such room" like the web
|
|
68
|
+
// receiver's retry loop in CallContext.startVideoRoom.
|
|
69
|
+
let joined;
|
|
70
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
71
|
+
joined = await this.janus.message(this.publisherHandle, {
|
|
72
|
+
request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
73
|
+
});
|
|
74
|
+
const data = joined?.plugindata?.data || {};
|
|
75
|
+
const noRoom = Number(data.error_code) === 426 || /no such room/i.test(String(data.error || ""));
|
|
76
|
+
if (!noRoom || attempt >= this.joinRetries || this.closed) break;
|
|
77
|
+
await wait(this.joinRetryDelayMs);
|
|
78
|
+
}
|
|
64
79
|
await this._consumePublisherFrame(joined);
|
|
65
80
|
this._startedAt = Date.now();
|
|
66
81
|
this._lastHeard = this._startedAt;
|
|
@@ -76,7 +91,11 @@ class KazeeCallSession {
|
|
|
76
91
|
|
|
77
92
|
async _consumePublisherFrame(frame) {
|
|
78
93
|
const data = frame?.plugindata?.data || {};
|
|
79
|
-
if (data.error)
|
|
94
|
+
if (data.error) {
|
|
95
|
+
const error = new Error(data.error);
|
|
96
|
+
if (data.error_code != null) error.code = Number(data.error_code);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
80
99
|
for (const publisher of data.publishers || []) {
|
|
81
100
|
const feedId = Number(publisher.id);
|
|
82
101
|
if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
|
|
@@ -135,7 +154,11 @@ class KazeeCallSession {
|
|
|
135
154
|
const sub = this.subscribers.get(feedId);
|
|
136
155
|
if (!sub) return;
|
|
137
156
|
const data = frame?.plugindata?.data || {};
|
|
138
|
-
if (data.error)
|
|
157
|
+
if (data.error) {
|
|
158
|
+
const error = new Error(data.error);
|
|
159
|
+
if (data.error_code != null) error.code = Number(data.error_code);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
139
162
|
if (frame.jsep && !sub.started) {
|
|
140
163
|
sub.started = true;
|
|
141
164
|
await sub.pc.setRemoteDescription(frame.jsep);
|
package/core/runner.js
CHANGED
|
@@ -1647,6 +1647,18 @@ async function deliverForegroundResult(result, runContext, state = currentState(
|
|
|
1647
1647
|
}
|
|
1648
1648
|
}
|
|
1649
1649
|
|
|
1650
|
+
// The <voice-script> block (if the provider wrote one) is a spoken summary
|
|
1651
|
+
// for the voice note — strip it from the chat text; the guard above already
|
|
1652
|
+
// vetted the full output including the script.
|
|
1653
|
+
const { VOICE_REPLY_MODE } = require("./config");
|
|
1654
|
+
const { shouldReplyWithVoice, extractVoiceScript, speechFallback } = require("./voice-policy");
|
|
1655
|
+
let voiceScript = "";
|
|
1656
|
+
if (result.ok) {
|
|
1657
|
+
const parts = extractVoiceScript(finalText);
|
|
1658
|
+
voiceScript = parts.script;
|
|
1659
|
+
finalText = parts.text || parts.script || finalText;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1650
1662
|
const chunks = splitMessage(finalText);
|
|
1651
1663
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
1652
1664
|
let sent = await send(chunks[index], telegramHtmlOpts(index === 0 ? { replyTo: runContext.replyToMsgId } : {}));
|
|
@@ -1654,25 +1666,26 @@ async function deliverForegroundResult(result, runContext, state = currentState(
|
|
|
1654
1666
|
if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
|
|
1655
1667
|
}
|
|
1656
1668
|
|
|
1657
|
-
const { VOICE_REPLY_MODE } = require("./config");
|
|
1658
|
-
const { shouldReplyWithVoice } = require("./voice-policy");
|
|
1659
1669
|
const shouldSpeak = shouldReplyWithVoice(VOICE_REPLY_MODE, runContext.lastInputWasVoice);
|
|
1660
1670
|
if (result.ok && shouldSpeak) {
|
|
1661
1671
|
const { currentTransport } = require("./context");
|
|
1662
1672
|
if (currentTransport() === "voice") {
|
|
1663
|
-
const sentences = splitSentences(finalText);
|
|
1673
|
+
const sentences = splitSentences(voiceScript || finalText);
|
|
1664
1674
|
let spoke = false;
|
|
1665
1675
|
for (const sentence of sentences) {
|
|
1666
1676
|
const clip = await synthSentenceMp3(sentence);
|
|
1667
1677
|
if (clip) { spoke = true; await sendVoice(clip); }
|
|
1668
1678
|
}
|
|
1669
1679
|
if (!spoke) {
|
|
1670
|
-
const voicePath = await textToVoice(finalText);
|
|
1680
|
+
const voicePath = await textToVoice(voiceScript || finalText);
|
|
1671
1681
|
if (voicePath) await sendVoice(voicePath);
|
|
1672
1682
|
}
|
|
1673
1683
|
await sendVoiceEnd();
|
|
1674
1684
|
} else {
|
|
1675
|
-
|
|
1685
|
+
// Never TTS the formatted chat text verbatim: use the provider's spoken
|
|
1686
|
+
// script, else a sanitized short fallback.
|
|
1687
|
+
const spoken = voiceScript || speechFallback(finalText);
|
|
1688
|
+
const voicePath = spoken ? await textToVoice(spoken) : null;
|
|
1676
1689
|
if (voicePath) await sendVoice(voicePath);
|
|
1677
1690
|
}
|
|
1678
1691
|
}
|
package/core/system-prompt.js
CHANGED
|
@@ -995,10 +995,29 @@ const VOICE_REPLY_GUIDANCE = [
|
|
|
995
995
|
"- If the answer genuinely needs code or exact detail, say so briefly and offer to send it as text rather than reading it aloud.",
|
|
996
996
|
].join("\n");
|
|
997
997
|
|
|
998
|
+
// Text-channel voice turns (a Telegram/Kazee voice note): the chat reply stays
|
|
999
|
+
// a normal full text answer, but a voice note is ALSO sent back. Asking for a
|
|
1000
|
+
// dedicated spoken script — stripped from the text by the runner — is what
|
|
1001
|
+
// keeps that voice note conversational instead of a verbatim TTS of the
|
|
1002
|
+
// formatted chat text.
|
|
1003
|
+
const VOICE_NOTE_GUIDANCE = [
|
|
1004
|
+
"",
|
|
1005
|
+
"",
|
|
1006
|
+
"## Voice note reply",
|
|
1007
|
+
"The user sent this message as a voice note, and a spoken voice note will be sent back alongside your text reply. Do both:",
|
|
1008
|
+
"- Write your normal text reply as usual — full detail and formatting are fine; it is delivered as text.",
|
|
1009
|
+
"- End the reply with a spoken script between <voice-script> and </voice-script> markers. That block is stripped from the text message and only synthesized to audio.",
|
|
1010
|
+
"Script rules: two to four short sentences a person would naturally SAY — summarise the essence conversationally, do not re-read the text reply. No formatting, no emoji, no lists. Never speak code, IDs, file paths, URLs, or command syntax — describe them in plain words or say the details are in the text message.",
|
|
1011
|
+
].join("\n");
|
|
1012
|
+
|
|
998
1013
|
function voiceReplyGuidance(opts = {}) {
|
|
999
1014
|
try {
|
|
1000
1015
|
const { currentTransport } = require("./context");
|
|
1001
|
-
|
|
1016
|
+
const state = promptState(opts);
|
|
1017
|
+
if (currentTransport() === "voice") return state.lastInputWasVoice ? VOICE_REPLY_GUIDANCE : "";
|
|
1018
|
+
const { VOICE_REPLY_MODE } = require("./config");
|
|
1019
|
+
const { shouldReplyWithVoice } = require("./voice-policy");
|
|
1020
|
+
if (shouldReplyWithVoice(VOICE_REPLY_MODE, state.lastInputWasVoice)) return VOICE_NOTE_GUIDANCE;
|
|
1002
1021
|
} catch (e) {}
|
|
1003
1022
|
return "";
|
|
1004
1023
|
}
|
package/core/voice-policy.js
CHANGED
|
@@ -4,4 +4,49 @@ function shouldReplyWithVoice(mode, lastInputWasVoice) {
|
|
|
4
4
|
return mode === "always" || (mode === "on-voice-input" && !!lastInputWasVoice);
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// On voice-reply turns the provider is asked (system-prompt voice-note
|
|
8
|
+
// guidance) to end its reply with a spoken script between these markers. The
|
|
9
|
+
// block is stripped from the chat text and ONLY synthesized — chat keeps the
|
|
10
|
+
// full formatted answer while the voice note carries an ear-friendly summary
|
|
11
|
+
// instead of a verbatim TTS readout of bullets, emoji, and IDs.
|
|
12
|
+
const VOICE_SCRIPT_RE = /<voice-script>([\s\S]*?)(?:<\/voice-script>|$)/gi;
|
|
13
|
+
|
|
14
|
+
function extractVoiceScript(text) {
|
|
15
|
+
const raw = String(text || "");
|
|
16
|
+
const scripts = [];
|
|
17
|
+
const stripped = raw.replace(VOICE_SCRIPT_RE, (_match, body) => {
|
|
18
|
+
const clean = String(body || "").trim();
|
|
19
|
+
if (clean) scripts.push(clean);
|
|
20
|
+
return "";
|
|
21
|
+
});
|
|
22
|
+
return { text: stripped.trim(), script: scripts.join(" ").trim() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Last-resort shaping when the provider supplied no <voice-script>: strip
|
|
26
|
+
// everything that reads badly aloud and cap to the opening sentences, so a
|
|
27
|
+
// missed script degrades to a short spoken intro rather than a verbatim dump
|
|
28
|
+
// of formatted text into TTS.
|
|
29
|
+
const FALLBACK_MAX_CHARS = 400;
|
|
30
|
+
|
|
31
|
+
function speechFallback(text) {
|
|
32
|
+
const plain = String(text || "")
|
|
33
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
34
|
+
.replace(/<[^>\n]{0,120}>/g, " ")
|
|
35
|
+
.replace(/https?:\/\/\S+/gi, " ")
|
|
36
|
+
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}]/gu, "")
|
|
37
|
+
.replace(/^\s*[-*•▸►]+\s+/gm, "")
|
|
38
|
+
.replace(/[*_`#|]+/g, " ")
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim();
|
|
41
|
+
if (plain.length <= FALLBACK_MAX_CHARS) return plain;
|
|
42
|
+
const sentences = plain.match(/[^.!?]+[.!?]+(?:\s+|$)/g) || [];
|
|
43
|
+
let out = "";
|
|
44
|
+
for (const sentence of sentences) {
|
|
45
|
+
if (out && out.length + sentence.length > FALLBACK_MAX_CHARS) break;
|
|
46
|
+
out += sentence;
|
|
47
|
+
}
|
|
48
|
+
out = out.trim() || plain.slice(0, FALLBACK_MAX_CHARS).trim();
|
|
49
|
+
return `${out} The rest is in the text message.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { shouldReplyWithVoice, extractVoiceScript, speechFallback };
|
package/package.json
CHANGED
|
@@ -202,6 +202,32 @@ async function testMaxDuration() {
|
|
|
202
202
|
assert.strictEqual(ended[0], "max-duration", "cap closes the call even mid-conversation");
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
async function testJoinRetriesNoSuchRoom() {
|
|
206
|
+
const { session } = makeSession({});
|
|
207
|
+
session.joinRetryDelayMs = 1;
|
|
208
|
+
let joins = 0;
|
|
209
|
+
session.janus.message = async (_handle, body) => {
|
|
210
|
+
if (body?.request !== "join") return {};
|
|
211
|
+
joins += 1;
|
|
212
|
+
if (joins < 3) return { plugindata: { data: { videoroom: "event", error_code: 426, error: "No such room (7)" } } };
|
|
213
|
+
return {};
|
|
214
|
+
};
|
|
215
|
+
await session.start();
|
|
216
|
+
assert.strictEqual(joins, 3, "join retried through transient no-such-room");
|
|
217
|
+
await session.close();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function testJoinGivesUpAfterRetries() {
|
|
221
|
+
const { session } = makeSession({});
|
|
222
|
+
session.joinRetryDelayMs = 1;
|
|
223
|
+
session.joinRetries = 2;
|
|
224
|
+
session.janus.message = async (_handle, body) => body?.request === "join"
|
|
225
|
+
? { plugindata: { data: { videoroom: "event", error_code: 426, error: "No such room (7)" } } }
|
|
226
|
+
: {};
|
|
227
|
+
await assert.rejects(() => session.start(), /No such room/, "persistent 426 still fails the call");
|
|
228
|
+
await session.close();
|
|
229
|
+
}
|
|
230
|
+
|
|
205
231
|
async function testCloseResolvesSpeech() {
|
|
206
232
|
const { session } = makeSession({
|
|
207
233
|
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
|
|
@@ -226,6 +252,8 @@ async function testCloseResolvesSpeech() {
|
|
|
226
252
|
await testIdleTimeout();
|
|
227
253
|
await testIdleBargeInKeepsCall();
|
|
228
254
|
await testMaxDuration();
|
|
255
|
+
await testJoinRetriesNoSuchRoom();
|
|
256
|
+
await testJoinGivesUpAfterRetries();
|
|
229
257
|
await testCloseResolvesSpeech();
|
|
230
258
|
console.log("kazee call session: ok");
|
|
231
259
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -95,7 +95,10 @@ async function providerProbe(kind) {
|
|
|
95
95
|
const service = enforcer.createEnforcerService({
|
|
96
96
|
spawnUtilityAgent: utility.spawnUtilityAgent,
|
|
97
97
|
audit,
|
|
98
|
-
|
|
98
|
+
// Generous: children answer in ms, so the green path never waits this out.
|
|
99
|
+
// The deadline exists only as the fail-closed backstop, and 2000ms was
|
|
100
|
+
// missed by child boot on a loaded CI runner (pipeline 10445 flake).
|
|
101
|
+
timeoutMs: 8000,
|
|
99
102
|
});
|
|
100
103
|
const speaker = { name: "External Fixture", relationship: "external", mandate: "May discuss invoice status only." };
|
|
101
104
|
const secret = ["sk", "proj", "guard", "fixture", "private"].join("-");
|
|
@@ -106,9 +109,10 @@ async function providerProbe(kind) {
|
|
|
106
109
|
const timeoutService = enforcer.createEnforcerService({
|
|
107
110
|
spawnUtilityAgent: utility.spawnUtilityAgent,
|
|
108
111
|
audit,
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
+
// Bounded (this deadline MUST fire — the child hangs by design), but with
|
|
113
|
+
// enough headroom for the hung child to boot on a loaded CI runner (write
|
|
114
|
+
// capture #5, arm its SIGTERM recorder) before the deadline kills it.
|
|
115
|
+
timeoutMs: 5000,
|
|
112
116
|
});
|
|
113
117
|
const timedOut = await timeoutService.vetReply(speaker, "timeout verdict fixture");
|
|
114
118
|
|
|
@@ -212,7 +212,22 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
212
212
|
// production hardening — serialized TTS with barge-in cancellation, echo gate,
|
|
213
213
|
// per-feed STT with group speaker attribution, duration/idle limits with a
|
|
214
214
|
// spoken goodbye, and the call tests wired into the main npm test chain.
|
|
215
|
-
|
|
215
|
+
// 3.1.15 approved by Sumeet 2026-07-22 ("if replying in voice it needs to be
|
|
216
|
+
// summaries etc but not just conversions directly"): voice-note replies now
|
|
217
|
+
// speak a provider-written conversational <voice-script> summary (stripped from
|
|
218
|
+
// the chat text) instead of a verbatim TTS of the formatted answer, with a
|
|
219
|
+
// sanitizing speechFallback when the script is missing.
|
|
220
|
+
// 3.1.16 approved by Sumeet 2026-07-22 (same calls-directive authority; re-cut
|
|
221
|
+
// of the never-published 3.1.15 after two loaded-runner CI flakes): test-only
|
|
222
|
+
// deflake — heal-guard test stubs its live api.telegram.org probe and isolates
|
|
223
|
+
// CONFIG_DIR; enforcer probe deadlines raised to CI-safe values (2000→8000ms
|
|
224
|
+
// backstop, 2500→5000ms deliberate-timeout boot headroom). No runtime changes.
|
|
225
|
+
// 3.1.17 approved by Sumeet 2026-07-23 (same calls-directive authority; his web
|
|
226
|
+
// call to an upgraded bot showed "Call declined"): the call bridge now retries
|
|
227
|
+
// the Janus publisher join through transient 426 "No such room" — the caller
|
|
228
|
+
// creates the room at dial time and the bot answers faster than any human, so
|
|
229
|
+
// the single naked join lost the race deterministically.
|
|
230
|
+
assert.strictEqual(pkg.version, "3.1.17", "release version must remain unchanged without explicit approval");
|
|
216
231
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
217
232
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
218
233
|
}
|