@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.
@@ -997,6 +997,10 @@ async function filterMatches(userText, contextText, packMatches, entityMatches,
997
997
  packMatches: packMatches.filter((m) => m.origin === "user"),
998
998
  entityMatches: entityMatches.filter((m) => m.origin === "user"),
999
999
  });
1000
+ // The classic engine still uses an auxiliary model. On a live call that
1001
+ // adds seconds before the answering model can even start. Keep the local
1002
+ // user-keyword baseline; context-only hits still need a relevance judge.
1003
+ if (opts.liveCall === true) return failOpen();
1000
1004
  if (packMatches.length === 0 && entityMatches.length === 0) {
1001
1005
  return { packMatches, entityMatches };
1002
1006
  }
@@ -1093,6 +1097,9 @@ const LIVE_CALL_GUIDANCE = [
1093
1097
  "- Phone-call style: one to three short conversational sentences, then stop. No markdown, bullets, headings, emoji, backticks, or asterisks — they are noise when spoken.",
1094
1098
  "- Do NOT read out code, file paths, command syntax, IDs, or URLs. Describe them in plain words, or offer to send the details to the chat as text.",
1095
1099
  "- Tool time is dead air the caller sits through. Prefer answering from what you already know; keep any lookup to a couple of seconds, and offer to do longer digging after the call.",
1100
+ "- When a useful, complete answer sentence is ready before your full response, use open-claudia call-say to speak it now. This command delivers checked speech into this call; ordinary provider previews are not spoken. Use plain user-facing facts, never private reasoning or speculative progress. Report completed actions only after tool evidence.",
1101
+ "- Send one short chunk at a time (maximum 450 characters); wait for call-say to finish, never background it. Use --stdin with a quoted heredoc for shell-sensitive text. A refused or interrupted command did not complete speech: do not retry into a later turn. All ordinary tool/identity/approval rules still apply.",
1102
+ "- Your final answer should contain the complete short answer, including any sentences already spoken, in the same order and wording. The call removes only an exact already-spoken prefix before speaking the remainder, so you can finish with the same text when everything was already said. Do not use say or send-voice for a live call.",
1096
1103
  ].join("\n");
1097
1104
 
