@inetafrica/open-claudia 3.4.14 → 3.4.16
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 +14 -0
- package/bin/cli.js +28 -0
- package/bin/loopback-client.js +1 -0
- package/channels/kazee/call/manager.js +66 -14
- package/channels/kazee/call/session.js +154 -80
- package/channels/kazee/call/speech-client.js +96 -19
- package/channels/kazee/call/turn-gate.js +62 -0
- package/core/call-speech.js +111 -0
- package/core/loopback.js +10 -1
- package/core/providers/env.js +1 -0
- package/core/run-context.js +13 -6
- package/core/runner.js +69 -7
- package/core/system-prompt.js +19 -7
- package/package.json +4 -3
- package/test-call-speech.js +237 -0
- package/test-kazee-call-manager.js +57 -2
- package/test-kazee-call-session.js +153 -7
- package/test-kazee-call-speech.js +38 -2
- package/test-provider-env-isolation.js +5 -0
- package/test-provider-gateway.js +15 -1
- package/test-provider-language.js +3 -1
- package/test-provider-prompt-parity.js +16 -1
- package/test-provider-recall.js +46 -0
- package/test-provider-runner.js +34 -0
- package/test-tool-manifest.js +25 -12
- package/test-tools.js +11 -7
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
const assert = require("assert");
|
|
5
|
+
const { EventEmitter } = require("events");
|
|
5
6
|
const { KazeeCallSession } = require("./channels/kazee/call/session");
|
|
7
|
+
const { SpeechStream } = require("./channels/kazee/call/speech-client");
|
|
6
8
|
const { KazeeCallManager } = require("./channels/kazee/call/manager");
|
|
7
9
|
const { OpusAudioCodec, WEBRTC_SAMPLES } = require("./channels/kazee/call/audio");
|
|
8
10
|
|
|
@@ -63,6 +65,46 @@ function toneOpus(codec, amplitude) {
|
|
|
63
65
|
return codec.encode(pcm);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
async function testDelayedSpeechProtocolReady() {
|
|
69
|
+
class DelayedWebSocket extends EventEmitter {
|
|
70
|
+
static OPEN = 1;
|
|
71
|
+
constructor() {
|
|
72
|
+
super(); this.readyState = 1; this.sent = [];
|
|
73
|
+
queueMicrotask(() => this.emit("open"));
|
|
74
|
+
}
|
|
75
|
+
send(value) { this.sent.push(value); }
|
|
76
|
+
close() { this.readyState = 3; this.emit("close"); }
|
|
77
|
+
}
|
|
78
|
+
const { session, transcripts } = makeSession();
|
|
79
|
+
await session._subscribe(901, "peer");
|
|
80
|
+
const sub = session.subscribers.get(901);
|
|
81
|
+
sub.stt.close();
|
|
82
|
+
sub.stt = new SpeechStream({
|
|
83
|
+
baseUrl: "https://speech.example", token: "fixture", WebSocketImpl: DelayedWebSocket,
|
|
84
|
+
onTranscript: (text, meta) => session._onFeedFinal(901, text, meta),
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await sub.stt.connect();
|
|
88
|
+
session._commitTurn(901, "silence");
|
|
89
|
+
await wait(0);
|
|
90
|
+
const socket = sub.stt.socket;
|
|
91
|
+
// The caller finished before the gateway delivered its protocol greeting.
|
|
92
|
+
socket.emit("message", JSON.stringify({ type: "ready", protocol: 2, segment_id: 1 }));
|
|
93
|
+
socket.emit("message", JSON.stringify({ type: "final", segment_id: 1, text: "The complete question." }));
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["The complete question."],
|
|
96
|
+
"late protocol negotiation must not orphan a committed final");
|
|
97
|
+
assert.strictEqual(sub.finalTimer, null, "a delivered final cancels the fallback");
|
|
98
|
+
session._commitTurn(901, "silence");
|
|
99
|
+
await wait(0);
|
|
100
|
+
socket.emit("message", JSON.stringify({ type: "final", segment_id: 2, text: "The next question." }));
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["The complete question.", "The next question."]);
|
|
103
|
+
assert.deepStrictEqual(socket.sent.slice(1).map(JSON.parse), [{ type: "commit" }, { type: "commit" }],
|
|
104
|
+
"the connection keeps the same compatible commit format after its first turn");
|
|
105
|
+
} finally { await session.close(); }
|
|
106
|
+
}
|
|
107
|
+
|
|
66
108
|
async function testSpeakSerialization() {
|
|
67
109
|
const writes = [];
|
|
68
110
|
const synthCalls = [];
|
|
@@ -169,7 +211,7 @@ async function testHoldForWords() {
|
|
|
169
211
|
sub.stt.onPartial("hello there");
|
|
170
212
|
assert.strictEqual(sub.stt.commits, 1, "first words release the held commit");
|
|
171
213
|
assert.strictEqual(sub.awaitingWordsSince, 0);
|
|
172
|
-
assert(session._thinkTimer, "
|
|
214
|
+
assert.strictEqual(session._thinkTimer, null, "an unfinished transcript is not acknowledged");
|
|
173
215
|
gen.close();
|
|
174
216
|
await session.close();
|
|
175
217
|
}
|
|
@@ -242,6 +284,106 @@ async function testTrickleBuffering() {
|
|
|
242
284
|
await session.close();
|
|
243
285
|
}
|
|
244
286
|
|
|
287
|
+
async function testNoiseAfterFallbackCannotReplayPreviousUtterance() {
|
|
288
|
+
const { session, transcripts } = makeSession({ finalWaitMs: 30 });
|
|
289
|
+
await session._subscribe(101, "caller");
|
|
290
|
+
const sub = session.subscribers.get(101);
|
|
291
|
+
sub.stt.onPartial("I can hear you");
|
|
292
|
+
session._commitTurn(101, "silence");
|
|
293
|
+
await wait(60);
|
|
294
|
+
sub.noiseCommit = true;
|
|
295
|
+
session._commitTurn(101, "no-words");
|
|
296
|
+
await wait(60);
|
|
297
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["I can hear you"], "a following noise turn cannot replay a consumed partial");
|
|
298
|
+
await session.close();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function testVersionedFinalsBelongToTheirCommittedSegment() {
|
|
302
|
+
const { session, transcripts } = makeSession({ finalWaitMs: 30 });
|
|
303
|
+
await session._subscribe(101, "caller");
|
|
304
|
+
const sub = session.subscribers.get(101);
|
|
305
|
+
Object.assign(sub.stt, { protocol: 2, streamId: 1, segmentId: 1 });
|
|
306
|
+
sub.stt.commitTurn = () => ({ streamId: 1, segmentId: sub.stt.segmentId++, pending: Promise.resolve() });
|
|
307
|
+
session._onFeedPartial(101, "only the start", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
308
|
+
session._commitTurn(101, "silence");
|
|
309
|
+
await wait(60);
|
|
310
|
+
assert.strictEqual(transcripts.length, 0, "versioned service must complete the segment rather than dispatch an unfinished partial");
|
|
311
|
+
session._onFeedPartial(101, "another thought", { protocol: 2, streamId: 1, segmentId: 2 });
|
|
312
|
+
session._commitTurn(101, "silence");
|
|
313
|
+
session._onFeedFinal(101, "the whole first thought", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
314
|
+
session._onFeedFinal(101, "duplicate", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
315
|
+
session._onFeedFinal(101, "", { protocol: 2, streamId: 1, segmentId: 2 });
|
|
316
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["the whole first thought"]);
|
|
317
|
+
assert.strictEqual(transcripts[0].meta.utteranceId, "1:1");
|
|
318
|
+
await session.close();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function testSemanticPauseResumesWithoutSplittingAudio() {
|
|
322
|
+
const { session } = makeSession();
|
|
323
|
+
const decisions = [];
|
|
324
|
+
session.turnDecision = (audio, { signal }) => new Promise(resolve => decisions.push({ resolve, signal, bytes: audio.length }));
|
|
325
|
+
await session._subscribe(101, "caller");
|
|
326
|
+
const sub = session.subscribers.get(101);
|
|
327
|
+
Object.assign(sub.stt, { protocol: 2, streamId: 1, segmentId: 1 });
|
|
328
|
+
sub.stt.commitTurn = () => {
|
|
329
|
+
sub.stt.commits++;
|
|
330
|
+
return { streamId: 1, segmentId: sub.stt.segmentId++, pending: Promise.resolve() };
|
|
331
|
+
};
|
|
332
|
+
const codec = new OpusAudioCodec();
|
|
333
|
+
const speech = () => { for (let i = 0; i < 3; i++) session._onFeedRtp(101, { payload: toneOpus(codec, 12000) }); };
|
|
334
|
+
const silence = () => { for (let i = 0; i < 36; i++) session._onFeedRtp(101, { payload: toneOpus(codec, 30) }); };
|
|
335
|
+
speech(); silence();
|
|
336
|
+
await wait(1);
|
|
337
|
+
assert.strictEqual(decisions.length, 1);
|
|
338
|
+
const captured = sub.stt.audioFrames.length;
|
|
339
|
+
speech();
|
|
340
|
+
assert(decisions[0].signal.aborted, "resumed speech cancels the old remote decision");
|
|
341
|
+
decisions[0].resolve({ complete: true });
|
|
342
|
+
await wait(1);
|
|
343
|
+
assert.strictEqual(sub.stt.commits, 0, "a late decision cannot split resumed speech");
|
|
344
|
+
assert(sub.stt.audioFrames.length > captured, "ingestion continues while the boundary is tentative");
|
|
345
|
+
silence();
|
|
346
|
+
await wait(1);
|
|
347
|
+
assert.strictEqual(decisions.length, 2);
|
|
348
|
+
assert(decisions[1].bytes > decisions[0].bytes, "the resumed thought retains its previous audio context");
|
|
349
|
+
decisions[1].resolve({ complete: true });
|
|
350
|
+
await wait(1);
|
|
351
|
+
assert.strictEqual(sub.stt.commits, 1);
|
|
352
|
+
assert.strictEqual(sub.stt.segmentId, 2);
|
|
353
|
+
codec.close();
|
|
354
|
+
await session.close();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function testTurnModelFailureAndCloseAreBounded() {
|
|
358
|
+
const { SpeechTurnGate } = require("./channels/kazee/call/turn-gate");
|
|
359
|
+
const commits = [];
|
|
360
|
+
const gate = new SpeechTurnGate({ decide: async () => { throw new Error("unavailable"); }, commit: reason => commits.push(reason), holdMs: 20 });
|
|
361
|
+
gate.audio(Buffer.alloc(640));
|
|
362
|
+
gate.candidate("silence");
|
|
363
|
+
await wait(40);
|
|
364
|
+
assert.deepStrictEqual(commits, ["silence-deadline"], "model failure falls back after a bounded pause");
|
|
365
|
+
gate.audio(Buffer.alloc(640));
|
|
366
|
+
gate.candidate("silence");
|
|
367
|
+
gate.close();
|
|
368
|
+
await wait(40);
|
|
369
|
+
assert.strictEqual(commits.length, 1, "closing prevents pending model/fallback work committing");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function testCancelStopsPendingTtsRequest() {
|
|
373
|
+
const { session } = makeSession();
|
|
374
|
+
let signal;
|
|
375
|
+
session.synthesizeStream = (_text, options) => new Promise((resolve, reject) => {
|
|
376
|
+
signal = options.signal;
|
|
377
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
|
378
|
+
});
|
|
379
|
+
const speaking = session.speak("answer pending first audio");
|
|
380
|
+
assert(signal && !signal.aborted);
|
|
381
|
+
session.cancelSpeech();
|
|
382
|
+
assert(signal.aborted, "barge-in aborts the HTTP request before audio arrives");
|
|
383
|
+
assert.strictEqual(await speaking, false);
|
|
384
|
+
await session.close();
|
|
385
|
+
}
|
|
386
|
+
|
|
245
387
|
async function testTranscriptAttribution() {
|
|
246
388
|
const { session, transcripts } = makeSession({});
|
|
247
389
|
await session._subscribe(101, "alice@kazee");
|
|
@@ -391,16 +533,14 @@ async function testThinkingCues() {
|
|
|
391
533
|
const sub = session.subscribers.get(101);
|
|
392
534
|
const gen = new OpusAudioCodec();
|
|
393
535
|
|
|
394
|
-
//
|
|
395
|
-
// plays an audible "heard you" chime, and starts ticking right away — STT
|
|
396
|
-
// finalization takes seconds and must not sound like a dead line.
|
|
536
|
+
// A proposed boundary alone must not beep or claim to have heard a turn.
|
|
397
537
|
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
398
538
|
sub.stt.onPartial("what is the status");
|
|
399
539
|
for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
|
|
400
540
|
assert.strictEqual(sub.stt.commits, 1);
|
|
401
541
|
await wait(60);
|
|
402
|
-
assert(frames
|
|
403
|
-
assert(session._thinkTimer, "
|
|
542
|
+
assert.strictEqual(frames, 0, "turn commit does not beep");
|
|
543
|
+
assert.strictEqual(session._thinkTimer, null, "no cue before recognized words are final");
|
|
404
544
|
|
|
405
545
|
// A noise commit finalizes to empty text: nothing dispatches, so the ticks
|
|
406
546
|
// stop rather than promising an answer that will never come.
|
|
@@ -421,7 +561,7 @@ async function testThinkingCues() {
|
|
|
421
561
|
assert.strictEqual(session._thinkTimer, null, "answer playback stops the thinking loop");
|
|
422
562
|
|
|
423
563
|
// A fresh thinking loop stops the moment the caller starts talking again.
|
|
424
|
-
session._startThinking();
|
|
564
|
+
session._startThinking("another question");
|
|
425
565
|
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
426
566
|
assert.strictEqual(session._thinkTimer, null, "caller speech stops the thinking loop");
|
|
427
567
|
gen.close();
|
|
@@ -611,7 +751,12 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
611
751
|
await testHoldTimeoutDropsNoiseTurn();
|
|
612
752
|
await testWordStallRescue();
|
|
613
753
|
await testInterimFallbackAndLateFinal();
|
|
754
|
+
await testNoiseAfterFallbackCannotReplayPreviousUtterance();
|
|
755
|
+
await testVersionedFinalsBelongToTheirCommittedSegment();
|
|
614
756
|
await testTrickleBuffering();
|
|
757
|
+
await testSemanticPauseResumesWithoutSplittingAudio();
|
|
758
|
+
await testTurnModelFailureAndCloseAreBounded();
|
|
759
|
+
await testCancelStopsPendingTtsRequest();
|
|
615
760
|
await testTranscriptAttribution();
|
|
616
761
|
await testIdleTimeout();
|
|
617
762
|
await testIdleBargeInKeepsCall();
|
|
@@ -622,5 +767,6 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
622
767
|
await testCallerModeRoomExistsAndFailure();
|
|
623
768
|
await testThinkingCues();
|
|
624
769
|
await testCloseResolvesSpeech();
|
|
770
|
+
await testDelayedSpeechProtocolReady();
|
|
625
771
|
console.log("kazee call session: ok");
|
|
626
772
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
const assert = require("assert");
|
|
4
4
|
const { EventEmitter } = require("events");
|
|
5
|
-
const { SpeechStream, synthesizePcm } = require("./channels/kazee/call/speech-client");
|
|
5
|
+
const { SpeechStream, synthesizePcm, detectTurn, selectAcknowledgement } = require("./channels/kazee/call/speech-client");
|
|
6
6
|
class FakeWebSocket extends EventEmitter {
|
|
7
7
|
static OPEN = 1;
|
|
8
8
|
constructor(url, options) { super(); this.url = url; this.options = options; this.readyState = 1; this.sent = []; queueMicrotask(() => this.emit("open")); }
|
|
@@ -14,12 +14,32 @@ class FakeWebSocket extends EventEmitter {
|
|
|
14
14
|
const stream = new SpeechStream({ baseUrl: "https://speech.example", token: "secret", WebSocketImpl: FakeWebSocket, onTranscript: (t) => { transcript = t; } });
|
|
15
15
|
await stream.connect();
|
|
16
16
|
assert.strictEqual(stream.socket.url, "wss://speech.example/v1/audio/transcriptions/stream");
|
|
17
|
-
assert.deepStrictEqual(JSON.parse(stream.socket.sent[0]), { sample_rate: 16000, language: "en" });
|
|
17
|
+
assert.deepStrictEqual(JSON.parse(stream.socket.sent[0]), { sample_rate: 16000, language: "en", protocol: 2 });
|
|
18
18
|
await stream.audio(Buffer.from([1, 2]));
|
|
19
19
|
await stream.commit();
|
|
20
20
|
assert(Buffer.isBuffer(stream.socket.sent[1]));
|
|
21
21
|
stream.socket.emit("message", Buffer.from(JSON.stringify({ type: "final", text: "hello" })));
|
|
22
22
|
assert.strictEqual(transcript, "hello");
|
|
23
|
+
const finals = [], partials = [];
|
|
24
|
+
const versioned = new SpeechStream({ baseUrl: "https://speech.example", token: "secret", WebSocketImpl: FakeWebSocket,
|
|
25
|
+
onTranscript: (text, meta) => finals.push({ text, meta }), onPartial: (text, meta) => partials.push({ text, meta }) });
|
|
26
|
+
await versioned.connect();
|
|
27
|
+
const oldSocket = versioned.socket;
|
|
28
|
+
oldSocket.emit("message", Buffer.from(JSON.stringify({ type: "ready", protocol: 2, segment_id: 1 })));
|
|
29
|
+
const first = versioned.commitTurn(), second = versioned.commitTurn();
|
|
30
|
+
await Promise.all([first.pending, second.pending]);
|
|
31
|
+
assert.deepStrictEqual(oldSocket.sent.slice(1).map(JSON.parse), [{ type: "commit", segment_id: 1 }, { type: "commit", segment_id: 2 }]);
|
|
32
|
+
oldSocket.emit("message", Buffer.from(JSON.stringify({ type: "final", segment_id: 1, text: "" })));
|
|
33
|
+
assert.strictEqual(finals.length, 1, "empty final is forwarded to clear the committed turn");
|
|
34
|
+
assert.strictEqual(finals[0].meta.segmentId, 1);
|
|
35
|
+
oldSocket.close();
|
|
36
|
+
assert.strictEqual(versioned.protocol, 1, "disconnect resets protocol before a new turn can commit");
|
|
37
|
+
await versioned.connect();
|
|
38
|
+
oldSocket.emit("message", Buffer.from(JSON.stringify({ type: "final", segment_id: 2, text: "stale" })));
|
|
39
|
+
assert.strictEqual(finals.length, 1, "an old socket cannot deliver into a new stream");
|
|
40
|
+
assert.strictEqual(versioned.segmentId, 1);
|
|
41
|
+
versioned.close();
|
|
42
|
+
await assert.rejects(versioned.audio(Buffer.alloc(2)), /closed/, "terminal close cannot reopen a speech connection");
|
|
23
43
|
let request;
|
|
24
44
|
const pcm = await synthesizePcm("Hello", { baseUrl: "https://speech.example", token: "secret", fetchImpl: async (url, options) => {
|
|
25
45
|
request = { url, options }; return {
|
|
@@ -31,6 +51,22 @@ class FakeWebSocket extends EventEmitter {
|
|
|
31
51
|
assert.strictEqual(JSON.parse(request.options.body).response_format, "pcm");
|
|
32
52
|
assert.strictEqual(pcm.sampleRate, 24000);
|
|
33
53
|
assert.deepStrictEqual([...pcm.pcm], [1, 2, 3]);
|
|
54
|
+
const controller = { baseUrl: "https://speech.example", token: "fixture", fetchImpl: async (_url, options) => {
|
|
55
|
+
assert.strictEqual(JSON.parse(options.body).state, "waiting");
|
|
56
|
+
return { ok: true, json: async () => ({ choice: "wait", text: "Invented answer must never be spoken" }) };
|
|
57
|
+
} };
|
|
58
|
+
assert.strictEqual(await selectAcknowledgement("A complete question", "waiting", controller), "One moment.");
|
|
59
|
+
assert.strictEqual(await selectAcknowledgement("hello", "working", { ...controller,
|
|
60
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ choice: "acknowledge", text: "Mhm." }) }),
|
|
61
|
+
}), "", "wrong-state cue is omitted");
|
|
62
|
+
await assert.rejects(detectTurn(Buffer.alloc(640), { ...controller,
|
|
63
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ complete: true, probability: 3 }) }),
|
|
64
|
+
}), /Invalid turn decision/);
|
|
65
|
+
const abort = new AbortController();
|
|
66
|
+
abort.abort();
|
|
67
|
+
await assert.rejects(synthesizePcm("cancelled", { baseUrl: "https://speech.example", token: "fixture", signal: abort.signal,
|
|
68
|
+
fetchImpl: async (_url, options) => { assert(options.signal.aborted); throw new Error("aborted"); },
|
|
69
|
+
}), /aborted/, "TTS request inherits call cancellation");
|
|
34
70
|
stream.close();
|
|
35
71
|
console.log("kazee call speech: ok");
|
|
36
72
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -85,6 +85,11 @@ assert.throws(
|
|
|
85
85
|
);
|
|
86
86
|
|
|
87
87
|
const base = baseAgentEnv(source);
|
|
88
|
+
for (const builder of [baseAgentEnv, claudeAgentEnv, codexAgentEnv]) {
|
|
89
|
+
assert.strictEqual(builder({ ...source, OC_CALL_SPEECH_TOKEN: "stale-call-capability",
|
|
90
|
+
AGENT_ENV_PASSTHROUGH: "OC_CALL_SPEECH_TOKEN" }).OC_CALL_SPEECH_TOKEN, undefined,
|
|
91
|
+
"a call speech capability never passes through a generic provider environment");
|
|
92
|
+
}
|
|
88
93
|
assert.strictEqual(base.PROJECT_REGION, "fixture-region", "explicit project passthrough survives");
|
|
89
94
|
assert.deepStrictEqual(presence(base), Object.fromEntries(baseline.environment.keys.map((key) => [key, false])));
|
|
90
95
|
|
package/test-provider-gateway.js
CHANGED
|
@@ -382,7 +382,7 @@ async function schedulerProbe() {
|
|
|
382
382
|
console.log("provider gateway scheduler OK");
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
function queueProbe() {
|
|
385
|
+
async function queueProbe() {
|
|
386
386
|
const projectA = path.join(process.env.WORKSPACE, "project-a");
|
|
387
387
|
const projectB = path.join(process.env.WORKSPACE, "project-b");
|
|
388
388
|
fs.mkdirSync(projectA, { recursive: true });
|
|
@@ -455,6 +455,20 @@ function queueProbe() {
|
|
|
455
455
|
assert.strictEqual(queued.runContext.project.dir, projectA);
|
|
456
456
|
assert.strictEqual(queued.runContext.providerSettings.model, "claude-fixture");
|
|
457
457
|
assert.strictEqual(queued.runContext.sessionId, "claude-queued-session");
|
|
458
|
+
const obsoleteCall = new AbortController();
|
|
459
|
+
store.adapter.liveCall = true;
|
|
460
|
+
store.adapter.callTurn = { signal: obsoleteCall.signal };
|
|
461
|
+
const callPending = runAgent("obsolete call question", projectA, null, { runId: "obsolete-call" });
|
|
462
|
+
assert.strictEqual(state.messageQueue.length, 2);
|
|
463
|
+
obsoleteCall.abort();
|
|
464
|
+
assert.strictEqual(state.messageQueue.length, 1, "superseded call entry is removed immediately, ordinary queued work is preserved");
|
|
465
|
+
assert.strictEqual((await callPending).status, "cancelled");
|
|
466
|
+
assert.strictEqual(state.messageQueue[0], queued);
|
|
467
|
+
const alreadyEnded = await runAgent("late call dispatch", projectA, null, { runId: "late-call" });
|
|
468
|
+
assert.strictEqual(alreadyEnded.status, "cancelled");
|
|
469
|
+
assert.strictEqual(state.messageQueue.length, 1, "an already-ended call never joins the queue");
|
|
470
|
+
delete store.adapter.callTurn;
|
|
471
|
+
delete store.adapter.liveCall;
|
|
458
472
|
console.log("provider gateway immutable queue OK");
|
|
459
473
|
}
|
|
460
474
|
|
|
@@ -467,7 +467,9 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
467
467
|
// 3.4.14: bot managed-call consumer (subscribe only to gateway-authenticated User publishers) — part of the Janus calling plan Sumeet approved on 2026-09-05.
|
|
468
468
|
// 3.4.13: Codex usage accounting fix — Sumeet approved the three-bug fix on 2026-09-05 ("Yes");
|
|
469
469
|
// prepared 2026-09-06 for his release go. Rollout-based Codex context, per-turn ledger, sub-agent + quota accounting.
|
|
470
|
-
|
|
470
|
+
// 3.4.15: conversation flow and checked sentence speech; authorized calling rollout, 2026-09-05/06.
|
|
471
|
+
// 3.4.16: remove the auxiliary recall-model delay from live replies; Sumeet's approved calling rollout, 2026-09-05/06.
|
|
472
|
+
assert.strictEqual(pkg.version, "3.4.16", "release version must remain unchanged without explicit approval");
|
|
471
473
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
472
474
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
473
475
|
}
|
|
@@ -30,7 +30,17 @@ async function probe() {
|
|
|
30
30
|
origin: { transport: "telegram", channelId: "parity-channel" },
|
|
31
31
|
},
|
|
32
32
|
});
|
|
33
|
-
|
|
33
|
+
const { chatContext } = require("./core/context");
|
|
34
|
+
const voiceBundle = await chatContext.run({ adapter: { type: "kazee", liveCall: true }, channelId: "parity-channel", canonicalUserId: state.userId }, () => buildTurnPrompt("Explain that beep.", {
|
|
35
|
+
provider: getProvider("codex"),
|
|
36
|
+
runContext: {
|
|
37
|
+
canonicalUserId: state.userId, project: { ...state.currentSession }, provider: "codex",
|
|
38
|
+
providerSettings: { ...state.settings }, sessionId: null, fresh: true, liveCall: true,
|
|
39
|
+
channelId: "parity-channel", origin: { transport: "kazee", channelId: "parity-channel" },
|
|
40
|
+
callHistory: [{ role: "user", text: "I heard a beep before you answered." }],
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
process.stdout.write(`${JSON.stringify({ bundle, voiceBundle })}\n`);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
if (process.argv[2] === "--probe") {
|
|
@@ -77,6 +87,11 @@ if (process.argv[2] === "--probe") {
|
|
|
77
87
|
assert.ok(observed.bundle.dynamicContext.includes("## Project Transcript Memory"));
|
|
78
88
|
assert.ok(observed.bundle.dynamicContext.includes("Codex exposes its native exec tools"));
|
|
79
89
|
assert.ok(observed.bundle.recallMetadata);
|
|
90
|
+
assert.strictEqual(observed.voiceBundle.coreInstructions, observed.bundle.coreInstructions, "bounded call conversation keeps the complete core policy");
|
|
91
|
+
assert.ok(observed.voiceBundle.dynamicContext.includes("LIVE VOICE CALL"));
|
|
92
|
+
assert.ok(observed.voiceBundle.dynamicContext.includes("I heard a beep before you answered."));
|
|
93
|
+
assert.ok(observed.voiceBundle.dynamicContext.includes("Do not claim an earlier request was acted on without tool evidence."));
|
|
94
|
+
assert.strictEqual(observed.voiceBundle.userPrompt, "Explain that beep.");
|
|
80
95
|
assert.notStrictEqual(
|
|
81
96
|
`${observed.bundle.dynamicContext}\n\n${observed.bundle.userPrompt}`,
|
|
82
97
|
baseline.prompt.sentinel,
|
package/test-provider-recall.js
CHANGED
|
@@ -253,6 +253,52 @@ async function main() {
|
|
|
253
253
|
recall.getEngine = originalGetEngine;
|
|
254
254
|
assert.strictEqual(admittedProvider, "codex");
|
|
255
255
|
|
|
256
|
+
// Real call prompt -> classic matcher -> relevance filter. A second model
|
|
257
|
+
// must not hold a live reply; context-only hits still require a judge and
|
|
258
|
+
// cannot be promoted just because calls use the local keyword baseline.
|
|
259
|
+
const packsLib = require("./core/packs");
|
|
260
|
+
const entitiesLib = require("./core/entities");
|
|
261
|
+
const transcriptsLib = require("./core/transcripts");
|
|
262
|
+
const filterLib = require("./core/recall-filter");
|
|
263
|
+
const saved = { matchPacks: packsLib.matchPacks, matchEntities: entitiesLib.matchEntities,
|
|
264
|
+
recentTranscriptText: transcriptsLib.recentTranscriptText, judgeRelevance: filterLib.judgeRelevance };
|
|
265
|
+
const { chatContext } = require("./core/context");
|
|
266
|
+
let judgeCalls = 0;
|
|
267
|
+
try {
|
|
268
|
+
packsLib.matchPacks = text => [{ dir: text === "Current deployment" ? "current-topic" : "stale-chat-topic", score: 10 }];
|
|
269
|
+
entitiesLib.matchEntities = () => [];
|
|
270
|
+
transcriptsLib.recentTranscriptText = () => "Earlier unrelated chat discussion";
|
|
271
|
+
filterLib.judgeRelevance = async () => { judgeCalls++; return new Set(["pack:current-topic", "pack:stale-chat-topic"]); };
|
|
272
|
+
for (const recallEngine of ["discoverer", "classic"]) {
|
|
273
|
+
const callRecall = await chatContext.run({ adapter: { type: "kazee", liveCall: true } }, () =>
|
|
274
|
+
systemPrompt.buildOptionalRecallContext("Current deployment", {
|
|
275
|
+
state: { ...mutableState, settings: { backend: "codex", recallEngine } },
|
|
276
|
+
runContext: { provider: "codex", liveCall: true, purpose: "foreground", project: { name: "project-a", dir: root }, runId: `call-${recallEngine}` },
|
|
277
|
+
}));
|
|
278
|
+
assert.strictEqual(judgeCalls, 0, "live call must reach answer generation without a second model for recall");
|
|
279
|
+
assert.deepStrictEqual(callRecall.metadata.packDirs, ["current-topic"], "retain current-topic keyword memory, exclude stale context-only matches");
|
|
280
|
+
}
|
|
281
|
+
const ordinaryRecall = await chatContext.run({ adapter: { type: "kazee" } }, () =>
|
|
282
|
+
systemPrompt.buildOptionalRecallContext("Current deployment", {
|
|
283
|
+
state: { ...mutableState, settings: { backend: "codex", recallEngine: "classic" } },
|
|
284
|
+
runContext: { provider: "codex", purpose: "foreground", project: { name: "project-a", dir: root }, runId: "ordinary-classic" },
|
|
285
|
+
}));
|
|
286
|
+
assert.strictEqual(judgeCalls, 1, "ordinary chat retains its model-based relevance filter");
|
|
287
|
+
assert.deepStrictEqual(ordinaryRecall.metadata.packDirs, ["current-topic", "stale-chat-topic"]);
|
|
288
|
+
const askRecall = await chatContext.run({ adapter: { type: "kazee", liveCall: true } }, () =>
|
|
289
|
+
systemPrompt.buildOptionalRecallContext("Current deployment", {
|
|
290
|
+
state: { ...mutableState, settings: { backend: "codex", recallEngine: "ask" } },
|
|
291
|
+
runContext: { provider: "codex", liveCall: true, purpose: "foreground", project: { name: "project-a", dir: root }, runId: "call-ask" },
|
|
292
|
+
}));
|
|
293
|
+
assert.strictEqual(askRecall.metadata.engine, "ask", "explicit on-demand recall remains on-demand during calls");
|
|
294
|
+
assert.strictEqual(judgeCalls, 1);
|
|
295
|
+
} finally {
|
|
296
|
+
packsLib.matchPacks = saved.matchPacks;
|
|
297
|
+
entitiesLib.matchEntities = saved.matchEntities;
|
|
298
|
+
transcriptsLib.recentTranscriptText = saved.recentTranscriptText;
|
|
299
|
+
filterLib.judgeRelevance = saved.judgeRelevance;
|
|
300
|
+
}
|
|
301
|
+
|
|
256
302
|
// ── compaction recall policy: the summary meta-turn skips recall entirely;
|
|
257
303
|
// the seed turn matches on the runner-supplied recallQuery (condensed
|
|
258
304
|
// brief + task titles), never the seed prompt's boilerplate ──
|
package/test-provider-runner.js
CHANGED
|
@@ -132,6 +132,40 @@ async function main() {
|
|
|
132
132
|
codex: path.join(root, "codex-capture.json"),
|
|
133
133
|
};
|
|
134
134
|
const providers = makeProviders(captures);
|
|
135
|
+
const voiceStore = { adapter: { liveCall: true, callHistory: [{ role: "user", text: "What about the beeping?" }] } };
|
|
136
|
+
mutable.activeSessions["project-a"].claude = "long-coding-session";
|
|
137
|
+
const voiceContext = captureRunContext({ state: mutable, store: voiceStore, prompt: "What does it mean?", cwd: projectA,
|
|
138
|
+
opts: { resumeSessionId: "long-coding-session", continueSession: true } });
|
|
139
|
+
assert.strictEqual(voiceContext.sessionId, null, "call must not resume the room's coding conversation");
|
|
140
|
+
assert.strictEqual(voiceContext.resumeSessionId, null);
|
|
141
|
+
assert.strictEqual(voiceContext.continueSession, false);
|
|
142
|
+
assert.strictEqual(voiceContext.fresh, true);
|
|
143
|
+
assert.strictEqual(voiceContext.attachSession, false, "call completion must not replace the coding session");
|
|
144
|
+
assert.strictEqual(voiceContext.skipAutoCompact, true, "call cannot compact the room's coding session");
|
|
145
|
+
voiceStore.adapter.callHistory[0].text = "later mutation";
|
|
146
|
+
assert.strictEqual(voiceContext.callHistory[0].text, "What about the beeping?", "queued history is immutable");
|
|
147
|
+
assert.ok(Object.isFrozen(voiceContext.callHistory[0]));
|
|
148
|
+
delete mutable.activeSessions["project-a"].claude;
|
|
149
|
+
|
|
150
|
+
let callObsolete = false;
|
|
151
|
+
let callBuilds = 0;
|
|
152
|
+
let callSpawns = 0;
|
|
153
|
+
const callGate = deferred();
|
|
154
|
+
const cancellableCall = createRunnerService({
|
|
155
|
+
getState: () => mutable,
|
|
156
|
+
getStore: () => voiceStore,
|
|
157
|
+
resolveProvider: id => providers[id],
|
|
158
|
+
isObsolete: () => callObsolete,
|
|
159
|
+
async buildTurnPrompt(prompt, opts) { callBuilds++; await callGate.promise; return simpleBundle(prompt, opts); },
|
|
160
|
+
spawnProcess() { callSpawns++; throw new Error("obsolete call must never spawn"); },
|
|
161
|
+
});
|
|
162
|
+
const preparingCall = cancellableCall.runAgent("old question", projectA, null);
|
|
163
|
+
callObsolete = true;
|
|
164
|
+
callGate.resolve();
|
|
165
|
+
assert.strictEqual((await preparingCall).status, "cancelled", "hangup during recall prevents provider execution");
|
|
166
|
+
assert.strictEqual(callSpawns, 0);
|
|
167
|
+
assert.strictEqual((await cancellableCall.runAgent("late dispatch", projectA, null)).status, "cancelled");
|
|
168
|
+
assert.strictEqual(callBuilds, 1, "obsolete call avoids even a prompt/recall build");
|
|
135
169
|
const persisted = [];
|
|
136
170
|
const delivered = [];
|
|
137
171
|
const service = createRunnerService({
|
package/test-tool-manifest.js
CHANGED
|
@@ -16,6 +16,9 @@ process.env.APPROVALS_DIR = path.join(tmp, "approvals");
|
|
|
16
16
|
const tools = require("./core/tools");
|
|
17
17
|
const repo = require("./core/tool-repo");
|
|
18
18
|
const approvals = require("./core/approvals");
|
|
19
|
+
// The runner may itself be an approved composing tool. These fixtures model
|
|
20
|
+
// top-level calls; keep process.env intact and inject each scenario explicitly.
|
|
21
|
+
const topLevelEnv = {};
|
|
19
22
|
|
|
20
23
|
(async () => {
|
|
21
24
|
// ── scaffold with --verbs → manifest header lines, dispatcher, TOOL.md block ──
|
|
@@ -39,32 +42,32 @@ const approvals = require("./core/approvals");
|
|
|
39
42
|
assert.ok(/list\)/.test(t.content) && /purge\)/.test(t.content), "bash dispatcher has a case per verb");
|
|
40
43
|
|
|
41
44
|
// ── per-verb gate: tier comes from the invoked verb, not the tool tier ──
|
|
42
|
-
assert.strictEqual(tools.runGate(t, ["list"]).ok, true, "read-only verb runs unflagged");
|
|
43
|
-
assert.strictEqual(tools.runGate(t, ["list"]).tier, "read-only");
|
|
44
|
-
assert.strictEqual(tools.runGate(t, ["restart", "n1"]).ok, false, "write verb demands --yes");
|
|
45
|
-
assert.strictEqual(tools.runGate(t, ["restart", "n1", "--yes"]).ok, true);
|
|
46
|
-
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes"]).ok, false, "--yes cannot approve a destructive verb");
|
|
47
|
-
const purgeGate = tools.runGate(t, ["purge", "n1"]);
|
|
45
|
+
assert.strictEqual(tools.runGate(t, ["list"], topLevelEnv).ok, true, "read-only verb runs unflagged");
|
|
46
|
+
assert.strictEqual(tools.runGate(t, ["list"], topLevelEnv).tier, "read-only");
|
|
47
|
+
assert.strictEqual(tools.runGate(t, ["restart", "n1"], topLevelEnv).ok, false, "write verb demands --yes");
|
|
48
|
+
assert.strictEqual(tools.runGate(t, ["restart", "n1", "--yes"], topLevelEnv).ok, true);
|
|
49
|
+
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes"], topLevelEnv).ok, false, "--yes cannot approve a destructive verb");
|
|
50
|
+
const purgeGate = tools.runGate(t, ["purge", "n1"], topLevelEnv);
|
|
48
51
|
assert.strictEqual(purgeGate.tier, "destructive", "failed gate reports the resolved tier for the approval fallback");
|
|
49
|
-
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes-destructive"]).ok, true);
|
|
52
|
+
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes-destructive"], topLevelEnv).ok, true);
|
|
50
53
|
|
|
51
54
|
// ── undeclared verb on a manifest tool is refused regardless of flags ──
|
|
52
|
-
const un = tools.runGate(t, ["obliterate", "--yes-destructive"]);
|
|
55
|
+
const un = tools.runGate(t, ["obliterate", "--yes-destructive"], topLevelEnv);
|
|
53
56
|
assert.strictEqual(un.ok, false, "undeclared verb refused");
|
|
54
57
|
assert.strictEqual(un.undeclared, true);
|
|
55
58
|
assert.ok(/no declared verb "obliterate"/.test(un.message));
|
|
56
59
|
|
|
57
60
|
// ── help / no verb = read-only ──
|
|
58
|
-
assert.strictEqual(tools.runGate(t, []).ok, true, "no verb (help) runs unflagged");
|
|
59
|
-
assert.strictEqual(tools.runGate(t, ["help"]).tier, "read-only");
|
|
61
|
+
assert.strictEqual(tools.runGate(t, [], topLevelEnv).ok, true, "no verb (help) runs unflagged");
|
|
62
|
+
assert.strictEqual(tools.runGate(t, ["help"], topLevelEnv).tier, "read-only");
|
|
60
63
|
|
|
61
64
|
// ── manifest-less tools keep the old whole-tool gate (backward compatible) ──
|
|
62
65
|
const legacySrc = path.join(tmp, "old.sh");
|
|
63
66
|
fs.writeFileSync(legacySrc, "#!/usr/bin/env bash\necho old\n");
|
|
64
67
|
const legacy = tools.addTool(legacySrc, { name: "old", description: "legacy", risk: "write" });
|
|
65
68
|
assert.strictEqual(legacy.manifest.length, 0);
|
|
66
|
-
assert.strictEqual(tools.runGate(legacy, ["anything"]).ok, false, "manifest-less write tool still needs --yes");
|
|
67
|
-
assert.strictEqual(tools.runGate(legacy, ["anything", "--yes"]).ok, true, "…and any verb is allowed with it");
|
|
69
|
+
assert.strictEqual(tools.runGate(legacy, ["anything"], topLevelEnv).ok, false, "manifest-less write tool still needs --yes");
|
|
70
|
+
assert.strictEqual(tools.runGate(legacy, ["anything", "--yes"], topLevelEnv).ok, true, "…and any verb is allowed with it");
|
|
68
71
|
|
|
69
72
|
// ── nested composition still gates on the inherited tier, per verb ──
|
|
70
73
|
const env = { OPEN_CLAUDIA_TOOL_STACK: "outer", OPEN_CLAUDIA_APPROVED_TIER: "write" };
|
|
@@ -119,6 +122,12 @@ const approvals = require("./core/approvals");
|
|
|
119
122
|
approvals.decide(b10rec.id, "approved", "c1");
|
|
120
123
|
const errs = [];
|
|
121
124
|
const realErr = console.error;
|
|
125
|
+
// This integration fixture redeems a top-level approval against the isolated
|
|
126
|
+
// tools/keyring above. A nested tool must refuse redemption, so temporarily
|
|
127
|
+
// model the top-level CLI environment and restore the launcher's context.
|
|
128
|
+
const approvalKeys = ["OPEN_CLAUDIA_TOOL_STACK", "OPEN_CLAUDIA_APPROVED_TIER"];
|
|
129
|
+
const savedApprovalEnv = Object.fromEntries(approvalKeys.map((key) => [key, process.env[key]]));
|
|
130
|
+
for (const key of approvalKeys) delete process.env[key];
|
|
122
131
|
console.error = (...a) => errs.push(a.join(" "));
|
|
123
132
|
try {
|
|
124
133
|
process.exitCode = 0;
|
|
@@ -134,6 +143,10 @@ const approvals = require("./core/approvals");
|
|
|
134
143
|
assert.strictEqual(process.exitCode, 0, "the same token redeems once the key exists");
|
|
135
144
|
assert.ok(approvals.read(b10rec.id).consumedAt, "token consumed exactly by the real run");
|
|
136
145
|
} finally {
|
|
146
|
+
for (const key of approvalKeys) {
|
|
147
|
+
if (savedApprovalEnv[key] === undefined) delete process.env[key];
|
|
148
|
+
else process.env[key] = savedApprovalEnv[key];
|
|
149
|
+
}
|
|
137
150
|
console.error = realErr;
|
|
138
151
|
process.exitCode = 0;
|
|
139
152
|
}
|
package/test-tools.js
CHANGED
|
@@ -250,14 +250,18 @@ assert.strictEqual(tools.findTool("hello").createdAt, bornHello, "createdAt is i
|
|
|
250
250
|
|
|
251
251
|
// ── runGate: read-only runs freely; write demands --yes; destructive demands
|
|
252
252
|
// --yes-destructive (a plain --yes must NOT unlock it); undeclared → write ──
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
assert.ok(tools.runGate({ name: "t", risk: "
|
|
258
|
-
|
|
253
|
+
// These fixtures model top-level invocations even when npm test is launched
|
|
254
|
+
// through an approved tool. Keep the real process approval environment intact;
|
|
255
|
+
// nested composition is exercised with explicit environments below.
|
|
256
|
+
const topLevelEnv = {};
|
|
257
|
+
assert.ok(tools.runGate({ name: "t", risk: "read-only" }, [], topLevelEnv).ok, "read-only runs without flags");
|
|
258
|
+
assert.ok(!tools.runGate({ name: "t", risk: "write" }, [], topLevelEnv).ok, "write without --yes is refused");
|
|
259
|
+
assert.ok(tools.runGate({ name: "t", risk: "write" }, ["--yes"], topLevelEnv).ok, "write with --yes runs");
|
|
260
|
+
assert.ok(!tools.runGate({ name: "t", risk: "destructive" }, ["--yes"], topLevelEnv).ok, "plain --yes does not unlock destructive");
|
|
261
|
+
assert.ok(tools.runGate({ name: "t", risk: "destructive" }, ["--yes-destructive"], topLevelEnv).ok, "destructive with --yes-destructive runs");
|
|
262
|
+
const undeclared = tools.runGate({ name: "t", risk: "" }, [], topLevelEnv);
|
|
259
263
|
assert.ok(!undeclared.ok && undeclared.tier === "write", "undeclared tier is treated as write (fail-safe)");
|
|
260
|
-
assert.ok(/destructive/i.test(tools.runGate({ name: "t", risk: "destructive" }, []).message), "destructive refusal spells out the warning");
|
|
264
|
+
assert.ok(/destructive/i.test(tools.runGate({ name: "t", risk: "destructive" }, [], topLevelEnv).message), "destructive refusal spells out the warning");
|
|
261
265
|
|
|
262
266
|
// ── scaffold: creates dir + stub + TOOL.md, registered and runnable ──
|
|
263
267
|
const sc = repo.scaffoldTool("kwidget", { description: "widget system CLI", risk: "read-only", pack: "widgets", usage: "kwidget <verb>" });
|