@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.
@@ -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, "released commit acks and thinks like a normal turn");
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
- // Caller finishes a sentence: speech then sustained silence commits the turn,
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 > 0, "commit plays the ack chime");
403
- assert(session._thinkTimer, "thinking loop starts at commit, before the transcript");
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
 
@@ -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
- assert.strictEqual(pkg.version, "3.4.14", "release version must remain unchanged without explicit approval");
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
- process.stdout.write(`${JSON.stringify({ bundle })}\n`);
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,
@@ -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 ──
@@ -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({
@@ -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
- assert.ok(tools.runGate({ name: "t", risk: "read-only" }, []).ok, "read-only runs without flags");
254
- assert.ok(!tools.runGate({ name: "t", risk: "write" }, []).ok, "write without --yes is refused");
255
- assert.ok(tools.runGate({ name: "t", risk: "write" }, ["--yes"]).ok, "write with --yes runs");
256
- assert.ok(!tools.runGate({ name: "t", risk: "destructive" }, ["--yes"]).ok, "plain --yes does not unlock destructive");
257
- assert.ok(tools.runGate({ name: "t", risk: "destructive" }, ["--yes-destructive"]).ok, "destructive with --yes-destructive runs");
258
- const undeclared = tools.runGate({ name: "t", risk: "" }, []);
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>" });