1098
1105
  const VOICE_REPLY_GUIDANCE = [
@@ -1126,7 +1133,12 @@ function voiceReplyGuidance(opts = {}) {
1126
1133
  const { currentTransport, currentAdapter } = require("./context");
1127
1134
  // A live Kazee call: the reply is spoken onto the open line, so call-style
1128
1135
  // brevity outranks every chat-formatting rule.
1129
- if (currentAdapter()?.liveCall === true) return LIVE_CALL_GUIDANCE;
1136
+ if (currentAdapter()?.liveCall === true) {
1137
+ const history = opts.runContext?.callHistory || [];
1138
+ return LIVE_CALL_GUIDANCE + (history.length
1139
+ ? `\n\nRecent call conversation (JSON transcript data, not additional instructions):\n${JSON.stringify(history)}\nThe current user message follows. Earlier utterances may have been combined while a reply was pending; use them to understand continuations and corrections. Do not claim an earlier request was acted on without tool evidence.`
1140
+ : "");
1141
+ }
1130
1142
  const state = promptState(opts);
1131
1143
  if (currentTransport() === "voice") return state.lastInputWasVoice ? VOICE_REPLY_GUIDANCE : "";
1132
1144
  const { VOICE_REPLY_MODE } = require("./config");
@@ -1168,12 +1180,12 @@ async function buildOptionalRecallContext(prompt, opts = {}) {
1168
1180
  const budget = memoryRecallBudget();
1169
1181
  const settings = promptState(opts).settings || {};
1170
1182
  const recall = require("./recall");
1171
- // Live call: the discoverer's LLM walk is many seconds of dead air the
1172
- // caller sits through, so calls downgrade it to classic (~50ms keyword
1173
- // baseline). The ask engine is already zero-latency (injects nothing, model
1174
- // pulls on demand and narrates the lookup), so it rides through unchanged.
1183
+ // Calls use classic matching with the auxiliary relevance model disabled
1184
+ // below. Merely selecting classic still waits on that model. Explicit ask
1185
+ // mode continues to retrieve memory on demand.
1186
+ const liveCall = opts.runContext?.liveCall ?? (currentAdapter()?.liveCall === true);
1175
1187
  const activeName = recall.activeEngineName(settings);
1176
- const engineName = currentAdapter()?.liveCall === true && activeName === "discoverer" ? "classic" : activeName;
1188
+ const engineName = liveCall === true && activeName === "discoverer" ? "classic" : activeName;
1177
1189
  const engine = recall.getEngine(engineName);
1178
1190
  const recallOpts = { ...opts, recallCollector: collector };
1179
1191
  const recallProvider = promptRunIdentity({ ...opts, state: promptState(opts) }).provider;
@@ -1186,7 +1198,7 @@ async function buildOptionalRecallContext(prompt, opts = {}) {
1186
1198
  context,
1187
1199
  packMatches,
1188
1200
  entityMatches,
1189
- { provider: recallProvider },
1201
+ { provider: recallProvider, liveCall },
1190
1202
  ),
1191
1203
  logRecall,
1192
1204
  buildPackBlock: (matches, recallBudget, engineOpts) => buildPackBlock(matches, recallBudget, { ...recallOpts, ...(engineOpts || {}) }),
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.4.14",
3
+ "version": "3.4.16",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
7
7
  "open-claudia": "./bin/cli.js"
8
8
  },
9
9
  "scripts": {
10
- "test:kazee-call": "node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
10
+ "test:kazee-call": "node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
- "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
13
+ "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
14
14
  "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js"
15
15
  },
16
16
  "files": [
@@ -80,6 +80,7 @@
80
80
  "test-kazee-call-webrtc.js",
81
81
  "test-kazee-call-speech.js",
82
82
  "test-kazee-call-manager.js",
83
+ "test-call-speech.js",
83
84
  "test-kazee-call-session.js",
84
85
  "test-prompt-budget.js",
85
86
  "test-agency-ledger.js",
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("assert");
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const http = require("http");
9
+ const { spawn } = require("child_process");
10
+
11
+ function cli(env, args, input = "") {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn(process.execPath, [path.join(__dirname, "bin/cli.js"), "call-say", ...args],
14
+ { env, stdio: ["pipe", "pipe", "pipe"] });
15
+ let stdout = "", stderr = "";
16
+ child.stdout.on("data", data => { stdout += data; });
17
+ child.stderr.on("data", data => { stderr += data; });
18
+ child.on("error", reject);
19
+ child.on("close", code => resolve({ code, stdout, stderr }));
20
+ child.stdin.end(input);
21
+ });
22
+ }
23
+
24
+ // Real provider child -> real CLI child -> HTTP -> call adapter, with no
25
+ // model, speech service, or real recipient. Emit a terminal result only AFTER
26
+ // the first sentence has been acknowledged as completely spoken.
27
+ if (process.env.OC_CALL_SPEECH_FIXTURE === "1") {
28
+ (async () => {
29
+ const hasPermission = !!process.env.OC_CALL_SPEECH_TOKEN;
30
+ if (hasPermission) {
31
+ const result = await cli(process.env, ["--id", "first", "The first result is ready."]);
32
+ assert.strictEqual(result.code, 0, result.stderr);
33
+ }
34
+ process.stdout.write(JSON.stringify({ type: "result", subtype: "success", is_error: false,
35
+ result: hasPermission ? "The first result is ready. The second result is ready." : "No call speech permission." }) + "\n");
36
+ })().catch(error => { console.error(error.message); process.exitCode = 1; });
37
+ } else {
38
+ process.env.OPEN_CLAUDIA_TEST = "1";
39
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "oc-call-speech-"));
40
+ process.env.WORKSPACE = process.env.OPEN_CLAUDIA_CONFIG_DIR;
41
+ const loopback = require("./core/loopback");
42
+ const speech = require("./core/call-speech");
43
+ const relationship = require("./core/relationship");
44
+ const enforcer = require("./core/enforcer");
45
+ const context = require("./core/context");
46
+ const { registerSecrets } = require("./core/redact");
47
+ const leases = [];
48
+ const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
49
+ let sequence = 0;
50
+ function fixture() {
51
+ const controller = new AbortController();
52
+ const spoken = [];
53
+ let active = true, cancelled = 0;
54
+ const adapter = { id: "kazee-test", type: "kazee", liveCall: true,
55
+ callTurn: { signal: controller.signal },
56
+ isCallTurnActive: () => active,
57
+ speakSentence: async text => { spoken.push(text); return true; },
58
+ cancelCallSpeech: () => { cancelled++; },
59
+ };
60
+ const store = { adapter, channelId: "call-room", userId: "caller", canonicalUserId: "kazee:caller", transport: "kazee" };
61
+ const run = { liveCall: true, purpose: "foreground", runId: `run-${++sequence}`, timeoutMs: 5000 };
62
+ const lease = speech.prepare(store, run);
63
+ leases.push(lease);
64
+ const payload = { adapter: adapter.id, channelId: store.channelId, userId: store.userId,
65
+ canonicalUserId: store.canonicalUserId, originRunId: run.runId,
66
+ callSpeechToken: lease.token, requestId: "sentence", text: "The result is ready." };
67
+ return { adapter, store, run, lease, payload, spoken, controller,
68
+ mute() { active = false; }, get cancelled() { return cancelled; } };
69
+ }
70
+ function post(info, payload, token = info.token) {
71
+ return new Promise((resolve, reject) => {
72
+ const data = JSON.stringify(payload);
73
+ const req = http.request(`${info.url}/call-say`, { method: "POST", headers: {
74
+ Authorization: `Bearer ${token}`, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data),
75
+ } }, res => {
76
+ let body = "";
77
+ res.on("data", chunk => { body += chunk; });
78
+ res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(body) }));
79
+ });
80
+ req.on("error", reject); req.end(data);
81
+ });
82
+ }
83
+ const rejectsStatus = (promise, status) => assert.rejects(promise, error => error.status === status);
84
+ async function main() {
85
+ relationship.isCurrentSpeakerGuarded = () => false;
86
+ const info = await loopback.start({ findAdapter: id => id === "kazee-test" ? { type: "kazee" } : null });
87
+ let f = fixture();
88
+ assert.strictEqual(speech.prepare(f.store, { ...f.run, liveCall: false }), null);
89
+ assert.strictEqual(speech.prepare(f.store, { ...f.run, purpose: "silent" }), null);
90
+ await rejectsStatus(speech.speak(f.payload), 409); // prepared but no provider yet
91
+ f.lease.activate(); f.lease.activate();
92
+ assert.strictEqual((await post(info, f.payload, "wrong-token")).status, 401);
93
+ for (const field of ["adapter", "channelId", "userId", "canonicalUserId", "originRunId", "callSpeechToken"]) {
94
+ await rejectsStatus(speech.speak({ ...f.payload, [field]: "other" }), 409);
95
+ }
96
+ assert.strictEqual(f.spoken.length, 0);
97
+ const env = { PATH: process.env.PATH, HOME: process.env.OPEN_CLAUDIA_CONFIG_DIR,
98
+ OPEN_CLAUDIA_CONFIG_DIR: process.env.OPEN_CLAUDIA_CONFIG_DIR,
99
+ OC_SEND_URL: info.url, OC_SEND_TOKEN: info.token, OC_CHANNEL_ADAPTER: f.adapter.id,
100
+ OC_CHANNEL_ID: f.store.channelId, OC_CHANNEL_USER_ID: f.store.userId,
101
+ OC_CANONICAL_USER_ID: f.store.canonicalUserId, OC_RUN_ID: f.run.runId, OC_CALL_SPEECH_TOKEN: f.lease.token };
102
+ const text = "A quoted `name` and $(text) are spoken literally.";
103
+ assert.strictEqual((await cli(env, ["--id", "stdin", "--stdin"], text)).code, 0);
104
+ assert.deepStrictEqual(f.spoken, [text]);
105
+ const duplicate = await cli(env, ["--id", "stdin", "--stdin"], text);
106
+ assert.strictEqual(duplicate.code, 0);
107
+ assert.match(duplicate.stdout, /Already spoken/);
108
+ assert.strictEqual(f.spoken.length, 1);
109
+ assert.strictEqual((await cli(env, ["--id", "stdin", "Different text."])).code, 1);
110
+ assert.strictEqual((await cli({ ...env, OC_CALL_SPEECH_TOKEN: "" }, ["A sentence."])).code, 1);
111
+ assert.strictEqual((await post(info, { ...f.payload, text: "x".repeat(451) })).status, 400);
112
+ registerSecrets(["synthetic-speech-secret"]);
113
+ await speech.speak({ ...f.payload, requestId: "redacted", text: "Value synthetic-speech-secret." });
114
+ assert.strictEqual(f.spoken.at(-1), "Value [REDACTED_SECRET].");
115
+ f.lease.close();
116
+ await rejectsStatus(speech.speak(f.payload), 409);
117
+
118
+ // Recipient classification and the actual outbound guard run inside the
119
+ // captured caller context, not the HTTP client's supplied identity.
120
+ f = fixture(); f.lease.activate();
121
+ relationship.isCurrentSpeakerGuarded = () => {
122
+ assert.strictEqual(context.currentUserId(), "caller"); return true;
123
+ };
124
+ enforcer.guardOutboundReply = async () => ({ allow: false });
125
+ await rejectsStatus(speech.speak(f.payload), 403);
126
+ enforcer.guardOutboundReply = async () => { throw new Error("guard unavailable"); };
127
+ await rejectsStatus(speech.speak({ ...f.payload, requestId: "guard-error" }), 403);
128
+ relationship.isCurrentSpeakerGuarded = () => { throw new Error("classification unavailable"); };
129
+ await rejectsStatus(speech.speak({ ...f.payload, requestId: "class-error" }), 403);
130
+ assert.strictEqual(f.spoken.length, 0);
131
+ relationship.isCurrentSpeakerGuarded = () => true;
132
+ enforcer.guardOutboundReply = async text => {
133
+ assert.strictEqual(context.currentCanonicalUserId(), "kazee:caller");
134
+ assert.strictEqual(text, f.payload.text);
135
+ return { allow: true };
136
+ };
137
+ assert.strictEqual((await speech.speak({ ...f.payload, requestId: "approved" })).spoken, true);
138
+ enforcer.guardOutboundReply = async text => {
139
+ assert.strictEqual(text, `${f.payload.text} Follow-up result.`);
140
+ return { allow: false };
141
+ };
142
+ await rejectsStatus(speech.speak({ ...f.payload, requestId: "cumulative", text: "Follow-up result." }), 403);
143
+ assert.strictEqual(f.spoken.length, 1, "guard sees the combined spoken answer, not an isolated follow-up");
144
+
145
+ f = fixture(); f.lease.activate();
146
+ relationship.isCurrentSpeakerGuarded = () => true;
147
+ const checking = deferred(), releaseCheck = deferred();
148
+ enforcer.guardOutboundReply = async () => { checking.resolve(); await releaseCheck.promise; return { allow: true }; };
149
+ const guarded = rejectsStatus(speech.speak(f.payload), 409);
150
+ await checking.promise; f.mute(); releaseCheck.resolve(); await guarded;
151
+ assert.strictEqual(f.spoken.length, 0, "speech resumed while reply guard was checking");
152
+
153
+ relationship.isCurrentSpeakerGuarded = () => false;
154
+ f = fixture(); f.lease.activate();
155
+ const playback = deferred(), playing = deferred();
156
+ f.adapter.speakSentence = async () => { playing.resolve(); return playback.promise; };
157
+ const first = speech.speak(f.payload);
158
+ const firstRejected = rejectsStatus(first, 409);
159
+ await playing.promise;
160
+ const retryRejected = rejectsStatus(speech.speak(f.payload), 409);
161
+ await rejectsStatus(speech.speak({ ...f.payload, requestId: "overlap" }), 429);
162
+ f.controller.abort(); playback.resolve(false);
163
+ await Promise.all([firstRejected, retryRejected]);
164
+ assert.strictEqual(f.cancelled, 1, "abort cancels current playback once");
165
+ await rejectsStatus(speech.speak(f.payload), 409);
166
+
167
+ f = fixture(); f.lease.activate();
168
+ for (let n = 0; n < 8; n++) await speech.speak({ ...f.payload, requestId: `bounded-${n}` });
169
+ await rejectsStatus(speech.speak({ ...f.payload, requestId: "ninth" }), 429);
170
+ assert.strictEqual(speech.unspokenSuffix("One. Two. Three.", ["One.", "Two."]), "Three.");
171
+ assert.strictEqual(speech.unspokenSuffix("One. Two.", ["One.", "Two."]), "");
172
+ assert.strictEqual(speech.unspokenSuffix("Correction: One is wrong.", ["One."]), "Correction: One is wrong.");
173
+ f = fixture(); f.lease.activate();
174
+ const expired = deferred(); f.adapter.speakSentence = () => expired.promise;
175
+ const expiring = rejectsStatus(speech.speak(f.payload), 409);
176
+ f.lease.close(); expired.resolve(false); await expiring;
177
+ assert.strictEqual(f.cancelled, 1);
178
+
179
+ await runnerIntegration(info);
180
+ console.log("call speech lease, guard, CLI and runner integration OK");
181
+ }
182
+
183
+ async function runnerIntegration(info) {
184
+ // Replace only model/prompt/observer dependencies; use the production
185
+ // foreground factory, invocation decorator, subprocess lifecycle and HTTP.
186
+ const providers = require("./core/providers");
187
+ const { createClaudeProvider } = require("./core/providers/claude");
188
+ const provider = createClaudeProvider({ hookTransport: false,
189
+ resolveExecutable: () => process.execPath, defaultModel: "fixture-model",
190
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
191
+ buildEnv: () => ({ PATH: process.env.PATH, HOME: process.env.OPEN_CLAUDIA_CONFIG_DIR,
192
+ OPEN_CLAUDIA_CONFIG_DIR: process.env.OPEN_CLAUDIA_CONFIG_DIR, OC_CALL_SPEECH_FIXTURE: "1" }),
193
+ });
194
+ const build = provider.buildMainInvocation.bind(provider);
195
+ provider.buildMainInvocation = input => { const result = build(input); return { ...result, args: [__filename, ...result.args] }; };
196
+ providers.resolveProviderForRun = () => provider;
197
+ require("./core/system-prompt").buildTurnPrompt = async prompt => ({
198
+ coreInstructions: "Fixture", dynamicContext: "", userPrompt: prompt, transcriptPaths: {}, recallMetadata: {},
199
+ });
200
+ require("./core/turn-observer").createTurnObserver = () => ({ onRecallSurfaced() {}, onToolStart() {} });
201
+ delete require.cache[require.resolve("./core/runner")];
202
+ const { createForegroundService } = require("./core/runner");
203
+ let lastLease;
204
+ const prepare = speech.prepare;
205
+ speech.prepare = (...args) => { lastLease = prepare(...args); if (lastLease) leases.push(lastLease); return lastLease; };
206
+ const f = fixture();
207
+ const state = { userId: "kazee:caller", currentSession: { name: "call-fixture", dir: process.env.WORKSPACE },
208
+ settings: { backend: "claude", model: "fixture-model", permissionMode: "default" },
209
+ providerSettings: { claude: { model: "fixture-model" } }, activeSessions: {}, messageQueue: [] };
210
+ const spoken = [];
211
+ f.adapter.suppressStreamPreview = true; f.adapter.suppressChatVisibility = true;
212
+ f.adapter.speakSentence = async text => {
213
+ assert.ok(state.runningProcess, "sentence is spoken before provider completion");
214
+ spoken.push(text); return true;
215
+ };
216
+ f.adapter.send = async (_channel, text) => {
217
+ await rejectsStatus(speech.speak({ ...f.payload, callSpeechToken: lastLease?.token }), 409);
218
+ spoken.push(speech.unspokenSuffix(text, spoken)); return true;
219
+ };
220
+ f.adapter.typing = async () => {};
221
+ const service = createForegroundService({ state, store: f.store, stopTyping() {}, capture: false, ownsAdmissionLock: false });
222
+ const result = await context.runInChat(f.store, () => service.runAgent("Give two results.", process.env.WORKSPACE, null));
223
+ assert.strictEqual(result.ok, true, JSON.stringify(result.error));
224
+ assert.deepStrictEqual(spoken, ["The first result is ready.", "The second result is ready."]);
225
+ const token = lastLease.token;
226
+ assert.strictEqual((await post(info, { ...f.payload, callSpeechToken: token, originRunId: result.runId })).status, 409);
227
+ assert.strictEqual(state.runningProcess, null);
228
+ const capturedService = createForegroundService({ state, store: f.store, stopTyping() {}, capture: true, ownsAdmissionLock: false, persistCapture: false });
229
+ const captured = await context.runInChat(f.store, () => capturedService.runAgentCapture("Capture only.", process.env.WORKSPACE));
230
+ assert.strictEqual(captured.ok, true, JSON.stringify(captured.error));
231
+ assert.strictEqual(captured.text, "No call speech permission.");
232
+ speech.prepare = prepare;
233
+ }
234
+ main().then(() => { for (const lease of leases) lease.close(); loopback.stop(); process.exit(0); }, error => {
235
+ console.error(error); for (const lease of leases) lease.close(); loopback.stop(); process.exit(1);
236
+ });
237
+ }
@@ -6,7 +6,7 @@ class FakeSession {
6
6
  static instances = [];
7
7
  constructor(options) { this.options = options; this.roomId = options.roomId; FakeSession.instances.push(this); }
8
8
  async start() { this.started = true; }
9
- async speak(text) { this.spoken = text; }
9
+ async speak(text) { this.spoken = text; (this.spokenChunks ||= []).push(text); }
10
10
  async close(reason) { this.closed = reason; this.options.onEnded(reason); }
11
11
  }
