verbalcoding 0.2.12 → 0.2.13
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/.env.example +74 -4
- package/README.es.md +3 -1
- package/README.fr.md +3 -1
- package/README.ja.md +3 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/app-node/agent_adapters.test.mjs +14 -0
- package/app-node/agent_routing.mjs +148 -0
- package/app-node/agent_routing.test.mjs +138 -0
- package/app-node/agent_turn.mjs +86 -0
- package/app-node/agent_turn.test.mjs +109 -0
- package/app-node/bridge_context.mjs +73 -0
- package/app-node/bridge_context.test.mjs +54 -0
- package/app-node/bridge_state.mjs +4 -0
- package/app-node/bridge_wireup.test.mjs +462 -0
- package/app-node/cli_install.test.mjs +31 -0
- package/app-node/cross_agent_routing.test.mjs +78 -0
- package/app-node/discord_command_router.mjs +204 -0
- package/app-node/discord_command_router.test.mjs +311 -0
- package/app-node/discord_voice_setup.mjs +251 -0
- package/app-node/discord_voice_setup.test.mjs +86 -0
- package/app-node/hermes_profiles.test.mjs +12 -1
- package/app-node/install_config.mjs +110 -3
- package/app-node/install_config.test.mjs +8 -0
- package/app-node/instance_doctor.test.mjs +9 -0
- package/app-node/instances.test.mjs +8 -1
- package/app-node/main.mjs +488 -1368
- package/app-node/mcp_tools.test.mjs +7 -0
- package/app-node/notification_handler.mjs +89 -0
- package/app-node/notification_handler.test.mjs +187 -0
- package/app-node/plan_dispatcher.mjs +215 -0
- package/app-node/plan_dispatcher.test.mjs +101 -0
- package/app-node/plan_mode.mjs +36 -7
- package/app-node/plan_mode.test.mjs +78 -0
- package/app-node/progress_handler.mjs +220 -0
- package/app-node/progress_handler.test.mjs +193 -0
- package/app-node/progress_speech.mjs +54 -32
- package/app-node/progress_speech.test.mjs +12 -3
- package/app-node/project_sessions.mjs +5 -2
- package/app-node/project_sessions.test.mjs +7 -0
- package/app-node/research_mode.mjs +282 -0
- package/app-node/research_mode.test.mjs +264 -0
- package/app-node/restart_notice.mjs +3 -0
- package/app-node/restart_notice.test.mjs +11 -0
- package/app-node/session_ontology.mjs +271 -0
- package/app-node/session_ontology.test.mjs +130 -0
- package/app-node/smart_progress.mjs +1 -1
- package/app-node/stream_sentencer.mjs +32 -2
- package/app-node/stream_sentencer.test.mjs +65 -0
- package/app-node/streaming_tts_queue.mjs +5 -1
- package/app-node/streaming_tts_queue.test.mjs +7 -1
- package/app-node/stt_whisper.mjs +24 -0
- package/app-node/stt_whisper.test.mjs +32 -0
- package/app-node/text_routing.mjs +4 -2
- package/app-node/tts_backends.mjs +537 -3
- package/app-node/tts_backends.test.mjs +454 -0
- package/app-node/tts_player.mjs +164 -0
- package/app-node/tts_player.test.mjs +202 -0
- package/app-node/tts_runtime.mjs +134 -0
- package/app-node/tts_runtime.test.mjs +89 -0
- package/app-node/tts_settings.mjs +150 -3
- package/app-node/tts_settings.test.mjs +204 -0
- package/app-node/tts_voice_config.mjs +136 -2
- package/app-node/tts_voice_config.test.mjs +94 -0
- package/app-node/utterance_router.mjs +216 -0
- package/app-node/utterance_router.test.mjs +236 -0
- package/app-node/voice_autojoin.mjs +37 -0
- package/app-node/voice_autojoin.test.mjs +59 -0
- package/app-node/voice_io.mjs +272 -0
- package/app-node/voice_io.test.mjs +102 -0
- package/app-node/voice_turn_runner.mjs +449 -0
- package/app-node/voice_turn_runner.test.mjs +289 -0
- package/docs/CONFIGURATION.md +12 -2
- package/docs/HARNESSES.md +58 -0
- package/docs/HARNESS_AIDER.md +50 -0
- package/docs/HARNESS_CLAUDE.md +56 -0
- package/docs/HARNESS_CODEX.md +56 -0
- package/docs/HARNESS_CURSOR.md +45 -0
- package/docs/HARNESS_GEMINI.md +45 -0
- package/docs/HARNESS_HERMES.md +57 -0
- package/docs/HARNESS_OPENCLAW.md +44 -0
- package/docs/HARNESS_OPENCODE.md +44 -0
- package/docs/README.md +1 -0
- package/docs/ROADMAP.md +20 -5
- package/docs/TTS_BACKENDS.md +227 -0
- package/docs/USAGE.md +22 -0
- package/docs/i18n/AGENTS.es.md +34 -0
- package/docs/i18n/AGENTS.fr.md +34 -0
- package/docs/i18n/AGENTS.ja.md +34 -0
- package/docs/i18n/AGENTS.ko.md +34 -0
- package/docs/i18n/AGENTS.ru.md +34 -0
- package/docs/i18n/AGENTS.zh.md +34 -0
- package/docs/i18n/HARNESSES.es.md +58 -0
- package/docs/i18n/HARNESSES.fr.md +58 -0
- package/docs/i18n/HARNESSES.ja.md +58 -0
- package/docs/i18n/HARNESSES.ko.md +58 -0
- package/docs/i18n/HARNESSES.ru.md +58 -0
- package/docs/i18n/HARNESSES.zh.md +58 -0
- package/docs/i18n/HARNESS_AIDER.es.md +48 -0
- package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
- package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
- package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
- package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
- package/docs/i18n/HARNESS_CODEX.es.md +55 -0
- package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
- package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
- package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
- package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
- package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
- package/docs/i18n/HARNESS_HERMES.es.md +54 -0
- package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
- package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
- package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
- package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
- package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
- package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
- package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
- package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
- package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
- package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
- package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
- package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
- package/integrations/fireredtts2/mlx_llm.py +183 -0
- package/integrations/fireredtts2/synth.py +156 -0
- package/integrations/fireredtts2/synth_mlx.py +196 -0
- package/integrations/mlxaudio/synth.py +74 -0
- package/integrations/neuttsair/synth.py +104 -0
- package/integrations/omnivoice/synth.py +110 -0
- package/package.json +6 -1
- package/scripts/cli.mjs +84 -0
- package/scripts/doctor.mjs +104 -4
- package/scripts/install.mjs +5 -1
- package/scripts/install_fireredtts2.sh +109 -0
- package/scripts/install_mlxaudio.sh +34 -0
- package/scripts/install_mossttsnano.sh +46 -0
- package/scripts/postinstall.mjs +34 -0
package/app-node/plan_mode.mjs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
const PLAN_RE = /PLAN_BEGIN\s*\n([\s\S]*?)\nPLAN_END
|
|
2
|
-
const DECISIONS_RE = /DECISIONS_BEGIN\s*\n([\s\S]*?)\nDECISIONS_END
|
|
1
|
+
const PLAN_RE = /PLAN_BEGIN\s*\n([\s\S]*?)\nPLAN_END/g;
|
|
2
|
+
const DECISIONS_RE = /DECISIONS_BEGIN\s*\n([\s\S]*?)\nDECISIONS_END/g;
|
|
3
|
+
|
|
4
|
+
function lastMatch(text, regex) {
|
|
5
|
+
const matches = Array.from(String(text || '').matchAll(regex));
|
|
6
|
+
return matches.length ? matches[matches.length - 1] : null;
|
|
7
|
+
}
|
|
3
8
|
|
|
4
9
|
const SKIP_EN = /\bskip\s+step\s+(\d+)\b/i;
|
|
5
10
|
const SKIP_KO = /step\s*(\d+)\s*건너뛰/i;
|
|
@@ -13,19 +18,20 @@ const ENTER_EN = /\b(plan\s+(it\s+)?first|make\s+a\s+plan)\b/i;
|
|
|
13
18
|
const ENTER_KO = /(먼저\s*계획|계획\s*먼저|계획부터)/i;
|
|
14
19
|
|
|
15
20
|
export function parsePlanOutput(text) {
|
|
16
|
-
const planMatch =
|
|
21
|
+
const planMatch = lastMatch(text, PLAN_RE);
|
|
17
22
|
if (!planMatch) return { steps: [], decisions: [] };
|
|
18
23
|
const steps = planMatch[1]
|
|
19
24
|
.split(/\r?\n/)
|
|
20
25
|
.map(line => line.match(/^\s*(\d+)\.\s*(.+)$/))
|
|
21
26
|
.filter(Boolean)
|
|
22
27
|
.map(m => ({ id: Number(m[1]), text: m[2].trim(), status: 'pending' }));
|
|
23
|
-
const
|
|
28
|
+
const afterPlan = String(text || '').slice(planMatch.index + planMatch[0].length);
|
|
29
|
+
const decisions = parseDecisions(afterPlan);
|
|
24
30
|
return { steps, decisions };
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export function parseDecisions(text) {
|
|
28
|
-
const match =
|
|
34
|
+
const match = lastMatch(text, DECISIONS_RE);
|
|
29
35
|
if (!match) return [];
|
|
30
36
|
const out = [];
|
|
31
37
|
let counter = 1;
|
|
@@ -48,6 +54,20 @@ const ORDINAL_EN = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, '1st':
|
|
|
48
54
|
const ORDINAL_KO = { '첫': 1, '첫번째': 1, '첫 번째': 1, '두번째': 2, '두 번째': 2, '세번째': 3, '세 번째': 3, '네번째': 4 };
|
|
49
55
|
const DEFER_RE = /\b(either|whatever|you\s+(decide|pick|choose)|agent\s+(decides|picks)|up\s+to\s+you|no\s+preference)\b|아무거나|네가\s*골라|마음대로|상관없|알아서/i;
|
|
50
56
|
|
|
57
|
+
function escapeRegex(value) {
|
|
58
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function optionMatchesText(text, optionLower) {
|
|
62
|
+
if (!optionLower || optionLower.length < 2) return false;
|
|
63
|
+
const asciiOnly = /^[\x00-\x7f]+$/.test(optionLower);
|
|
64
|
+
if (asciiOnly) {
|
|
65
|
+
const pattern = new RegExp(`(^|\\W)${escapeRegex(optionLower)}(\\W|$)`, 'i');
|
|
66
|
+
return pattern.test(text);
|
|
67
|
+
}
|
|
68
|
+
return text.includes(optionLower);
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
export function parseDecisionAnswer(utterance, decision, language = 'en') {
|
|
52
72
|
const text = String(utterance || '').trim();
|
|
53
73
|
if (!text || !decision || !Array.isArray(decision.options) || decision.options.length === 0) {
|
|
@@ -57,7 +77,7 @@ export function parseDecisionAnswer(utterance, decision, language = 'en') {
|
|
|
57
77
|
if (DEFER_RE.test(text)) return { type: 'auto', choice: null };
|
|
58
78
|
for (const opt of decision.options) {
|
|
59
79
|
const optLower = String(opt).toLowerCase();
|
|
60
|
-
if (lower
|
|
80
|
+
if (optionMatchesText(lower, optLower)) return { type: 'option', choice: opt };
|
|
61
81
|
}
|
|
62
82
|
const numMatch = text.match(/\b(\d+)\b/);
|
|
63
83
|
if (numMatch) {
|
|
@@ -120,11 +140,18 @@ export function applyCommand(steps, cmd) {
|
|
|
120
140
|
return steps.map(s => (s.id === cmd.index ? { ...s, status: 'skipped' } : s));
|
|
121
141
|
}
|
|
122
142
|
if (cmd.type === 'insert') {
|
|
143
|
+
const usedIds = new Set(steps.map(s => s.id));
|
|
144
|
+
let fraction = 0.5;
|
|
145
|
+
let proposed = cmd.after + fraction;
|
|
146
|
+
while (usedIds.has(proposed)) {
|
|
147
|
+
fraction /= 2;
|
|
148
|
+
proposed = cmd.after + fraction;
|
|
149
|
+
}
|
|
123
150
|
const out = [];
|
|
124
151
|
for (const s of steps) {
|
|
125
152
|
out.push(s);
|
|
126
153
|
if (s.id === cmd.after) {
|
|
127
|
-
out.push({ id:
|
|
154
|
+
out.push({ id: proposed, text: cmd.text, status: 'added' });
|
|
128
155
|
}
|
|
129
156
|
}
|
|
130
157
|
return out;
|
|
@@ -150,6 +177,7 @@ export function planModePreamble(language = 'en') {
|
|
|
150
177
|
'- <slot> | <한 문장 질문> | <옵션1> | <옵션2> | ...',
|
|
151
178
|
'DECISIONS_END',
|
|
152
179
|
'각 단계는 12단어 이하 한국어 한 줄. slot은 oauth_provider, session_store 같은 짧은 영문 키.',
|
|
180
|
+
'slot이 "which_agent"이면 다음에 답할 CLI 에이전트를 묻는 분기다 (options: codex, aider, claude, gemini, opencode, openclaw, cursor, hermes).',
|
|
153
181
|
'결정이 필요 없으면 DECISIONS 블록 자체를 생략해라.',
|
|
154
182
|
].join('\n');
|
|
155
183
|
}
|
|
@@ -164,6 +192,7 @@ export function planModePreamble(language = 'en') {
|
|
|
164
192
|
'- <slot> | <one-sentence question> | <option1> | <option2> | ...',
|
|
165
193
|
'DECISIONS_END',
|
|
166
194
|
'Each step under 12 words. slot is a short snake_case key (e.g. oauth_provider).',
|
|
195
|
+
'Use slot "which_agent" when the choice is which CLI agent should answer next (options: codex, aider, claude, gemini, opencode, openclaw, cursor, hermes).',
|
|
167
196
|
'Omit the DECISIONS block entirely if there is nothing to ask.',
|
|
168
197
|
].join('\n');
|
|
169
198
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isPlanEntryUtterance,
|
|
13
13
|
planModePreamble,
|
|
14
14
|
} from './plan_mode.mjs';
|
|
15
|
+
import { isAgentRoutingDecision } from './agent_routing.mjs';
|
|
15
16
|
|
|
16
17
|
test('parsePlanOutput extracts numbered steps between markers', () => {
|
|
17
18
|
const out = parsePlanOutput('intro\nPLAN_BEGIN\n1. Read auth.ts\n2. Add login route\n3. Write test\nPLAN_END\nthanks');
|
|
@@ -76,6 +77,15 @@ test('parseDecisionAnswer returns unknown on no match', () => {
|
|
|
76
77
|
assert.equal(parseDecisionAnswer('hello world', decision).type, 'unknown');
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
test('parseDecisionAnswer requires word boundaries for ASCII options', () => {
|
|
81
|
+
const decision = { slot: 'x', question: 'q', options: ['go', 'wait'] };
|
|
82
|
+
assert.equal(parseDecisionAnswer('go ahead', decision).choice, 'go');
|
|
83
|
+
// "go" should not match inside "ago" or "google"
|
|
84
|
+
const trickier = { slot: 'x', question: 'q', options: ['go', 'wait'] };
|
|
85
|
+
assert.equal(parseDecisionAnswer('a long time ago', trickier).type, 'unknown');
|
|
86
|
+
assert.equal(parseDecisionAnswer('let me google that', trickier).type, 'unknown');
|
|
87
|
+
});
|
|
88
|
+
|
|
79
89
|
test('renderDecisionPrompt formats question with numbered options', () => {
|
|
80
90
|
const decision = { slot: 'x', question: 'Pick auth?', options: ['google', 'github'] };
|
|
81
91
|
assert.match(renderDecisionPrompt(decision, 'en'), /Pick auth\?\s+Options:\s+1\) google\s+2\) github/);
|
|
@@ -131,6 +141,52 @@ test('applyCommand insert places new step after target', () => {
|
|
|
131
141
|
assert.equal(after[2].text, 'b');
|
|
132
142
|
});
|
|
133
143
|
|
|
144
|
+
test('applyCommand two inserts after the same step get unique ids', () => {
|
|
145
|
+
let steps = [{ id: 1, text: 'a', status: 'pending' }, { id: 2, text: 'b', status: 'pending' }];
|
|
146
|
+
steps = applyCommand(steps, { type: 'insert', after: 1, text: 'first extra' });
|
|
147
|
+
steps = applyCommand(steps, { type: 'insert', after: 1, text: 'second extra' });
|
|
148
|
+
const ids = steps.map(s => s.id);
|
|
149
|
+
assert.equal(new Set(ids).size, ids.length, `expected unique ids, got ${ids.join(',')}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('parsePlanOutput drops stale draft DECISIONS that precede the final PLAN', () => {
|
|
153
|
+
const text = [
|
|
154
|
+
'DECISIONS_BEGIN',
|
|
155
|
+
'- stale_slot | Old question? | a | b',
|
|
156
|
+
'DECISIONS_END',
|
|
157
|
+
'',
|
|
158
|
+
'PLAN_BEGIN',
|
|
159
|
+
'1. real step',
|
|
160
|
+
'PLAN_END',
|
|
161
|
+
].join('\n');
|
|
162
|
+
const out = parsePlanOutput(text);
|
|
163
|
+
assert.deepEqual(out.steps.map(s => s.text), ['real step']);
|
|
164
|
+
assert.deepEqual(out.decisions, []);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('parsePlanOutput picks the last PLAN/DECISIONS block when duplicates exist', () => {
|
|
168
|
+
const text = [
|
|
169
|
+
'PLAN_BEGIN',
|
|
170
|
+
'1. example step',
|
|
171
|
+
'PLAN_END',
|
|
172
|
+
'DECISIONS_BEGIN',
|
|
173
|
+
'- example_slot | Pick? | a | b',
|
|
174
|
+
'DECISIONS_END',
|
|
175
|
+
'',
|
|
176
|
+
'PLAN_BEGIN',
|
|
177
|
+
'1. real step one',
|
|
178
|
+
'2. real step two',
|
|
179
|
+
'PLAN_END',
|
|
180
|
+
'DECISIONS_BEGIN',
|
|
181
|
+
'- real_slot | Real question? | yes | no',
|
|
182
|
+
'DECISIONS_END',
|
|
183
|
+
].join('\n');
|
|
184
|
+
const out = parsePlanOutput(text);
|
|
185
|
+
assert.deepEqual(out.steps.map(s => s.text), ['real step one', 'real step two']);
|
|
186
|
+
assert.equal(out.decisions.length, 1);
|
|
187
|
+
assert.equal(out.decisions[0].slot, 'real_slot');
|
|
188
|
+
});
|
|
189
|
+
|
|
134
190
|
test('renderFinalPlan skips skipped steps and renumbers', () => {
|
|
135
191
|
const steps = [
|
|
136
192
|
{ id: 1, text: 'a', status: 'pending' },
|
|
@@ -151,3 +207,25 @@ test('planModePreamble contains PLAN_BEGIN marker', () => {
|
|
|
151
207
|
assert.match(planModePreamble('en'), /PLAN_BEGIN/);
|
|
152
208
|
assert.match(planModePreamble('ko'), /PLAN_BEGIN/);
|
|
153
209
|
});
|
|
210
|
+
|
|
211
|
+
test('parsePlanOutput tags which_agent decision via isAgentRoutingDecision', () => {
|
|
212
|
+
const text = [
|
|
213
|
+
'PLAN_BEGIN',
|
|
214
|
+
'1. Survey the codebase',
|
|
215
|
+
'PLAN_END',
|
|
216
|
+
'DECISIONS_BEGIN',
|
|
217
|
+
'- which_agent | Who should answer? | codex | aider',
|
|
218
|
+
'DECISIONS_END',
|
|
219
|
+
].join('\n');
|
|
220
|
+
const out = parsePlanOutput(text);
|
|
221
|
+
assert.equal(out.decisions[0].slot, 'which_agent');
|
|
222
|
+
assert.equal(isAgentRoutingDecision(out.decisions[0]), true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('planModePreamble in English mentions which_agent', () => {
|
|
226
|
+
assert.match(planModePreamble('en'), /which_agent/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('planModePreamble in Korean mentions which_agent', () => {
|
|
230
|
+
assert.match(planModePreamble('ko'), /which_agent/);
|
|
231
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Agent progress speech pipeline: queues verbose progress events into rate-limited
|
|
2
|
+
// TTS batches, plays them through the shared bridge player, and stops cleanly
|
|
3
|
+
// when the agent finally answers.
|
|
4
|
+
//
|
|
5
|
+
// Phase 5a extraction from main.mjs. Closes over bridge state
|
|
6
|
+
// (activeProgressSignal, activeProgressAbortController, smartProgressSummarizer,
|
|
7
|
+
// progressSpeechBatch, etc.) plus a handful of injected helpers (settings, log,
|
|
8
|
+
// warn, playAudio, synthProgressTTS deps, refreshTtsRuntimeConfig).
|
|
9
|
+
//
|
|
10
|
+
// Module-level imports (createSmartProgressSummarizer, progressCategory,
|
|
11
|
+
// formatProgressMessage, summarizeProgressEvents, progressTtsCacheFileName)
|
|
12
|
+
// move with the handler — they're not needed anywhere else in main.mjs.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createSmartProgressSummarizer } from './smart_progress.mjs';
|
|
17
|
+
import { progressCategory, formatProgressMessage, summarizeProgressEvents } from './progress_speech.mjs';
|
|
18
|
+
import { progressTtsCacheFileName } from './progress_cache.mjs';
|
|
19
|
+
|
|
20
|
+
export function createProgressHandler(deps) {
|
|
21
|
+
const {
|
|
22
|
+
bridge,
|
|
23
|
+
settings,
|
|
24
|
+
log,
|
|
25
|
+
warn,
|
|
26
|
+
isAbortError,
|
|
27
|
+
playAudio,
|
|
28
|
+
sendText,
|
|
29
|
+
refreshTtsRuntimeConfig,
|
|
30
|
+
} = deps;
|
|
31
|
+
|
|
32
|
+
function ensureSmartProgressSummarizer() {
|
|
33
|
+
if (bridge.smartProgressSummarizer) return bridge.smartProgressSummarizer;
|
|
34
|
+
bridge.smartProgressSummarizer = createSmartProgressSummarizer({
|
|
35
|
+
apiKey: process.env.SMART_PROGRESS_API_KEY || '',
|
|
36
|
+
baseUrl: process.env.SMART_PROGRESS_BASE_URL || 'https://api.groq.com/openai/v1',
|
|
37
|
+
model: process.env.SMART_PROGRESS_MODEL || 'llama-3.1-8b-instant',
|
|
38
|
+
language: settings.voiceLanguage,
|
|
39
|
+
});
|
|
40
|
+
bridge.smartProgressSummarizer.on('summary', summary => {
|
|
41
|
+
if (!summary || !bridge.activeProgressSignal) return;
|
|
42
|
+
queueVerboseProgressSpeech(summary, bridge.activeProgressSignal);
|
|
43
|
+
});
|
|
44
|
+
return bridge.smartProgressSummarizer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function smartProgressStatusText() {
|
|
48
|
+
const hasKey = Boolean(process.env.SMART_PROGRESS_API_KEY);
|
|
49
|
+
const mode = bridge.smartProgressEnabled && hasKey ? 'on' : 'off';
|
|
50
|
+
const reason = !hasKey ? ' (no SMART_PROGRESS_API_KEY set)' : '';
|
|
51
|
+
return `smart-progress: ${mode}${reason}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function progressEmoji(event) {
|
|
55
|
+
const category = progressCategory(event, { language: settings.voiceLanguage })?.key;
|
|
56
|
+
return {
|
|
57
|
+
test: '🧪',
|
|
58
|
+
edit: '✏️',
|
|
59
|
+
read: '📖',
|
|
60
|
+
search: '🔎',
|
|
61
|
+
terminal: '⌨️',
|
|
62
|
+
skill: '🧰',
|
|
63
|
+
browser: '🌐',
|
|
64
|
+
tool: '🛠️',
|
|
65
|
+
agent: '🤖',
|
|
66
|
+
work: '⚙️',
|
|
67
|
+
}[category] || '⚙️';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatProgressText(event) {
|
|
71
|
+
return formatProgressMessage(event, { language: settings.voiceLanguage });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sendVerboseProgressText(event, signal) {
|
|
75
|
+
if (!bridge.verboseProgress || !signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
|
|
76
|
+
const formatted = formatProgressText(event).replace(/\s+/g, ' ').trim();
|
|
77
|
+
if (!formatted) return;
|
|
78
|
+
const message = formatted.slice(0, 1900);
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (message === bridge.lastVerboseProgressText && now - bridge.lastVerboseProgressTextAt < 2000) return;
|
|
81
|
+
bridge.lastVerboseProgressText = message;
|
|
82
|
+
bridge.lastVerboseProgressTextAt = now;
|
|
83
|
+
void sendText(message).catch(e => warn('verbose progress text delivery failed', e?.stack || e));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function synthProgressTTS(text, signal) {
|
|
87
|
+
await refreshTtsRuntimeConfig();
|
|
88
|
+
const ext = bridge.ttsBackend.outputExtension || 'mp3';
|
|
89
|
+
const cachePath = path.join(settings.tts.progressCacheDir, progressTtsCacheFileName({
|
|
90
|
+
backendKeyParts: bridge.ttsBackend.cacheKeyParts(),
|
|
91
|
+
text,
|
|
92
|
+
ext,
|
|
93
|
+
}));
|
|
94
|
+
if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 0) {
|
|
95
|
+
log('progress tts cache hit', text, cachePath);
|
|
96
|
+
return cachePath;
|
|
97
|
+
}
|
|
98
|
+
log('progress tts cache miss', text);
|
|
99
|
+
const tmp = await bridge.ttsBackend.synthesize(text, { signal, kind: 'progress' });
|
|
100
|
+
fs.renameSync(tmp, cachePath);
|
|
101
|
+
return cachePath;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function speakProgress(text, signal) {
|
|
105
|
+
if (signal?.aborted) return;
|
|
106
|
+
try {
|
|
107
|
+
const mp3 = await synthProgressTTS(text, signal);
|
|
108
|
+
if (signal?.aborted) return;
|
|
109
|
+
await playAudio(mp3, { deleteAfter: false });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (!isAbortError(e)) warn('progress tts failed', e?.stack || e);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function speakImmediateNotice(text, signal, reason = 'notice') {
|
|
116
|
+
if (signal?.aborted) return;
|
|
117
|
+
try {
|
|
118
|
+
log('immediate notice speech', reason, 'text', String(text || '').slice(0, 80));
|
|
119
|
+
const mp3 = await synthProgressTTS(text, signal);
|
|
120
|
+
if (signal?.aborted) return;
|
|
121
|
+
await playAudio(mp3, { deleteAfter: false });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (!isAbortError(e)) warn('immediate notice speech failed', reason, e?.stack || e);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function queueProgressSpeechText(text, signal, reason = 'status') {
|
|
128
|
+
const spoken = String(text || '').replace(/\s+/g, ' ').trim();
|
|
129
|
+
if (!spoken || !signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
|
|
130
|
+
bridge.verboseProgressSpeechQueue = bridge.verboseProgressSpeechQueue
|
|
131
|
+
.catch(() => {})
|
|
132
|
+
.then(async () => {
|
|
133
|
+
if (signal.aborted || bridge.activeProgressSignal !== signal || !bridge.processing) return;
|
|
134
|
+
log('progress speech queued', reason, 'text', spoken);
|
|
135
|
+
await speakProgress(spoken, signal);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function flushProgressSpeechBatch(signal, reason = 'timer') {
|
|
140
|
+
if (!signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
|
|
141
|
+
if (bridge.progressSpeechBatchTimer) {
|
|
142
|
+
clearTimeout(bridge.progressSpeechBatchTimer);
|
|
143
|
+
bridge.progressSpeechBatchTimer = null;
|
|
144
|
+
}
|
|
145
|
+
const events = bridge.progressSpeechBatch;
|
|
146
|
+
bridge.progressSpeechBatch = [];
|
|
147
|
+
bridge.progressSpeechBatchSignal = null;
|
|
148
|
+
bridge.progressSpeechBatchStartedAt = 0;
|
|
149
|
+
const text = summarizeProgressEvents(events, { maxCategories: 3, language: settings.voiceLanguage });
|
|
150
|
+
if (!text) return;
|
|
151
|
+
queueProgressSpeechText(text, signal, `batch-${reason}-${events.length}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function queueVerboseProgressSpeech(event, signal) {
|
|
155
|
+
if (!bridge.verboseProgress || !signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
|
|
156
|
+
const text = String(event || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
157
|
+
if (!text) return;
|
|
158
|
+
if (bridge.progressSpeechBatchSignal && bridge.progressSpeechBatchSignal !== signal) {
|
|
159
|
+
bridge.progressSpeechBatch = [];
|
|
160
|
+
if (bridge.progressSpeechBatchTimer) clearTimeout(bridge.progressSpeechBatchTimer);
|
|
161
|
+
bridge.progressSpeechBatchTimer = null;
|
|
162
|
+
bridge.progressSpeechBatchStartedAt = 0;
|
|
163
|
+
}
|
|
164
|
+
bridge.progressSpeechBatchSignal = signal;
|
|
165
|
+
if (!bridge.progressSpeechBatchStartedAt) bridge.progressSpeechBatchStartedAt = Date.now();
|
|
166
|
+
bridge.progressSpeechBatch.push(text);
|
|
167
|
+
const elapsedMs = Date.now() - bridge.progressSpeechBatchStartedAt;
|
|
168
|
+
const ratePerSecond = bridge.progressSpeechBatch.length / Math.max(0.2, elapsedMs / 1000);
|
|
169
|
+
const maxBatchEvents = ratePerSecond >= 6 ? 5 : ratePerSecond >= 3 ? 4 : 3;
|
|
170
|
+
const batchDelayMs = ratePerSecond >= 6 ? 650 : ratePerSecond >= 3 ? 550 : 450;
|
|
171
|
+
if (bridge.progressSpeechBatch.length >= maxBatchEvents) {
|
|
172
|
+
flushProgressSpeechBatch(signal, 'full');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (bridge.progressSpeechBatchTimer) clearTimeout(bridge.progressSpeechBatchTimer);
|
|
176
|
+
bridge.progressSpeechBatchTimer = setTimeout(() => flushProgressSpeechBatch(signal, 'timer'), batchDelayMs);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function clearProgressSpeechBatch(signal = bridge.activeProgressSignal) {
|
|
180
|
+
if (bridge.progressSpeechBatchTimer) {
|
|
181
|
+
clearTimeout(bridge.progressSpeechBatchTimer);
|
|
182
|
+
bridge.progressSpeechBatchTimer = null;
|
|
183
|
+
}
|
|
184
|
+
if (!signal || bridge.progressSpeechBatchSignal === signal) {
|
|
185
|
+
bridge.progressSpeechBatch = [];
|
|
186
|
+
bridge.progressSpeechBatchSignal = null;
|
|
187
|
+
bridge.progressSpeechBatchStartedAt = 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stopProgressSpeech(signal, reason = 'final-answer') {
|
|
192
|
+
if (bridge.activeProgressSignal !== signal) return;
|
|
193
|
+
clearProgressSpeechBatch(signal);
|
|
194
|
+
bridge.activeProgressSignal = null;
|
|
195
|
+
if (bridge.activeProgressAbortController && !bridge.activeProgressAbortController.signal.aborted) {
|
|
196
|
+
try { bridge.activeProgressAbortController.abort(); } catch (e) { warn('abort progress speech failed', e?.stack || e); }
|
|
197
|
+
}
|
|
198
|
+
if (bridge.speaking) {
|
|
199
|
+
log('stop progress speech before final answer', reason);
|
|
200
|
+
try { bridge.player.stop(true); } catch (e) { warn('stop progress speech failed', e?.stack || e); }
|
|
201
|
+
bridge.speaking = false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ensureSmartProgressSummarizer,
|
|
207
|
+
smartProgressStatusText,
|
|
208
|
+
progressEmoji,
|
|
209
|
+
formatProgressText,
|
|
210
|
+
sendVerboseProgressText,
|
|
211
|
+
synthProgressTTS,
|
|
212
|
+
speakProgress,
|
|
213
|
+
speakImmediateNotice,
|
|
214
|
+
queueProgressSpeechText,
|
|
215
|
+
flushProgressSpeechBatch,
|
|
216
|
+
queueVerboseProgressSpeech,
|
|
217
|
+
clearProgressSpeechBatch,
|
|
218
|
+
stopProgressSpeech,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createProgressHandler } from './progress_handler.mjs';
|
|
4
|
+
import { createBridge } from './bridge_context.mjs';
|
|
5
|
+
|
|
6
|
+
const noop = () => {};
|
|
7
|
+
const noopAsync = async () => {};
|
|
8
|
+
|
|
9
|
+
function makeDeps(overrides = {}) {
|
|
10
|
+
const bridge = createBridge();
|
|
11
|
+
bridge.ttsBackend = {
|
|
12
|
+
outputExtension: 'mp3',
|
|
13
|
+
cacheKeyParts: () => ['fake'],
|
|
14
|
+
synthesize: async () => '/tmp/progress-tmp.mp3',
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
bridge,
|
|
18
|
+
settings: { tts: { progressCacheDir: '/tmp/progress-cache' }, voiceLanguage: 'ko' },
|
|
19
|
+
log: noop, warn: noop,
|
|
20
|
+
isAbortError: () => false,
|
|
21
|
+
playAudio: noopAsync,
|
|
22
|
+
sendText: noopAsync,
|
|
23
|
+
refreshTtsRuntimeConfig: noopAsync,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('createProgressHandler exposes the expected functions', () => {
|
|
29
|
+
const handler = createProgressHandler(makeDeps());
|
|
30
|
+
for (const name of [
|
|
31
|
+
'ensureSmartProgressSummarizer', 'smartProgressStatusText', 'progressEmoji',
|
|
32
|
+
'formatProgressText', 'sendVerboseProgressText', 'synthProgressTTS',
|
|
33
|
+
'speakProgress', 'speakImmediateNotice', 'queueProgressSpeechText',
|
|
34
|
+
'flushProgressSpeechBatch', 'queueVerboseProgressSpeech',
|
|
35
|
+
'clearProgressSpeechBatch', 'stopProgressSpeech',
|
|
36
|
+
]) {
|
|
37
|
+
assert.equal(typeof handler[name], 'function', `${name} is exposed`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('smartProgressStatusText reports off + no-key when env is empty', () => {
|
|
42
|
+
const prev = process.env.SMART_PROGRESS_API_KEY;
|
|
43
|
+
delete process.env.SMART_PROGRESS_API_KEY;
|
|
44
|
+
try {
|
|
45
|
+
const { smartProgressStatusText } = createProgressHandler(makeDeps());
|
|
46
|
+
const text = smartProgressStatusText();
|
|
47
|
+
assert.match(text, /smart-progress: off/);
|
|
48
|
+
assert.match(text, /no SMART_PROGRESS_API_KEY set/);
|
|
49
|
+
} finally {
|
|
50
|
+
if (prev !== undefined) process.env.SMART_PROGRESS_API_KEY = prev;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('progressEmoji maps category keys to emoji glyphs', () => {
|
|
55
|
+
const { progressEmoji } = createProgressHandler(makeDeps());
|
|
56
|
+
// progressCategory returns null for empty event -> fallback emoji
|
|
57
|
+
assert.equal(progressEmoji(''), '⚙️');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('queueProgressSpeechText drops empty/aborted/mismatched signals', () => {
|
|
61
|
+
const deps = makeDeps();
|
|
62
|
+
let calls = 0;
|
|
63
|
+
deps.playAudio = async () => { calls++; };
|
|
64
|
+
const { queueProgressSpeechText } = createProgressHandler(deps);
|
|
65
|
+
const signal = new AbortController().signal;
|
|
66
|
+
// No active progress signal set -> drop
|
|
67
|
+
queueProgressSpeechText('hello', signal, 'test');
|
|
68
|
+
assert.equal(calls, 0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('clearProgressSpeechBatch resets the batch buffer for the active signal', () => {
|
|
72
|
+
const deps = makeDeps();
|
|
73
|
+
const { clearProgressSpeechBatch } = createProgressHandler(deps);
|
|
74
|
+
deps.bridge.progressSpeechBatchSignal = 'sig';
|
|
75
|
+
deps.bridge.progressSpeechBatch = ['a', 'b'];
|
|
76
|
+
deps.bridge.progressSpeechBatchStartedAt = 12345;
|
|
77
|
+
clearProgressSpeechBatch('sig');
|
|
78
|
+
assert.equal(deps.bridge.progressSpeechBatchSignal, null);
|
|
79
|
+
assert.deepEqual(deps.bridge.progressSpeechBatch, []);
|
|
80
|
+
assert.equal(deps.bridge.progressSpeechBatchStartedAt, 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('stopProgressSpeech is a no-op when signal does not match active', () => {
|
|
84
|
+
const deps = makeDeps();
|
|
85
|
+
deps.bridge.activeProgressSignal = 'A';
|
|
86
|
+
deps.bridge.speaking = true;
|
|
87
|
+
const { stopProgressSpeech } = createProgressHandler(deps);
|
|
88
|
+
stopProgressSpeech('B', 'mismatch');
|
|
89
|
+
// Unchanged because mismatched signal
|
|
90
|
+
assert.equal(deps.bridge.activeProgressSignal, 'A');
|
|
91
|
+
assert.equal(deps.bridge.speaking, true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('sendVerboseProgressText debounces repeated identical messages', async () => {
|
|
95
|
+
const deps = makeDeps();
|
|
96
|
+
let sent = 0;
|
|
97
|
+
deps.sendText = async () => { sent++; };
|
|
98
|
+
deps.bridge.verboseProgress = true;
|
|
99
|
+
const signal = new AbortController().signal;
|
|
100
|
+
deps.bridge.activeProgressSignal = signal;
|
|
101
|
+
const handler = createProgressHandler(deps);
|
|
102
|
+
handler.sendVerboseProgressText('test', signal);
|
|
103
|
+
await new Promise(r => setImmediate(r));
|
|
104
|
+
handler.sendVerboseProgressText('test', signal);
|
|
105
|
+
await new Promise(r => setImmediate(r));
|
|
106
|
+
// Second identical call within 2s window is debounced
|
|
107
|
+
assert.equal(sent, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- queueVerboseProgressSpeech: batch sizing + flush -------------------
|
|
111
|
+
|
|
112
|
+
test('queueVerboseProgressSpeech buffers events into the bridge batch', () => {
|
|
113
|
+
// Note: the flush size is rate-adaptive — when events arrive faster than
|
|
114
|
+
// 6/sec (which happens in synchronous test loops), maxBatchEvents climbs
|
|
115
|
+
// to 5. Here we just verify the buffering side: events accumulate on the
|
|
116
|
+
// bridge and the signal is recorded. The flush-on-size path is exercised
|
|
117
|
+
// implicitly by the "ratePerSecond >= 6 path" assertion below.
|
|
118
|
+
const deps = makeDeps();
|
|
119
|
+
deps.bridge.verboseProgress = true;
|
|
120
|
+
const signal = new AbortController().signal;
|
|
121
|
+
deps.bridge.activeProgressSignal = signal;
|
|
122
|
+
const handler = createProgressHandler(deps);
|
|
123
|
+
|
|
124
|
+
handler.queueVerboseProgressSpeech('event one', signal);
|
|
125
|
+
assert.equal(deps.bridge.progressSpeechBatch.length, 1);
|
|
126
|
+
assert.ok(deps.bridge.progressSpeechBatchSignal === signal);
|
|
127
|
+
assert.ok(deps.bridge.progressSpeechBatchStartedAt > 0, 'batch start time recorded');
|
|
128
|
+
|
|
129
|
+
handler.queueVerboseProgressSpeech('event two', signal);
|
|
130
|
+
assert.equal(deps.bridge.progressSpeechBatch.length, 2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('queueVerboseProgressSpeech flushes on the slow-rate threshold (3 events)', () => {
|
|
134
|
+
const deps = makeDeps();
|
|
135
|
+
deps.bridge.verboseProgress = true;
|
|
136
|
+
const signal = new AbortController().signal;
|
|
137
|
+
deps.bridge.activeProgressSignal = signal;
|
|
138
|
+
const handler = createProgressHandler(deps);
|
|
139
|
+
|
|
140
|
+
// Force the slow-rate path by backdating progressSpeechBatchStartedAt so
|
|
141
|
+
// ratePerSecond falls below the 3/sec threshold (maxBatchEvents=3).
|
|
142
|
+
handler.queueVerboseProgressSpeech('event one', signal);
|
|
143
|
+
deps.bridge.progressSpeechBatchStartedAt = Date.now() - 5000; // 1 event over 5s -> 0.2/sec
|
|
144
|
+
handler.queueVerboseProgressSpeech('event two', signal);
|
|
145
|
+
// Backdate again before the third event so we stay on the slow path.
|
|
146
|
+
deps.bridge.progressSpeechBatchStartedAt = Date.now() - 5000;
|
|
147
|
+
handler.queueVerboseProgressSpeech('event three', signal);
|
|
148
|
+
// Third event hits maxBatchEvents=3 -> flush.
|
|
149
|
+
assert.equal(deps.bridge.progressSpeechBatch.length, 0, 'batch flushed on hitting slow-rate size');
|
|
150
|
+
assert.equal(deps.bridge.progressSpeechBatchSignal, null);
|
|
151
|
+
assert.equal(deps.bridge.progressSpeechBatchTimer, null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('queueVerboseProgressSpeech ignores events when signal does not match active', () => {
|
|
155
|
+
const deps = makeDeps();
|
|
156
|
+
deps.bridge.verboseProgress = true;
|
|
157
|
+
const activeSig = new AbortController().signal;
|
|
158
|
+
const otherSig = new AbortController().signal;
|
|
159
|
+
deps.bridge.activeProgressSignal = activeSig;
|
|
160
|
+
const handler = createProgressHandler(deps);
|
|
161
|
+
handler.queueVerboseProgressSpeech('orphan event', otherSig);
|
|
162
|
+
assert.equal(deps.bridge.progressSpeechBatch.length, 0, 'no batching for mismatched signal');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('queueVerboseProgressSpeech is a no-op when verboseProgress disabled', () => {
|
|
166
|
+
const deps = makeDeps();
|
|
167
|
+
deps.bridge.verboseProgress = false;
|
|
168
|
+
const sig = new AbortController().signal;
|
|
169
|
+
deps.bridge.activeProgressSignal = sig;
|
|
170
|
+
const handler = createProgressHandler(deps);
|
|
171
|
+
handler.queueVerboseProgressSpeech('test', sig);
|
|
172
|
+
assert.equal(deps.bridge.progressSpeechBatch.length, 0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('flushProgressSpeechBatch with mismatched signal does not drain the buffer', () => {
|
|
176
|
+
const deps = makeDeps();
|
|
177
|
+
deps.bridge.progressSpeechBatch = ['queued event'];
|
|
178
|
+
deps.bridge.activeProgressSignal = 'A';
|
|
179
|
+
const handler = createProgressHandler(deps);
|
|
180
|
+
handler.flushProgressSpeechBatch('B', 'wrong-signal');
|
|
181
|
+
assert.deepEqual(deps.bridge.progressSpeechBatch, ['queued event'], 'mismatched flush is no-op');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('ensureSmartProgressSummarizer memoizes and registers a summary callback', () => {
|
|
185
|
+
// Ensure no API key path: the summarizer is still constructed, but won't ingest.
|
|
186
|
+
// We don't need to interact with the real summarizer — just verify memoization.
|
|
187
|
+
const deps = makeDeps();
|
|
188
|
+
const handler = createProgressHandler(deps);
|
|
189
|
+
const a = handler.ensureSmartProgressSummarizer();
|
|
190
|
+
const b = handler.ensureSmartProgressSummarizer();
|
|
191
|
+
assert.equal(a, b, 'summarizer memoized on bridge');
|
|
192
|
+
assert.equal(deps.bridge.smartProgressSummarizer, a);
|
|
193
|
+
});
|