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
|
@@ -7,6 +7,11 @@ import path from 'node:path';
|
|
|
7
7
|
import { createVerbalCodingMcpTools, readEnvFile } from './mcp_tools.mjs';
|
|
8
8
|
import { AUTO_RESTART_ENV_KEY } from './restart_policy.mjs';
|
|
9
9
|
|
|
10
|
+
const __tempRoots = [];
|
|
11
|
+
test.after(() => {
|
|
12
|
+
for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
13
|
+
});
|
|
14
|
+
|
|
10
15
|
test('MCP tool definitions expose VerbalCoding control surface', () => {
|
|
11
16
|
const { toolDefs, tools } = createVerbalCodingMcpTools({ root: process.cwd() });
|
|
12
17
|
const names = toolDefs.map(tool => tool.name).sort();
|
|
@@ -16,6 +21,7 @@ test('MCP tool definitions expose VerbalCoding control surface', () => {
|
|
|
16
21
|
|
|
17
22
|
test('set_auto_restart MCP tool writes the default-off restart flag', async () => {
|
|
18
23
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-mcp-'));
|
|
24
|
+
__tempRoots.push(dir);
|
|
19
25
|
const envPath = path.join(dir, '.env');
|
|
20
26
|
const { tools } = createVerbalCodingMcpTools({ root: dir, envPath });
|
|
21
27
|
const off = await tools.get('set_auto_restart').handler({ enabled: false });
|
|
@@ -28,6 +34,7 @@ test('set_auto_restart MCP tool writes the default-off restart flag', async () =
|
|
|
28
34
|
|
|
29
35
|
test('set_language MCP tool updates STT, progress, and TTS language together', async () => {
|
|
30
36
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-mcp-lang-'));
|
|
37
|
+
__tempRoots.push(dir);
|
|
31
38
|
const envPath = path.join(dir, '.env');
|
|
32
39
|
const { tools } = createVerbalCodingMcpTools({ root: dir, envPath });
|
|
33
40
|
const result = await tools.get('set_language').handler({ language: 'en' });
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Runtime notification dispatcher built on top of `./notify.mjs`'s createNotifier.
|
|
2
|
+
//
|
|
3
|
+
// Phase 5b extraction from main.mjs. Closes over bridge state
|
|
4
|
+
// (notifierInstance, notifyUserOptIn, lastNotifyAt, lastNotifyBody,
|
|
5
|
+
// activeVoiceChannelId) plus the Discord client for human-count lookups.
|
|
6
|
+
//
|
|
7
|
+
// `ttsFallbackNotice` stays in main.mjs: it's wired through createTtsBackend's
|
|
8
|
+
// onFallback callback at module init (before ttsPlayer / sendText / speakText
|
|
9
|
+
// are fully bound), and threading it through a factory would require thunk
|
|
10
|
+
// indirection for marginal gain.
|
|
11
|
+
|
|
12
|
+
import { createNotifier, buildDiscordDeepLink } from './notify.mjs';
|
|
13
|
+
|
|
14
|
+
export function createNotificationHandler(deps) {
|
|
15
|
+
const {
|
|
16
|
+
bridge,
|
|
17
|
+
client,
|
|
18
|
+
log,
|
|
19
|
+
warn,
|
|
20
|
+
} = deps;
|
|
21
|
+
|
|
22
|
+
function ensureNotifier() {
|
|
23
|
+
if (bridge.notifierInstance) return bridge.notifierInstance;
|
|
24
|
+
bridge.notifierInstance = createNotifier({
|
|
25
|
+
provider: (process.env.NOTIFY_PROVIDER || 'ntfy').toLowerCase(),
|
|
26
|
+
topic: process.env.NTFY_TOPIC || '',
|
|
27
|
+
pushoverUser: process.env.PUSHOVER_USER || '',
|
|
28
|
+
pushoverToken: process.env.PUSHOVER_TOKEN || '',
|
|
29
|
+
});
|
|
30
|
+
return bridge.notifierInstance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function notifyStatusText() {
|
|
34
|
+
const provider = (process.env.NOTIFY_PROVIDER || 'ntfy').toLowerCase();
|
|
35
|
+
const hasTopic = provider === 'ntfy' ? Boolean(process.env.NTFY_TOPIC) : (provider === 'pushover' ? Boolean(process.env.PUSHOVER_USER && process.env.PUSHOVER_TOKEN) : true);
|
|
36
|
+
const mode = bridge.notifyUserOptIn ? 'always' : 'empty-channel only';
|
|
37
|
+
const config = hasTopic ? 'configured' : 'NOT configured';
|
|
38
|
+
return `notify: ${mode} via ${provider} (${config}). Threshold: ${process.env.NOTIFY_MIN_TASK_MS || '60000'}ms.`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getVoiceChannelHumanCount() {
|
|
42
|
+
if (!bridge.activeVoiceChannelId) return 0;
|
|
43
|
+
try {
|
|
44
|
+
const ch = await client.channels.fetch(bridge.activeVoiceChannelId).catch(() => null);
|
|
45
|
+
if (!ch || !ch.members) return 0;
|
|
46
|
+
let count = 0;
|
|
47
|
+
for (const [, m] of ch.members) if (!m.user?.bot) count += 1;
|
|
48
|
+
return count;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
warn('humanCount failed', e?.message || e);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function maybeNotifyTaskComplete({ answer, label, elapsedMs, guildId }) {
|
|
56
|
+
const provider = (process.env.NOTIFY_PROVIDER || '').toLowerCase();
|
|
57
|
+
if (!provider || provider === 'noop') return;
|
|
58
|
+
const minTaskMs = Number(process.env.NOTIFY_MIN_TASK_MS || '60000');
|
|
59
|
+
const debounceMs = Number(process.env.NOTIFY_DEBOUNCE_MS || '30000');
|
|
60
|
+
const humanCount = await getVoiceChannelHumanCount();
|
|
61
|
+
const notifier = ensureNotifier();
|
|
62
|
+
if (!notifier.shouldNotify({ humanCount, taskMs: elapsedMs, minTaskMs, userOptIn: bridge.notifyUserOptIn })) return;
|
|
63
|
+
const text = String(answer || '').trim();
|
|
64
|
+
const lastSentence = text.split(/(?<=[.!?。!?])\s+/).filter(Boolean).pop() || text;
|
|
65
|
+
const body = lastSentence.slice(0, 200);
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (body && body === bridge.lastNotifyBody && now - bridge.lastNotifyAt < debounceMs) {
|
|
68
|
+
log('notify debounced', 'sinceLastMs', now - bridge.lastNotifyAt);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const title = label ? `${label} finished` : 'VerbalCoding finished';
|
|
72
|
+
const deepLink = buildDiscordDeepLink({ guildId, channelId: bridge.activeVoiceChannelId });
|
|
73
|
+
try {
|
|
74
|
+
const result = await notifier.send({ title, body, deepLink });
|
|
75
|
+
bridge.lastNotifyAt = now;
|
|
76
|
+
bridge.lastNotifyBody = body;
|
|
77
|
+
log('notify sent', 'provider', provider, 'status', result?.status || result?.ok, 'skipped', result?.skipped || false);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
warn('notify send failed', e?.message || e);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
ensureNotifier,
|
|
85
|
+
notifyStatusText,
|
|
86
|
+
getVoiceChannelHumanCount,
|
|
87
|
+
maybeNotifyTaskComplete,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createNotificationHandler } from './notification_handler.mjs';
|
|
4
|
+
import { createBridge } from './bridge_context.mjs';
|
|
5
|
+
|
|
6
|
+
function makeDeps(overrides = {}) {
|
|
7
|
+
const bridge = createBridge();
|
|
8
|
+
const client = {
|
|
9
|
+
channels: {
|
|
10
|
+
fetch: async () => ({ members: new Map() }),
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
return { bridge, client, log: () => {}, warn: () => {}, ...overrides };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('createNotificationHandler exposes the expected functions', () => {
|
|
17
|
+
const h = createNotificationHandler(makeDeps());
|
|
18
|
+
for (const name of ['ensureNotifier', 'notifyStatusText', 'getVoiceChannelHumanCount', 'maybeNotifyTaskComplete']) {
|
|
19
|
+
assert.equal(typeof h[name], 'function', `${name} is exposed`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('ensureNotifier memoizes onto bridge.notifierInstance', () => {
|
|
24
|
+
const deps = makeDeps();
|
|
25
|
+
const { ensureNotifier } = createNotificationHandler(deps);
|
|
26
|
+
const first = ensureNotifier();
|
|
27
|
+
const second = ensureNotifier();
|
|
28
|
+
assert.equal(first, second);
|
|
29
|
+
assert.equal(deps.bridge.notifierInstance, first);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('notifyStatusText reflects userOptIn and provider configuration', () => {
|
|
33
|
+
const prev = { ...process.env };
|
|
34
|
+
process.env.NOTIFY_PROVIDER = 'ntfy';
|
|
35
|
+
process.env.NTFY_TOPIC = '';
|
|
36
|
+
try {
|
|
37
|
+
const deps = makeDeps();
|
|
38
|
+
const { notifyStatusText } = createNotificationHandler(deps);
|
|
39
|
+
let text = notifyStatusText();
|
|
40
|
+
assert.match(text, /empty-channel only/);
|
|
41
|
+
assert.match(text, /NOT configured/);
|
|
42
|
+
deps.bridge.notifyUserOptIn = true;
|
|
43
|
+
process.env.NTFY_TOPIC = 'topic-x';
|
|
44
|
+
text = notifyStatusText();
|
|
45
|
+
assert.match(text, /always/);
|
|
46
|
+
assert.match(text, /\(configured\)/);
|
|
47
|
+
} finally {
|
|
48
|
+
Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
|
|
49
|
+
Object.assign(process.env, prev);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('getVoiceChannelHumanCount returns 0 when no active channel', async () => {
|
|
54
|
+
const deps = makeDeps();
|
|
55
|
+
const { getVoiceChannelHumanCount } = createNotificationHandler(deps);
|
|
56
|
+
assert.equal(await getVoiceChannelHumanCount(), 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('getVoiceChannelHumanCount excludes bots', async () => {
|
|
60
|
+
const members = new Map([
|
|
61
|
+
['1', { user: { bot: false } }],
|
|
62
|
+
['2', { user: { bot: true } }],
|
|
63
|
+
['3', { user: { bot: false } }],
|
|
64
|
+
]);
|
|
65
|
+
const deps = makeDeps({ client: { channels: { fetch: async () => ({ members }) } } });
|
|
66
|
+
deps.bridge.activeVoiceChannelId = 'vc-1';
|
|
67
|
+
const { getVoiceChannelHumanCount } = createNotificationHandler(deps);
|
|
68
|
+
assert.equal(await getVoiceChannelHumanCount(), 2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('maybeNotifyTaskComplete returns early when provider is unset/noop', async () => {
|
|
72
|
+
const prev = process.env.NOTIFY_PROVIDER;
|
|
73
|
+
try {
|
|
74
|
+
delete process.env.NOTIFY_PROVIDER;
|
|
75
|
+
const deps = makeDeps();
|
|
76
|
+
const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
|
|
77
|
+
await maybeNotifyTaskComplete({ answer: 'ok', label: 'agent', elapsedMs: 99999, guildId: 'g' });
|
|
78
|
+
assert.equal(deps.bridge.notifierInstance, null, 'no notifier when provider unset');
|
|
79
|
+
process.env.NOTIFY_PROVIDER = 'noop';
|
|
80
|
+
await maybeNotifyTaskComplete({ answer: 'ok', label: 'agent', elapsedMs: 99999, guildId: 'g' });
|
|
81
|
+
assert.equal(deps.bridge.notifierInstance, null, 'no notifier when provider noop');
|
|
82
|
+
} finally {
|
|
83
|
+
if (prev === undefined) delete process.env.NOTIFY_PROVIDER;
|
|
84
|
+
else process.env.NOTIFY_PROVIDER = prev;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// --- happy-path send + debounce + body construction ----------------------
|
|
89
|
+
|
|
90
|
+
test('maybeNotifyTaskComplete fires notifier.send with last sentence as body, deep link, and tracks lastNotify state', async () => {
|
|
91
|
+
const prev = { ...process.env };
|
|
92
|
+
const calls = [];
|
|
93
|
+
process.env.NOTIFY_PROVIDER = 'ntfy';
|
|
94
|
+
process.env.NTFY_TOPIC = 'topic-x';
|
|
95
|
+
process.env.NOTIFY_MIN_TASK_MS = '0'; // pass the min-task gate
|
|
96
|
+
process.env.NOTIFY_DEBOUNCE_MS = '5000';
|
|
97
|
+
try {
|
|
98
|
+
const deps = makeDeps();
|
|
99
|
+
deps.bridge.notifyUserOptIn = true; // bypass humanCount/empty-channel gate
|
|
100
|
+
deps.bridge.activeVoiceChannelId = 'vc-1'; // for deepLink construction
|
|
101
|
+
// Pre-seed a notifier so we don't depend on createNotifier internals.
|
|
102
|
+
deps.bridge.notifierInstance = {
|
|
103
|
+
shouldNotify: () => true,
|
|
104
|
+
send: async payload => { calls.push(payload); return { ok: true, status: 200 }; },
|
|
105
|
+
};
|
|
106
|
+
const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
|
|
107
|
+
const answer = 'first sentence here. SECOND SENTENCE is the body.';
|
|
108
|
+
await maybeNotifyTaskComplete({ answer, label: 'hermes', elapsedMs: 5000, guildId: 'g-1' });
|
|
109
|
+
assert.equal(calls.length, 1, 'notifier.send called exactly once');
|
|
110
|
+
assert.equal(calls[0].title, 'hermes finished');
|
|
111
|
+
assert.equal(calls[0].body, 'SECOND SENTENCE is the body.', 'body = last sentence');
|
|
112
|
+
assert.match(calls[0].deepLink, /g-1/, 'deep link includes guild id');
|
|
113
|
+
assert.ok(deps.bridge.lastNotifyAt > 0, 'lastNotifyAt updated');
|
|
114
|
+
assert.equal(deps.bridge.lastNotifyBody, 'SECOND SENTENCE is the body.');
|
|
115
|
+
} finally {
|
|
116
|
+
Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
|
|
117
|
+
Object.assign(process.env, prev);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('maybeNotifyTaskComplete debounces identical body within debounce window', async () => {
|
|
122
|
+
const prev = { ...process.env };
|
|
123
|
+
process.env.NOTIFY_PROVIDER = 'ntfy';
|
|
124
|
+
process.env.NTFY_TOPIC = 'topic-x';
|
|
125
|
+
process.env.NOTIFY_MIN_TASK_MS = '0';
|
|
126
|
+
process.env.NOTIFY_DEBOUNCE_MS = '60000';
|
|
127
|
+
try {
|
|
128
|
+
let sendCalls = 0;
|
|
129
|
+
const deps = makeDeps();
|
|
130
|
+
deps.bridge.notifyUserOptIn = true;
|
|
131
|
+
deps.bridge.notifierInstance = {
|
|
132
|
+
shouldNotify: () => true,
|
|
133
|
+
send: async () => { sendCalls++; return { ok: true }; },
|
|
134
|
+
};
|
|
135
|
+
const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
|
|
136
|
+
const answer = 'identical message';
|
|
137
|
+
await maybeNotifyTaskComplete({ answer, label: 'a', elapsedMs: 5000, guildId: 'g' });
|
|
138
|
+
await maybeNotifyTaskComplete({ answer, label: 'a', elapsedMs: 5000, guildId: 'g' });
|
|
139
|
+
assert.equal(sendCalls, 1, 'second identical call is debounced');
|
|
140
|
+
} finally {
|
|
141
|
+
Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
|
|
142
|
+
Object.assign(process.env, prev);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('maybeNotifyTaskComplete respects shouldNotify=false (e.g. occupied channel, opt-out)', async () => {
|
|
147
|
+
const prev = { ...process.env };
|
|
148
|
+
process.env.NOTIFY_PROVIDER = 'ntfy';
|
|
149
|
+
process.env.NOTIFY_MIN_TASK_MS = '0';
|
|
150
|
+
try {
|
|
151
|
+
let sendCalls = 0;
|
|
152
|
+
const deps = makeDeps();
|
|
153
|
+
deps.bridge.notifierInstance = {
|
|
154
|
+
shouldNotify: () => false,
|
|
155
|
+
send: async () => { sendCalls++; return { ok: true }; },
|
|
156
|
+
};
|
|
157
|
+
const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
|
|
158
|
+
await maybeNotifyTaskComplete({ answer: 'hi', label: 'x', elapsedMs: 9999, guildId: 'g' });
|
|
159
|
+
assert.equal(sendCalls, 0, 'send not called when shouldNotify returns false');
|
|
160
|
+
assert.equal(deps.bridge.lastNotifyAt, 0, 'lastNotifyAt untouched');
|
|
161
|
+
} finally {
|
|
162
|
+
Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
|
|
163
|
+
Object.assign(process.env, prev);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('maybeNotifyTaskComplete swallows notifier.send errors and warns', async () => {
|
|
168
|
+
const prev = { ...process.env };
|
|
169
|
+
process.env.NOTIFY_PROVIDER = 'ntfy';
|
|
170
|
+
process.env.NOTIFY_MIN_TASK_MS = '0';
|
|
171
|
+
try {
|
|
172
|
+
const warnCalls = [];
|
|
173
|
+
const deps = makeDeps({ warn: (...args) => warnCalls.push(args) });
|
|
174
|
+
deps.bridge.notifyUserOptIn = true;
|
|
175
|
+
deps.bridge.notifierInstance = {
|
|
176
|
+
shouldNotify: () => true,
|
|
177
|
+
send: async () => { throw new Error('network down'); },
|
|
178
|
+
};
|
|
179
|
+
const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
|
|
180
|
+
// Must not reject the calling code.
|
|
181
|
+
await maybeNotifyTaskComplete({ answer: 'hi', label: 'x', elapsedMs: 9999, guildId: 'g' });
|
|
182
|
+
assert.ok(warnCalls.some(args => /notify send failed/.test(args[0])), 'warn called with explanatory message');
|
|
183
|
+
} finally {
|
|
184
|
+
Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
|
|
185
|
+
Object.assign(process.env, prev);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Plan-mode dispatcher: STATEFUL plan-mode logic that builds on
|
|
2
|
+
// ./plan_mode.mjs's pure parsers/renderers. Owns the per-channel plan
|
|
3
|
+
// state lifecycle on bridge.planStates and the multi-turn decision UX.
|
|
4
|
+
//
|
|
5
|
+
// Phase 7b extraction from utterance_router.mjs. Five functions:
|
|
6
|
+
// - planChannelKey: which channel-id keys the plan state. Kept here
|
|
7
|
+
// because plan state, routing state, and ontology state all share
|
|
8
|
+
// this key shape.
|
|
9
|
+
// - askNextDecision / finalizePlanReady / planNarrationLines: the
|
|
10
|
+
// narration helpers used to prompt the user for next decisions or
|
|
11
|
+
// confirm a plan is ready to run.
|
|
12
|
+
// - dispatchPlanModeUtterance: the multi-turn state machine. Detects
|
|
13
|
+
// plan-entry utterances, processes voice approve/skip/insert/cancel
|
|
14
|
+
// commands, resolves decisions one at a time, and either re-prompts
|
|
15
|
+
// or returns { handled, prompt? } for the caller to feed to the agent.
|
|
16
|
+
//
|
|
17
|
+
// Caller integration: voice_turn_runner consumes dispatchPlanModeUtterance
|
|
18
|
+
// as a dep. The Discord text path doesn't touch plan mode (no plan-mode
|
|
19
|
+
// integration in text agent messages today).
|
|
20
|
+
|
|
21
|
+
export function createPlanDispatcher(deps) {
|
|
22
|
+
const {
|
|
23
|
+
bridge,
|
|
24
|
+
settings,
|
|
25
|
+
sendText,
|
|
26
|
+
speakText,
|
|
27
|
+
routingStateFor,
|
|
28
|
+
adapterForBackend,
|
|
29
|
+
adapterForProjectSession,
|
|
30
|
+
resolveProjectSessionForChannel,
|
|
31
|
+
isAgentRoutingDecision,
|
|
32
|
+
parseDecisionAnswer,
|
|
33
|
+
parsePlanVoiceCommand,
|
|
34
|
+
applyPlanCommand,
|
|
35
|
+
parsePlanOutput,
|
|
36
|
+
renderDecisionPrompt,
|
|
37
|
+
renderResolvedDecisions,
|
|
38
|
+
renderFinalPlan,
|
|
39
|
+
planModePreamble,
|
|
40
|
+
planExecutionPreamble,
|
|
41
|
+
isPlanEntryUtterance,
|
|
42
|
+
} = deps;
|
|
43
|
+
|
|
44
|
+
function planChannelKey() {
|
|
45
|
+
return bridge.activeVoiceChannelId || settings.transcriptChannelId || 'default';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function askNextDecision(state, signal) {
|
|
49
|
+
const decision = state.decisions[state.pendingDecisionIndex];
|
|
50
|
+
if (!decision) return;
|
|
51
|
+
const text = renderDecisionPrompt(decision, state.language);
|
|
52
|
+
await sendText(`❓ ${text}`);
|
|
53
|
+
await speakText(text, signal, null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function finalizePlanReady(state, signal) {
|
|
57
|
+
const language = state.language;
|
|
58
|
+
const resolvedLine = renderResolvedDecisions(state.resolvedDecisions, language);
|
|
59
|
+
const plan = planNarrationLines(state.steps, language);
|
|
60
|
+
const tail = /^en/i.test(String(language || ''))
|
|
61
|
+
? `${plan}\n${resolvedLine}\nSay "approve" to run, or edit with skip/insert.`
|
|
62
|
+
: `${plan}\n${resolvedLine}\n"실행"이라고 하면 시작할게. skip/insert로 수정도 돼.`;
|
|
63
|
+
await sendText(`📝 ${tail}`);
|
|
64
|
+
await speakText(tail, signal, null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function dispatchPlanModeUtterance(prompt, signal) {
|
|
68
|
+
const language = settings.voiceLanguage;
|
|
69
|
+
const key = planChannelKey();
|
|
70
|
+
const existing = bridge.planStates.get(key);
|
|
71
|
+
|
|
72
|
+
if (existing && existing.pendingDecisionIndex < existing.decisions.length) {
|
|
73
|
+
const controlCommand = parsePlanVoiceCommand(prompt, language);
|
|
74
|
+
if (controlCommand.type === 'cancel') {
|
|
75
|
+
const cancelState = routingStateFor(key);
|
|
76
|
+
if (existing.routingSnapshot) cancelState.activeRouting = { ...existing.routingSnapshot };
|
|
77
|
+
cancelState.pendingFallbackPrompt = null;
|
|
78
|
+
cancelState.lastResolvedDecisions = {};
|
|
79
|
+
bridge.planStates.delete(key);
|
|
80
|
+
const msg = /^en/i.test(String(language || '')) ? 'Plan cancelled.' : '계획을 취소했어.';
|
|
81
|
+
await sendText(`❎ ${msg}`);
|
|
82
|
+
await speakText(msg, signal, null);
|
|
83
|
+
return { handled: true };
|
|
84
|
+
}
|
|
85
|
+
const decision = existing.decisions[existing.pendingDecisionIndex];
|
|
86
|
+
const answer = parseDecisionAnswer(prompt, decision, language);
|
|
87
|
+
if (answer.type === 'unknown') {
|
|
88
|
+
await sendText(/^en/i.test(String(language || ''))
|
|
89
|
+
? '⚠️ I did not catch that. Please pick an option.'
|
|
90
|
+
: '⚠️ 못 알아들었어. 옵션 중에 하나 골라줘.');
|
|
91
|
+
await askNextDecision(existing, signal);
|
|
92
|
+
return { handled: true };
|
|
93
|
+
}
|
|
94
|
+
const next = {
|
|
95
|
+
...existing,
|
|
96
|
+
resolvedDecisions: { ...existing.resolvedDecisions, [decision.slot]: answer.choice },
|
|
97
|
+
pendingDecisionIndex: existing.pendingDecisionIndex + 1,
|
|
98
|
+
};
|
|
99
|
+
bridge.planStates.set(key, next);
|
|
100
|
+
if (isAgentRoutingDecision(decision) && answer.choice) {
|
|
101
|
+
const candidate = adapterForBackend(answer.choice, resolveProjectSessionForChannel(key));
|
|
102
|
+
if (candidate) {
|
|
103
|
+
routingStateFor(key).activeRouting = { backend: answer.choice, sticky: true };
|
|
104
|
+
} else {
|
|
105
|
+
const msg = /^en/i.test(String(language || ''))
|
|
106
|
+
? `${answer.choice} is not installed; staying with ${settings.agent.label}.`
|
|
107
|
+
: `${answer.choice}이(가) 설치되어 있지 않아. ${settings.agent.label}로 진행할게.`;
|
|
108
|
+
await sendText(`⚠️ ${msg}`);
|
|
109
|
+
await speakText(msg, signal, null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (next.pendingDecisionIndex < next.decisions.length) {
|
|
113
|
+
await askNextDecision(next, signal);
|
|
114
|
+
} else {
|
|
115
|
+
await finalizePlanReady(next, signal);
|
|
116
|
+
}
|
|
117
|
+
return { handled: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (existing) {
|
|
121
|
+
const cmd = parsePlanVoiceCommand(prompt, language);
|
|
122
|
+
if (cmd.type === 'skip' || cmd.type === 'insert') {
|
|
123
|
+
const nextSteps = applyPlanCommand(existing.steps, cmd);
|
|
124
|
+
bridge.planStates.set(key, { ...existing, steps: nextSteps });
|
|
125
|
+
await finalizePlanReady({ ...existing, steps: nextSteps }, signal);
|
|
126
|
+
return { handled: true };
|
|
127
|
+
}
|
|
128
|
+
if (cmd.type === 'cancel') {
|
|
129
|
+
const cancelState = routingStateFor(key);
|
|
130
|
+
if (existing.routingSnapshot) cancelState.activeRouting = { ...existing.routingSnapshot };
|
|
131
|
+
cancelState.pendingFallbackPrompt = null;
|
|
132
|
+
cancelState.lastResolvedDecisions = {};
|
|
133
|
+
bridge.planStates.delete(key);
|
|
134
|
+
const msg = /^en/i.test(String(language || '')) ? 'Plan cancelled.' : '계획을 취소했어.';
|
|
135
|
+
await sendText(`❎ ${msg}`);
|
|
136
|
+
await speakText(msg, signal, null);
|
|
137
|
+
return { handled: true };
|
|
138
|
+
}
|
|
139
|
+
if (cmd.type === 'approve') {
|
|
140
|
+
routingStateFor(key).lastResolvedDecisions = existing.resolvedDecisions || {};
|
|
141
|
+
const finalPlan = renderFinalPlan(existing.steps);
|
|
142
|
+
const resolvedLine = renderResolvedDecisions(existing.resolvedDecisions, language);
|
|
143
|
+
const promptToRun = [
|
|
144
|
+
planExecutionPreamble(language),
|
|
145
|
+
'',
|
|
146
|
+
finalPlan,
|
|
147
|
+
resolvedLine,
|
|
148
|
+
'',
|
|
149
|
+
`Original user request: ${existing.originalPrompt}`,
|
|
150
|
+
].filter(Boolean).join('\n');
|
|
151
|
+
bridge.planStates.delete(key);
|
|
152
|
+
const note = /^en/i.test(String(language || '')) ? 'Running the plan now.' : '계획대로 실행할게.';
|
|
153
|
+
await sendText(`▶ ${note}`);
|
|
154
|
+
await speakText(note, signal, null);
|
|
155
|
+
return { handled: false, prompt: promptToRun };
|
|
156
|
+
}
|
|
157
|
+
bridge.planStates.delete(key);
|
|
158
|
+
return { handled: false, prompt };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isPlanEntryUtterance(prompt, language)) {
|
|
162
|
+
const planPrompt = `${planModePreamble(language)}\n\nUser request: ${prompt}`;
|
|
163
|
+
const adapter = adapterForProjectSession(resolveProjectSessionForChannel(planChannelKey()));
|
|
164
|
+
const plan = { task: false, label: adapter.label, verboseProgress: false, language, projectContext: '' };
|
|
165
|
+
const result = await adapter.run(planPrompt, signal, plan).catch(e => ({ answer: '', error: e }));
|
|
166
|
+
const { steps, decisions } = parsePlanOutput(result.answer || '');
|
|
167
|
+
if (!steps.length) {
|
|
168
|
+
const failMsg = /^en/i.test(String(language || ''))
|
|
169
|
+
? 'I could not produce a plan. Continuing as a regular turn.'
|
|
170
|
+
: '계획을 만들지 못했어. 일반 작업으로 진행할게.';
|
|
171
|
+
await sendText(`⚠️ ${failMsg}`);
|
|
172
|
+
return { handled: false, prompt };
|
|
173
|
+
}
|
|
174
|
+
const planKey = planChannelKey();
|
|
175
|
+
const routingSnapshot = { ...routingStateFor(planKey).activeRouting };
|
|
176
|
+
const state = {
|
|
177
|
+
steps,
|
|
178
|
+
decisions,
|
|
179
|
+
resolvedDecisions: {},
|
|
180
|
+
pendingDecisionIndex: 0,
|
|
181
|
+
originalPrompt: prompt,
|
|
182
|
+
language,
|
|
183
|
+
routingSnapshot,
|
|
184
|
+
};
|
|
185
|
+
bridge.planStates.set(planKey, state);
|
|
186
|
+
const narration = planNarrationLines(steps, language);
|
|
187
|
+
await sendText(`📝 ${narration}`);
|
|
188
|
+
await speakText(narration, signal, null);
|
|
189
|
+
if (decisions.length) {
|
|
190
|
+
await askNextDecision(state, signal);
|
|
191
|
+
} else {
|
|
192
|
+
await finalizePlanReady(state, signal);
|
|
193
|
+
}
|
|
194
|
+
return { handled: true };
|
|
195
|
+
}
|
|
196
|
+
return { handled: false, prompt };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function planNarrationLines(steps, language) {
|
|
200
|
+
const visible = steps.filter(s => s.status !== 'skipped');
|
|
201
|
+
const header = /^en/i.test(String(language || ''))
|
|
202
|
+
? `Plan with ${visible.length} steps. Say "skip step N", "add X after step N", or "approve" to run.`
|
|
203
|
+
: `${visible.length}단계 계획. "step N 건너뛰어", "step N 다음에 X 추가", "실행"이라고 말해줘.`;
|
|
204
|
+
const body = visible.map((s, i) => `${i + 1}. ${s.text}`).join('\n');
|
|
205
|
+
return `${header}\n${body}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
planChannelKey,
|
|
210
|
+
askNextDecision,
|
|
211
|
+
finalizePlanReady,
|
|
212
|
+
dispatchPlanModeUtterance,
|
|
213
|
+
planNarrationLines,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createPlanDispatcher } from './plan_dispatcher.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
|
+
return {
|
|
12
|
+
bridge,
|
|
13
|
+
settings: { voiceLanguage: 'ko', transcriptChannelId: 'tx-ch', agent: { backend: 'hermes', label: 'hermes' } },
|
|
14
|
+
sendText: noopAsync,
|
|
15
|
+
speakText: noopAsync,
|
|
16
|
+
routingStateFor: () => ({
|
|
17
|
+
activeRouting: { backend: 'hermes', sticky: false },
|
|
18
|
+
lastUsedBackend: 'hermes',
|
|
19
|
+
lastResolvedDecisions: {},
|
|
20
|
+
pendingFallbackPrompt: null,
|
|
21
|
+
recentUtterances: [],
|
|
22
|
+
}),
|
|
23
|
+
adapterForBackend: () => null,
|
|
24
|
+
adapterForProjectSession: () => ({ label: 'hermes', backend: 'hermes', ask: async () => '' }),
|
|
25
|
+
resolveProjectSessionForChannel: () => null,
|
|
26
|
+
isAgentRoutingDecision: () => false,
|
|
27
|
+
parseDecisionAnswer: () => ({ type: 'unknown' }),
|
|
28
|
+
parsePlanVoiceCommand: () => ({ type: 'unknown' }),
|
|
29
|
+
applyPlanCommand: s => s,
|
|
30
|
+
parsePlanOutput: () => ({ steps: [], decisions: [] }),
|
|
31
|
+
renderDecisionPrompt: d => d?.text || '',
|
|
32
|
+
renderResolvedDecisions: () => '',
|
|
33
|
+
renderFinalPlan: () => '',
|
|
34
|
+
planModePreamble: () => 'plan-preamble',
|
|
35
|
+
planExecutionPreamble: () => 'exec-preamble',
|
|
36
|
+
isPlanEntryUtterance: () => false,
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('createPlanDispatcher exposes the expected functions', () => {
|
|
42
|
+
const d = createPlanDispatcher(makeDeps());
|
|
43
|
+
for (const name of ['planChannelKey', 'askNextDecision', 'finalizePlanReady', 'dispatchPlanModeUtterance', 'planNarrationLines']) {
|
|
44
|
+
assert.equal(typeof d[name], 'function', `${name} is exposed`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('planChannelKey prefers active voice channel, then transcript, then default', () => {
|
|
49
|
+
const deps = makeDeps();
|
|
50
|
+
const { planChannelKey } = createPlanDispatcher(deps);
|
|
51
|
+
assert.equal(planChannelKey(), 'tx-ch');
|
|
52
|
+
deps.bridge.activeVoiceChannelId = 'vc-1';
|
|
53
|
+
assert.equal(planChannelKey(), 'vc-1');
|
|
54
|
+
deps.bridge.activeVoiceChannelId = '';
|
|
55
|
+
deps.settings.transcriptChannelId = '';
|
|
56
|
+
assert.equal(planChannelKey(), 'default');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('planNarrationLines numbers visible steps and skips dropped ones', () => {
|
|
60
|
+
const { planNarrationLines } = createPlanDispatcher(makeDeps());
|
|
61
|
+
const steps = [
|
|
62
|
+
{ text: 'first', status: 'pending' },
|
|
63
|
+
{ text: 'dropped', status: 'skipped' },
|
|
64
|
+
{ text: 'third', status: 'pending' },
|
|
65
|
+
];
|
|
66
|
+
const out = planNarrationLines(steps, 'en');
|
|
67
|
+
assert.match(out, /2 steps/, 'header counts visible only');
|
|
68
|
+
assert.match(out, /1\. first/);
|
|
69
|
+
assert.match(out, /2\. third/);
|
|
70
|
+
assert.doesNotMatch(out, /dropped/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('dispatchPlanModeUtterance returns handled:false when not a plan entry and no existing plan', async () => {
|
|
74
|
+
const deps = makeDeps({
|
|
75
|
+
isPlanEntryUtterance: () => false,
|
|
76
|
+
});
|
|
77
|
+
const { dispatchPlanModeUtterance } = createPlanDispatcher(deps);
|
|
78
|
+
const out = await dispatchPlanModeUtterance('hello world', new AbortController().signal);
|
|
79
|
+
assert.deepEqual(out, { handled: false, prompt: 'hello world' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('dispatchPlanModeUtterance handles cancel command on existing pending decision', async () => {
|
|
83
|
+
const deps = makeDeps({
|
|
84
|
+
parsePlanVoiceCommand: text => /cancel/.test(text) ? { type: 'cancel' } : { type: 'unknown' },
|
|
85
|
+
});
|
|
86
|
+
// Seed a plan with one pending decision so the cancel branch can fire.
|
|
87
|
+
const { dispatchPlanModeUtterance, planChannelKey } = createPlanDispatcher(deps);
|
|
88
|
+
const key = planChannelKey();
|
|
89
|
+
deps.bridge.planStates.set(key, {
|
|
90
|
+
steps: [{ text: 's1', status: 'pending' }],
|
|
91
|
+
decisions: [{ slot: 'oauth_provider', options: ['google', 'github'] }],
|
|
92
|
+
pendingDecisionIndex: 0,
|
|
93
|
+
resolvedDecisions: {},
|
|
94
|
+
language: 'en',
|
|
95
|
+
routingSnapshot: null,
|
|
96
|
+
});
|
|
97
|
+
const out = await dispatchPlanModeUtterance('cancel this', new AbortController().signal);
|
|
98
|
+
assert.equal(out.handled, true);
|
|
99
|
+
// Plan state should be cleared after cancel.
|
|
100
|
+
assert.equal(deps.bridge.planStates.has(key), false);
|
|
101
|
+
});
|