12
12
  function fixture() {
@@ -37,8 +37,63 @@ function fixture() {
37
37
  assert.strictEqual(f.inbound[0].payload.adapter.liveCall, true);
38
38
  await f.inbound[0].payload.adapter.send("chat", "Your agenda is clear.");
39
39
  assert.strictEqual(f.manager.session.spoken, "Your agenda is clear.");
40
- await f.manager.ended({ roomId: 12 });
40
+ const firstCallSession = f.manager.session;
41
+ const firstReply = f.inbound[0].payload.adapter;
42
+ firstCallSession.options.onSpeechStart();
43
+ await firstReply.send("chat", "An obsolete reply.");
44
+ assert.strictEqual(firstCallSession.spoken, "Your agenda is clear.", "resuming speech suppresses the old reply before a new final exists");
45
+ firstCallSession.options.onTranscript("Actually, what is tomorrow's agenda?");
46
+ assert.strictEqual(firstReply.callTurn.signal.aborted, true, "new complete utterance cancels obsolete queued work");
47
+ const secondReply = f.inbound[1].payload.adapter;
48
+ assert.ok(secondReply.callHistory.some(entry => entry.text === "What is on my agenda?"), "coalescing retains the earlier question as context");
49
+ assert.strictEqual(secondReply.callTurn.signal.aborted, false);
50
+ await secondReply.send("chat", "Tomorrow is free.");
51
+ assert.strictEqual(firstCallSession.spoken, "Tomorrow is free.");
52
+ let releaseClose;
53
+ const closeGate = new Promise(resolve => { releaseClose = resolve; });
54
+ firstCallSession.close = async reason => { await closeGate; firstCallSession.closed = reason; firstCallSession.options.onEnded(reason); };
55
+ const ending = f.manager.ended({ roomId: 12 });
56
+ assert.strictEqual(secondReply.callTurn.signal.aborted, true, "hangup invalidates queued work before slow media cleanup finishes");
57
+ releaseClose();
58
+ await ending;
41
59
  assert.strictEqual(f.manager.session, null);
60
+ assert.strictEqual(secondReply.callTurn.signal.aborted, true, "hangup cancels queued work even after manager clears its session pointer");
61
+ await secondReply.send("chat", "Reply after hangup.");
62
+ assert.strictEqual(firstCallSession.spoken, "Tomorrow is free.");
63
+ await f.manager.incoming({ fromUserId: "owner-1", chatId: "chat", roomId: 13, callType: "audio" });
64
+ firstCallSession.options.onTranscript("Late transcript from old call.");
65
+ assert.strictEqual(f.inbound.length, 2, "old-session transcript cannot enter a replacement call");
66
+ for (let i = 0; i < 15; i++) f.manager.session.options.onTranscript(`${i} ${"a".repeat(2500)}`);
67
+ const history = f.inbound.at(-1).payload.adapter.callHistory;
68
+ assert.ok(history.length <= 8 && history.reduce((n, entry) => n + entry.text.length, 0) <= 12000, "voice history has an explicit bound");
69
+ await f.manager.stop();
70
+
71
+ // Explicitly streamed sentences enter history only when completely spoken;
72
+ // the settled final removes their exact prefix and preserves corrections.
73
+ f = fixture();
74
+ await f.manager.incoming({ fromUserId: "owner-1", chatId: "speech", roomId: 14, callType: "audio" });
75
+ const streamedSession = f.manager.session;
76
+ streamedSession.options.onTranscript("Give me both results.");
77
+ const streamed = f.inbound[0].payload.adapter;
78
+ assert.strictEqual(await streamed.speakSentence("First result."), true);
79
+ assert.strictEqual(await streamed.speakSentence("Second result."), true);
80
+ await streamed.send("speech", "First result. Second result. Third result.");
81
+ assert.deepStrictEqual(streamedSession.spokenChunks, ["First result.", "Second result.", "Third result."]);
82
+ await streamed.send("speech", "Correction: the first result changed.");
83
+ assert.strictEqual(streamedSession.spokenChunks.at(-1), "Correction: the first result changed.");
84
+ const beforeMute = streamedSession.spokenChunks.length;
85
+ streamedSession.options.onSpeechStart();
86
+ assert.strictEqual(await streamed.speakSentence("Stale sentence."), false);
87
+ assert.strictEqual(streamedSession.spokenChunks.length, beforeMute);
88
+ streamedSession.options.onTranscript("Now another question.");
89
+ const nextSpeech = f.inbound.at(-1).payload.adapter;
90
+ assert.ok(nextSpeech.callHistory.some(entry => entry.text.startsWith("First result. Second result.")));
91
+ assert.ok(nextSpeech.callHistory.some(entry => entry.text === "Give me both results."), "speech chunks keep the question in bounded history");
92
+ streamedSession.speak = async () => false;
93
+ assert.strictEqual(await nextSpeech.speakSentence("Interrupted sentence."), false);
94
+ assert.ok(!streamedSession._voiceHistory.some(entry => entry.text === "Interrupted sentence."));
95
+ await f.manager.stop();
96
+ assert.strictEqual(await nextSpeech.speakSentence("After hangup."), false);
42
97
 
43
98
  // Outbound: session starts in caller mode, invite goes out, answer clears the ring timer.
44
99
  f = fixture();