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
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// Integration tests for the cross-module factory wire-up that lives in main.mjs.
|
|
2
|
+
//
|
|
3
|
+
// These tests don't import main.mjs directly (it has Discord-side effects).
|
|
4
|
+
// Instead they reproduce its factory construction sequence here and assert
|
|
5
|
+
// that the circular voice_io <-> utterance_router dep is actually resolved at
|
|
6
|
+
// runtime, that bridge state is shared by reference across modules, and that
|
|
7
|
+
// the call chain `voice_io.queueSegment -> flushUtterance -> handleRecording
|
|
8
|
+
// -> transcribe` round-trips correctly through the thunk indirection.
|
|
9
|
+
//
|
|
10
|
+
// Codex's holistic review flagged that the previous test suite had zero
|
|
11
|
+
// coverage of the wire-up itself; these tests close that gap.
|
|
12
|
+
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
|
|
16
|
+
import { createBridge } from './bridge_context.mjs';
|
|
17
|
+
import { createTtsPlayer } from './tts_player.mjs';
|
|
18
|
+
import { createProgressHandler } from './progress_handler.mjs';
|
|
19
|
+
import { createNotificationHandler } from './notification_handler.mjs';
|
|
20
|
+
import { createTtsRuntime } from './tts_runtime.mjs';
|
|
21
|
+
import { createVoiceIO } from './voice_io.mjs';
|
|
22
|
+
import { createDiscordVoiceSetup } from './discord_voice_setup.mjs';
|
|
23
|
+
import { createUtteranceRouter } from './utterance_router.mjs';
|
|
24
|
+
import { createVoiceTurnRunner } from './voice_turn_runner.mjs';
|
|
25
|
+
import { createPlanDispatcher } from './plan_dispatcher.mjs';
|
|
26
|
+
import { createAgentTurnLifecycle } from './agent_turn.mjs';
|
|
27
|
+
|
|
28
|
+
const noop = () => {};
|
|
29
|
+
const noopAsync = async () => {};
|
|
30
|
+
|
|
31
|
+
// Build the entire factory chain the way main.mjs does, returning every
|
|
32
|
+
// destructured handle plus the shared bridge so tests can introspect state.
|
|
33
|
+
function buildSystem(overrides = {}) {
|
|
34
|
+
const bridge = createBridge();
|
|
35
|
+
bridge.ttsBackend = {
|
|
36
|
+
name: 'fake',
|
|
37
|
+
outputExtension: 'mp3',
|
|
38
|
+
cacheKeyParts: () => ['fake'],
|
|
39
|
+
synthesize: async () => '/tmp/fake.wav',
|
|
40
|
+
close: () => {},
|
|
41
|
+
};
|
|
42
|
+
bridge.player = { play: () => {}, stop: () => {}, on: () => {} };
|
|
43
|
+
bridge.bridgeState = {
|
|
44
|
+
deferredSize: () => 0,
|
|
45
|
+
currentEpoch: () => 1,
|
|
46
|
+
discardQueues: () => 0,
|
|
47
|
+
appendSegment: (uid, seg) => ({ files: [seg.file], pcmBytes: seg.pcmBytes, epoch: 1, timer: null }),
|
|
48
|
+
deletePending: () => null,
|
|
49
|
+
getPending: () => null,
|
|
50
|
+
clearPendingTimer: () => {},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const settings = {
|
|
54
|
+
voiceLanguage: 'ko',
|
|
55
|
+
transcriptChannelId: 'tx-ch',
|
|
56
|
+
allowedUsers: new Set(),
|
|
57
|
+
autoJoinVoiceChannels: [],
|
|
58
|
+
debugDir: '',
|
|
59
|
+
agent: { backend: 'hermes', label: 'hermes' },
|
|
60
|
+
tts: { maxChars: 200, volume: 1, edge: { voice: 'ko-KR-x' }, progressCacheDir: '/tmp/cache' },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const client = {
|
|
64
|
+
user: { id: 'bot-id' },
|
|
65
|
+
channels: { cache: new Map(), fetch: async () => null },
|
|
66
|
+
guilds: { cache: new Map() },
|
|
67
|
+
destroy: () => {},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const sharedHelpers = {
|
|
71
|
+
log: noop,
|
|
72
|
+
warn: noop,
|
|
73
|
+
sleep: async () => {},
|
|
74
|
+
isAbortError: e => e?.name === 'AbortError',
|
|
75
|
+
isAllowed: () => true,
|
|
76
|
+
stamp: () => 'stamp',
|
|
77
|
+
sendText: async () => true,
|
|
78
|
+
sendEmbed: async () => true,
|
|
79
|
+
execFileAsync: async () => ({ stdout: '', stderr: '' }),
|
|
80
|
+
waitEvent: async () => {},
|
|
81
|
+
refreshTtsRuntimeConfig: async () => ({ backend: 'edge', voice: { voice: 'x' } }),
|
|
82
|
+
persistEnvValues: noop,
|
|
83
|
+
reloadRuntimeLanguageFromEnv: () => ({ changed: false, voiceLanguage: 'ko', whisperLanguage: 'ko' }),
|
|
84
|
+
discardVoiceInputQueues: () => 0,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ttsPlayer first — provides speakText / playAudio.
|
|
88
|
+
const ttsPlayer = createTtsPlayer({
|
|
89
|
+
bridge, settings,
|
|
90
|
+
log: sharedHelpers.log, warn: sharedHelpers.warn, sleep: sharedHelpers.sleep,
|
|
91
|
+
sendText: sharedHelpers.sendText,
|
|
92
|
+
refreshTtsRuntimeConfig: sharedHelpers.refreshTtsRuntimeConfig,
|
|
93
|
+
waitEvent: sharedHelpers.waitEvent,
|
|
94
|
+
isAbortError: sharedHelpers.isAbortError,
|
|
95
|
+
STREAMING_TTS_ENABLED: false,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const progressHandler = createProgressHandler({
|
|
99
|
+
bridge, settings,
|
|
100
|
+
log: sharedHelpers.log, warn: sharedHelpers.warn,
|
|
101
|
+
isAbortError: sharedHelpers.isAbortError,
|
|
102
|
+
playAudio: ttsPlayer.playAudio,
|
|
103
|
+
sendText: sharedHelpers.sendText,
|
|
104
|
+
refreshTtsRuntimeConfig: sharedHelpers.refreshTtsRuntimeConfig,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const notificationHandler = createNotificationHandler({ bridge, client, log: sharedHelpers.log, warn: sharedHelpers.warn });
|
|
108
|
+
|
|
109
|
+
const ttsRuntime = createTtsRuntime({
|
|
110
|
+
bridge, ROOT: '/tmp/vc',
|
|
111
|
+
execFileAsync: sharedHelpers.execFileAsync,
|
|
112
|
+
speakText: ttsPlayer.speakText,
|
|
113
|
+
warn: sharedHelpers.warn,
|
|
114
|
+
persistEnvValues: sharedHelpers.persistEnvValues,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const agentAdapter = { label: 'hermes', backend: 'hermes', readSessionId: () => null, ask: async () => 'mock answer' };
|
|
118
|
+
|
|
119
|
+
// --- circular dep resolution: forward-declared voiceTurnRunner + thunk ---
|
|
120
|
+
let utteranceRouter;
|
|
121
|
+
let voiceTurnRunner;
|
|
122
|
+
const voiceIO = createVoiceIO({
|
|
123
|
+
bridge, settings, client,
|
|
124
|
+
execFileAsync: sharedHelpers.execFileAsync,
|
|
125
|
+
log: sharedHelpers.log, warn: sharedHelpers.warn,
|
|
126
|
+
stamp: sharedHelpers.stamp, sleep: sharedHelpers.sleep,
|
|
127
|
+
isAllowed: sharedHelpers.isAllowed,
|
|
128
|
+
UTTERANCE_IDLE_MS: 100,
|
|
129
|
+
SUBSCRIBE_AFTER_SILENCE_MS: 100,
|
|
130
|
+
MIN_UTTERANCE_BYTES: 1024,
|
|
131
|
+
MIN_MEAN_VOLUME_DB: -50,
|
|
132
|
+
MIN_MAX_VOLUME_DB: -20,
|
|
133
|
+
currentBargeInThresholds: () => ({ minBytes: 0, minMeanDb: -40, minMaxDb: -20, mode: 'normal' }),
|
|
134
|
+
currentPlaybackBargeInThresholds: () => ({ minBytes: 0, minMeanDb: -40, minMaxDb: -20, requireBoth: true }),
|
|
135
|
+
createLiveBargeInMonitor: () => ({ push: () => {} }),
|
|
136
|
+
shouldUseLivePlaybackBargeIn: () => false,
|
|
137
|
+
stopPlaybackForBargeIn: ttsPlayer.stopPlaybackForBargeIn,
|
|
138
|
+
analyzeAudio: async () => ({ meanDb: -10, maxDb: -5 }),
|
|
139
|
+
concatWavs: async () => {},
|
|
140
|
+
saveCapturedVoiceCloneSample: async () => false,
|
|
141
|
+
isBargeInCandidate: () => false,
|
|
142
|
+
validateProcessingBargeIn: async () => ({ action: 'ignore', text: '' }),
|
|
143
|
+
enqueueDeferredProcessingUtterance: () => true,
|
|
144
|
+
newLatencyTurn: () => ({ mark: () => {}, addMeta: () => {}, stage: () => {}, finish: () => {} }),
|
|
145
|
+
// THUNK: at construction time voiceTurnRunner is undefined; the thunk
|
|
146
|
+
// defers the lookup until the call actually happens.
|
|
147
|
+
handleRecording: (...args) => voiceTurnRunner.handleRecording(...args),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const discordVoiceSetup = createDiscordVoiceSetup({
|
|
151
|
+
bridge, client, settings, ROOT: '/tmp/vc',
|
|
152
|
+
log: sharedHelpers.log, warn: sharedHelpers.warn,
|
|
153
|
+
speakText: ttsPlayer.speakText,
|
|
154
|
+
waitEvent: sharedHelpers.waitEvent,
|
|
155
|
+
subscribeUser: voiceIO.subscribeUser,
|
|
156
|
+
pendingFallbackNoticePromises: new Set(),
|
|
157
|
+
bindProjectSessionToChannel: () => null,
|
|
158
|
+
createProjectSession: () => null,
|
|
159
|
+
resolveProjectSessionForChannel: () => null,
|
|
160
|
+
saveProjectSessionsState: noop,
|
|
161
|
+
projectSessionsState: { channelSessions: {} },
|
|
162
|
+
invalidateBackendAdaptersForSession: noop,
|
|
163
|
+
VOICE_CONNECT_TIMEOUT_MS: 5000,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const agentTurnLifecycle = createAgentTurnLifecycle({
|
|
167
|
+
bridge,
|
|
168
|
+
warn: sharedHelpers.warn,
|
|
169
|
+
});
|
|
170
|
+
utteranceRouter = createUtteranceRouter({
|
|
171
|
+
bridge,
|
|
172
|
+
log: sharedHelpers.log, warn: sharedHelpers.warn,
|
|
173
|
+
path: { join: (...a) => a.join('/') },
|
|
174
|
+
ROOT: '/tmp/vc',
|
|
175
|
+
TTS_VOICE_CONFIG_PATH: '/tmp/voices.json',
|
|
176
|
+
agentAdapter, settings,
|
|
177
|
+
projectSessionContextText: () => '',
|
|
178
|
+
createBridgeAgentAdapter: () => ({ label: 'fake', backend: 'fake', ask: async () => '' }),
|
|
179
|
+
buildAgentSettings: () => ({ backend: 'hermes', label: 'hermes' }),
|
|
180
|
+
commandIsInstalled: ttsRuntime.commandIsInstalled,
|
|
181
|
+
shellSplit: s => String(s).split(' '),
|
|
182
|
+
sendText: sharedHelpers.sendText,
|
|
183
|
+
speakText: ttsPlayer.speakText,
|
|
184
|
+
ensureTtsVoiceConfig: () => ({ backends: {} }),
|
|
185
|
+
updateTtsVoiceConfig: c => c,
|
|
186
|
+
writeTtsVoiceConfig: noop,
|
|
187
|
+
applyVoiceConfigToProcessEnv: () => ({ selection: { backend: 'edge', voiceType: 'female', voice: { language: 'ko', voice: 'x' } } }),
|
|
188
|
+
ensureSelectedTtsBackendInstalled: ttsRuntime.ensureSelectedTtsBackendInstalled,
|
|
189
|
+
rebuildTtsRuntimeSettings: noop,
|
|
190
|
+
voiceCommandFromTranscript: () => null,
|
|
191
|
+
voiceChangedText: () => '',
|
|
192
|
+
voiceLanguageCommandFromTranscript: () => null,
|
|
193
|
+
voiceCloneCommandFromText: () => null,
|
|
194
|
+
voiceCloneCapture: { arm: () => ({ targetPath: '' }), cancel: () => false, current: () => null },
|
|
195
|
+
notifyVoiceCloneSampleGapIfNeeded: noopAsync,
|
|
196
|
+
languageChangedText: () => '',
|
|
197
|
+
applyRuntimeLanguage: noop,
|
|
198
|
+
persistEnvValues: noop,
|
|
199
|
+
discardVoiceInputQueues: () => 0,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const planDispatcher = createPlanDispatcher({
|
|
203
|
+
bridge,
|
|
204
|
+
settings,
|
|
205
|
+
sendText: sharedHelpers.sendText,
|
|
206
|
+
speakText: ttsPlayer.speakText,
|
|
207
|
+
routingStateFor: utteranceRouter.routingStateFor,
|
|
208
|
+
adapterForBackend: utteranceRouter.adapterForBackend,
|
|
209
|
+
adapterForProjectSession: utteranceRouter.adapterForProjectSession,
|
|
210
|
+
resolveProjectSessionForChannel: () => null,
|
|
211
|
+
isAgentRoutingDecision: () => false,
|
|
212
|
+
parseDecisionAnswer: () => ({ type: 'unknown' }),
|
|
213
|
+
parsePlanVoiceCommand: () => ({ type: 'unknown' }),
|
|
214
|
+
applyPlanCommand: s => s,
|
|
215
|
+
parsePlanOutput: () => ({ steps: [], decisions: [] }),
|
|
216
|
+
renderDecisionPrompt: () => '',
|
|
217
|
+
renderResolvedDecisions: () => '',
|
|
218
|
+
renderFinalPlan: () => '',
|
|
219
|
+
planModePreamble: () => '',
|
|
220
|
+
planExecutionPreamble: () => '',
|
|
221
|
+
isPlanEntryUtterance: () => false,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
voiceTurnRunner = createVoiceTurnRunner({
|
|
225
|
+
bridge, agentTurnLifecycle, settings, client,
|
|
226
|
+
log: sharedHelpers.log, warn: sharedHelpers.warn, fs: { rm: (_p, _o, cb) => cb && cb() },
|
|
227
|
+
transcribe: voiceIO.transcribe,
|
|
228
|
+
beginStreamingTurn: ttsPlayer.beginStreamingTurn,
|
|
229
|
+
endStreamingTurn: ttsPlayer.endStreamingTurn,
|
|
230
|
+
speakText: ttsPlayer.speakText,
|
|
231
|
+
queueProgressSpeechText: progressHandler.queueProgressSpeechText,
|
|
232
|
+
stopProgressSpeech: progressHandler.stopProgressSpeech,
|
|
233
|
+
speakImmediateNotice: progressHandler.speakImmediateNotice,
|
|
234
|
+
maybeNotifyTaskComplete: notificationHandler.maybeNotifyTaskComplete,
|
|
235
|
+
handleLanguageCommand: utteranceRouter.handleLanguageCommand,
|
|
236
|
+
handleTtsVoiceCommand: utteranceRouter.handleTtsVoiceCommand,
|
|
237
|
+
handleVoiceCloneCommand: utteranceRouter.handleVoiceCloneCommand,
|
|
238
|
+
dispatchPlanModeUtterance: planDispatcher.dispatchPlanModeUtterance,
|
|
239
|
+
adapterForBackend: utteranceRouter.adapterForBackend,
|
|
240
|
+
adapterForProjectSession: utteranceRouter.adapterForProjectSession,
|
|
241
|
+
planChannelKey: planDispatcher.planChannelKey,
|
|
242
|
+
routingStateFor: utteranceRouter.routingStateFor,
|
|
243
|
+
recordUtterance: utteranceRouter.recordUtterance,
|
|
244
|
+
clearTransientRouting: utteranceRouter.clearTransientRouting,
|
|
245
|
+
isAllowed: sharedHelpers.isAllowed,
|
|
246
|
+
isAbortError: sharedHelpers.isAbortError,
|
|
247
|
+
sleep: sharedHelpers.sleep,
|
|
248
|
+
sendText: sharedHelpers.sendText,
|
|
249
|
+
sendEmbed: sharedHelpers.sendEmbed,
|
|
250
|
+
reloadRuntimeLanguageFromEnv: sharedHelpers.reloadRuntimeLanguageFromEnv,
|
|
251
|
+
drainDeferredProcessingUtterances: noopAsync,
|
|
252
|
+
resolveProjectSessionForChannel: () => null,
|
|
253
|
+
projectSessionContextText: () => '',
|
|
254
|
+
ontologyStateFor: () => ({ nodeCount: 0, serializeForHandoff: () => '' }),
|
|
255
|
+
captureOntologyFromTurn: noop,
|
|
256
|
+
formatRecentDiscordContext: () => '',
|
|
257
|
+
formatSttResultMessage: (_lang, _u, t) => `you said: ${t}`,
|
|
258
|
+
formatSttStartMessage: () => '🎧',
|
|
259
|
+
formatVoiceErrorMessage: (_lang, m) => m,
|
|
260
|
+
formatWakeRejectedMessage: () => 'no wake word',
|
|
261
|
+
agentAnswerHeader: () => 'agent says:',
|
|
262
|
+
emptyAgentAnswer: () => '(empty)',
|
|
263
|
+
spokenResultOnly: (_p, a) => a,
|
|
264
|
+
stripWake: t => t,
|
|
265
|
+
acceptsWake: () => true,
|
|
266
|
+
sensitivityChangedSpeech: () => '',
|
|
267
|
+
sensitivityModeFromTranscript: () => null,
|
|
268
|
+
sensitivityStatusText: () => '',
|
|
269
|
+
setSensitivityMode: () => ({ mode: 'normal' }),
|
|
270
|
+
isSensitivityOnlyRequest: () => false,
|
|
271
|
+
verboseChangedSpeech: () => '',
|
|
272
|
+
verboseModeFromTranscript: () => null,
|
|
273
|
+
verboseStatusText: () => '',
|
|
274
|
+
setVerboseProgress: noop,
|
|
275
|
+
isVerboseOnlyRequest: () => false,
|
|
276
|
+
isRoutingOnlyUtterance: () => false,
|
|
277
|
+
parseAgentRoutingCommand: () => ({ type: 'none' }),
|
|
278
|
+
renderAgentPrefix: () => '',
|
|
279
|
+
buildCrossAgentPrompt: ({ prompt }) => prompt,
|
|
280
|
+
buildFallbackDecision: () => ({ slot: 'fallback' }),
|
|
281
|
+
parseDecisionAnswer: () => ({ type: 'unknown' }),
|
|
282
|
+
parseResearchCommand: () => ({ type: 'none' }),
|
|
283
|
+
runResearchTurn: async () => ({ status: 'no_backend' }),
|
|
284
|
+
PROGRESS_IDLE_CHECK_MS: 5000,
|
|
285
|
+
PROGRESS_IDLE_NOTICE_INITIAL_MS: 10000,
|
|
286
|
+
PROGRESS_IDLE_NOTICE_LIMIT: 20,
|
|
287
|
+
PROGRESS_IDLE_NOTICE_MAX_MS: 30000,
|
|
288
|
+
PROGRESS_IDLE_NOTICE_MULTIPLIER: 1.8,
|
|
289
|
+
STT_START_VOICE_NOTICE: false,
|
|
290
|
+
// Apply user overrides to voiceTurnRunner too (deps like `transcribe` live
|
|
291
|
+
// here in Phase 7a+ — they used to be on the utteranceRouter factory).
|
|
292
|
+
...overrides,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { bridge, settings, client, agentAdapter, ttsPlayer, progressHandler, notificationHandler, ttsRuntime, voiceIO, discordVoiceSetup, utteranceRouter, voiceTurnRunner, getUtteranceRouter: () => utteranceRouter };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- wire-up correctness -------------------------------------------------
|
|
299
|
+
|
|
300
|
+
test('the forward-declared voiceTurnRunner thunk resolves at invocation time', async () => {
|
|
301
|
+
// Reproduce main.mjs's exact pattern: voiceIO is built first with a
|
|
302
|
+
// closure that references `utteranceRouter` before it's assigned. If
|
|
303
|
+
// the thunk accidentally captured the (still-undefined) value at
|
|
304
|
+
// construction time, calling it would throw a TypeError. We assert the
|
|
305
|
+
// simpler contract: handleRecording is reachable AND a full turn lands
|
|
306
|
+
// in cleanup without raising.
|
|
307
|
+
//
|
|
308
|
+
// (Tracking the exact adapter the router picks for routedBackend='hermes'
|
|
309
|
+
// is brittle in unit-test conditions because adapterForBackend probes the
|
|
310
|
+
// real PATH via commandIsInstalled; see the end-to-end test below for the
|
|
311
|
+
// strict round-trip via voiceIO.transcribe instead.)
|
|
312
|
+
const system = buildSystem();
|
|
313
|
+
assert.equal(typeof system.voiceTurnRunner.handleRecording, 'function');
|
|
314
|
+
let threw = null;
|
|
315
|
+
try {
|
|
316
|
+
await system.voiceTurnRunner.handleRecording('user-1', '/tmp/u.wav', 8192, 1, null);
|
|
317
|
+
} catch (e) { threw = e; }
|
|
318
|
+
assert.equal(threw, null, 'handleRecording reachable via thunk closure; no TDZ throw');
|
|
319
|
+
assert.equal(system.bridge.processing, false, 'turn cleaned up');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('bridge state is shared by reference across factory closures', () => {
|
|
323
|
+
const { bridge, ttsPlayer, progressHandler, utteranceRouter } = buildSystem();
|
|
324
|
+
|
|
325
|
+
// utteranceRouter mutates bridge.processing during a turn; we'll simulate
|
|
326
|
+
// by manually setting + reading from another module's perspective.
|
|
327
|
+
bridge.processing = true;
|
|
328
|
+
bridge.speaking = true;
|
|
329
|
+
|
|
330
|
+
// tts_player.stopPlaybackForBargeIn reads bridge.speaking.
|
|
331
|
+
// Calling it should clear bridge.speaking + bump speechPlaybackGeneration.
|
|
332
|
+
const stopped = ttsPlayer.stopPlaybackForBargeIn('test-user', 'test');
|
|
333
|
+
assert.equal(stopped, true);
|
|
334
|
+
assert.equal(bridge.speaking, false, 'shared bridge.speaking cleared by tts_player');
|
|
335
|
+
assert.equal(bridge.speechPlaybackGeneration, 1);
|
|
336
|
+
|
|
337
|
+
// progress_handler also touches bridge.speaking.
|
|
338
|
+
bridge.speaking = true;
|
|
339
|
+
bridge.activeProgressSignal = 'sig-A';
|
|
340
|
+
progressHandler.stopProgressSpeech('sig-A', 'final');
|
|
341
|
+
assert.equal(bridge.activeProgressSignal, null, 'progress handler cleared shared signal');
|
|
342
|
+
assert.equal(bridge.speaking, false, 'progress handler cleared shared speaking flag');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('voice_io.transcribe is reachable from voice_turn_runner via deps', async () => {
|
|
346
|
+
// Reproduces the other half of the circular dep: voice_turn_runner calls
|
|
347
|
+
// voice_io.transcribe through its deps. Patch voice_io.transcribe and
|
|
348
|
+
// verify handleRecording reaches it.
|
|
349
|
+
let transcribedPath = null;
|
|
350
|
+
const system = buildSystem({
|
|
351
|
+
transcribe: async wav => { transcribedPath = wav; return 'hermes say hi'; },
|
|
352
|
+
});
|
|
353
|
+
await system.voiceTurnRunner.handleRecording('user-1', '/tmp/wired.wav', 4096, 1, null);
|
|
354
|
+
assert.equal(transcribedPath, '/tmp/wired.wav', 'runner reached voice_io.transcribe through deps');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('utteranceRouter destructured exports include dispatch handlers + adapter selection', () => {
|
|
358
|
+
// Plan-mode dispatch moved to plan_dispatcher in Phase 7b.
|
|
359
|
+
// handleRecording moved to voice_turn_runner in Phase 7a.
|
|
360
|
+
const { utteranceRouter, voiceTurnRunner } = buildSystem();
|
|
361
|
+
for (const name of [
|
|
362
|
+
'adapterForProjectSession', 'routingStateFor', 'recordUtterance',
|
|
363
|
+
'clearTransientRouting', 'adapterForBackend', 'handleTtsVoiceCommand', 'handleLanguageCommand',
|
|
364
|
+
'handleVoiceCloneCommand', 'interruptCurrentResponse',
|
|
365
|
+
]) {
|
|
366
|
+
assert.equal(typeof utteranceRouter[name], 'function', `utterance_router.${name} bound after factory call`);
|
|
367
|
+
}
|
|
368
|
+
assert.equal(typeof voiceTurnRunner.handleRecording, 'function', 'voice_turn_runner.handleRecording bound');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('voiceIO destructured exports include transcribe + subscribeUser', () => {
|
|
372
|
+
const { voiceIO } = buildSystem();
|
|
373
|
+
for (const name of ['transcribeOnce', 'transcribe', 'cleanTranscript', 'queueSegment', 'flushUtterance', 'subscribeUser']) {
|
|
374
|
+
assert.equal(typeof voiceIO[name], 'function', `voice_io.${name} bound after factory call`);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('discordVoiceSetup destructured exports include gracefulShutdown', () => {
|
|
379
|
+
const { discordVoiceSetup } = buildSystem();
|
|
380
|
+
for (const name of ['connectTo', 'autoJoin', 'findVoiceChannelBySelector', 'voiceChannelLabel', 'resolveVoiceChannelForAttach', 'attachVoiceChannelToTextSession', 'gracefulShutdown']) {
|
|
381
|
+
assert.equal(typeof discordVoiceSetup[name], 'function', `discord_voice_setup.${name} bound after factory call`);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('progressHandler + ttsPlayer share bridge.speaking via separate code paths', () => {
|
|
386
|
+
// Phase 5 layering specifically split speech-stop logic between
|
|
387
|
+
// tts_player.stopPlaybackForBargeIn (final answer barge-in) and
|
|
388
|
+
// progress_handler.stopProgressSpeech (progress speech termination).
|
|
389
|
+
// Both touch bridge.speaking. This test pins their contract so future
|
|
390
|
+
// refactors don't accidentally diverge.
|
|
391
|
+
const { bridge, ttsPlayer, progressHandler } = buildSystem();
|
|
392
|
+
|
|
393
|
+
// ttsPlayer: only acts when bridge.speaking is already true.
|
|
394
|
+
assert.equal(ttsPlayer.stopPlaybackForBargeIn('u1', 'noop'), false);
|
|
395
|
+
bridge.speaking = true;
|
|
396
|
+
assert.equal(ttsPlayer.stopPlaybackForBargeIn('u1', 'go'), true);
|
|
397
|
+
assert.equal(bridge.speaking, false);
|
|
398
|
+
|
|
399
|
+
// progressHandler: only acts when signal matches activeProgressSignal.
|
|
400
|
+
bridge.speaking = true;
|
|
401
|
+
bridge.activeProgressSignal = 'A';
|
|
402
|
+
progressHandler.stopProgressSpeech('B', 'mismatch');
|
|
403
|
+
assert.equal(bridge.speaking, true, 'mismatched signal leaves speaking alone');
|
|
404
|
+
progressHandler.stopProgressSpeech('A', 'match');
|
|
405
|
+
assert.equal(bridge.speaking, false);
|
|
406
|
+
assert.equal(bridge.activeProgressSignal, null);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('end-to-end: queueSegment timer triggers flushUtterance which calls handleRecording via thunk', async () => {
|
|
410
|
+
// The most important wire-up test: real voiceIO.queueSegment, real timer,
|
|
411
|
+
// resolves through the forward-declared thunk to the real handleRecording,
|
|
412
|
+
// which calls back into voiceIO.transcribe through its own deps.
|
|
413
|
+
let handleRecordingCalled = false;
|
|
414
|
+
let transcribeCalled = false;
|
|
415
|
+
const system = buildSystem({
|
|
416
|
+
transcribe: async () => { transcribeCalled = true; return 'hermes echo'; },
|
|
417
|
+
});
|
|
418
|
+
// Override handleRecording entirely so we can observe the thunk path.
|
|
419
|
+
// We need to re-build the system in a way that lets us patch the router
|
|
420
|
+
// post-construction. Easiest: monkey-patch the returned router instance.
|
|
421
|
+
const originalHandleRecording = system.voiceTurnRunner.handleRecording;
|
|
422
|
+
system.voiceTurnRunner.handleRecording = async (...args) => {
|
|
423
|
+
handleRecordingCalled = true;
|
|
424
|
+
return originalHandleRecording(...args);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Seed the bridge state we need for queueSegment + flushUtterance:
|
|
428
|
+
// appendSegment must return a 'pending' shape with a timer that fires.
|
|
429
|
+
let pendingTimer = null;
|
|
430
|
+
let pendingFiles = [];
|
|
431
|
+
let pendingTimerFactory = null;
|
|
432
|
+
system.bridge.bridgeState = {
|
|
433
|
+
deferredSize: () => 0,
|
|
434
|
+
currentEpoch: () => 1,
|
|
435
|
+
discardQueues: () => 0,
|
|
436
|
+
appendSegment: (_uid, seg) => {
|
|
437
|
+
pendingFiles.push(seg.file);
|
|
438
|
+
pendingTimerFactory = seg.timerFactory;
|
|
439
|
+
pendingTimer = seg.timerFactory();
|
|
440
|
+
return { files: pendingFiles, pcmBytes: 16384, epoch: 1, timer: pendingTimer };
|
|
441
|
+
},
|
|
442
|
+
deletePending: () => ({
|
|
443
|
+
files: pendingFiles,
|
|
444
|
+
pcmBytes: 16384,
|
|
445
|
+
epoch: 1,
|
|
446
|
+
timer: pendingTimer,
|
|
447
|
+
firstPacketAt: Date.now(),
|
|
448
|
+
lastSegmentEndAt: Date.now(),
|
|
449
|
+
}),
|
|
450
|
+
getPending: () => null,
|
|
451
|
+
clearPendingTimer: () => {},
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Kick off the chain: queueSegment -> timer fires -> flushUtterance ->
|
|
455
|
+
// handleRecording (via thunk) -> transcribe (via deps).
|
|
456
|
+
system.voiceIO.queueSegment('user-1', '/tmp/seg.wav', 16384, Date.now(), Date.now());
|
|
457
|
+
// Let the timer (UTTERANCE_IDLE_MS=100 in our setup) fire.
|
|
458
|
+
await new Promise(r => setTimeout(r, 200));
|
|
459
|
+
|
|
460
|
+
assert.equal(handleRecordingCalled, true, 'queueSegment -> flushUtterance -> thunk -> router.handleRecording');
|
|
461
|
+
assert.equal(transcribeCalled, true, 'router.handleRecording -> voiceIO.transcribe via deps');
|
|
462
|
+
});
|
|
@@ -8,6 +8,11 @@ import { healInstanceProfileFromEnv } from './instance_profile_lifecycle.mjs';
|
|
|
8
8
|
|
|
9
9
|
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
10
10
|
|
|
11
|
+
const __tempRoots = [];
|
|
12
|
+
test.after(() => {
|
|
13
|
+
for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
14
|
+
});
|
|
15
|
+
|
|
11
16
|
test('package exposes a short vc shell command', () => {
|
|
12
17
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
13
18
|
|
|
@@ -20,6 +25,7 @@ test('package exposes a short vc shell command', () => {
|
|
|
20
25
|
assert.ok(pkg.files.includes('scripts/*.mjs'));
|
|
21
26
|
assert.ok(pkg.files.includes('scripts/*.sh'));
|
|
22
27
|
assert.ok(pkg.files.includes('integrations/openvoice/*.py'));
|
|
28
|
+
assert.ok(pkg.files.includes('integrations/fireredtts2/*.py'));
|
|
23
29
|
assert.ok(!pkg.files.includes('integrations/openvoice/'));
|
|
24
30
|
assert.ok(!pkg.files.includes('scripts/*.py'));
|
|
25
31
|
assert.ok(pkg.files.includes('run.sh'));
|
|
@@ -93,6 +99,9 @@ test('doctor auto-bootstraps fixable prerequisites by default', () => {
|
|
|
93
99
|
assert.match(doctor, /WHISPER_CPP_BIN/);
|
|
94
100
|
assert.match(doctor, /EDGE_TTS_COMMAND/);
|
|
95
101
|
assert.match(doctor, /installHermesCliIfNeeded/);
|
|
102
|
+
assert.match(doctor, /installFireRedTts2IfNeeded/);
|
|
103
|
+
assert.match(doctor, /install_fireredtts2\.sh'\), '--yes'/);
|
|
104
|
+
assert.match(doctor, /VERBALCODING_DOCTOR_INSTALL_FIREREDTTS2/);
|
|
96
105
|
assert.match(doctor, /NousResearch\/hermes-agent\/main\/scripts\/install\.sh/);
|
|
97
106
|
assert.match(doctor, /VERBALCODING_DOCTOR_INSTALL_HERMES/);
|
|
98
107
|
assert.match(doctor, /Discord bot setup:/);
|
|
@@ -101,6 +110,27 @@ test('doctor auto-bootstraps fixable prerequisites by default', () => {
|
|
|
101
110
|
assert.match(cli, /doctor\.mjs'\), \.\.\.argv\.slice\(1\)/);
|
|
102
111
|
});
|
|
103
112
|
|
|
113
|
+
test('FireRedTTS-2 backend has package-managed installer and wrapper', () => {
|
|
114
|
+
const installer = fs.readFileSync(path.join(ROOT, 'scripts', 'install_fireredtts2.sh'), 'utf8');
|
|
115
|
+
const config = fs.readFileSync(path.join(ROOT, 'app-node', 'install_config.mjs'), 'utf8');
|
|
116
|
+
const settings = fs.readFileSync(path.join(ROOT, 'app-node', 'tts_settings.mjs'), 'utf8');
|
|
117
|
+
const main = fs.readFileSync(path.join(ROOT, 'app-node', 'main.mjs'), 'utf8');
|
|
118
|
+
const ttsRuntime = fs.readFileSync(path.join(ROOT, 'app-node', 'tts_runtime.mjs'), 'utf8');
|
|
119
|
+
const wrapper = fs.readFileSync(path.join(ROOT, 'integrations', 'fireredtts2', 'synth.py'), 'utf8');
|
|
120
|
+
|
|
121
|
+
assert.match(installer, /FireRedTeam\/FireRedTTS2\.git/);
|
|
122
|
+
assert.match(installer, /huggingface\.co\/FireRedTeam\/FireRedTTS2/);
|
|
123
|
+
assert.match(installer, /\.local\/bin\/fireredtts2/);
|
|
124
|
+
assert.match(config, /fireredtts2/);
|
|
125
|
+
assert.match(config, /FIREREDTTS2_COMMAND/);
|
|
126
|
+
assert.match(settings, /\.\/\.local\/bin\/fireredtts2/);
|
|
127
|
+
// ensureSelectedTtsBackendInstalled was extracted into tts_runtime.mjs;
|
|
128
|
+
// main.mjs still imports / destructures it from the factory.
|
|
129
|
+
assert.match(main, /ensureSelectedTtsBackendInstalled/);
|
|
130
|
+
assert.match(ttsRuntime, /install_fireredtts2\.sh/);
|
|
131
|
+
assert.match(wrapper, /generate_monologue/);
|
|
132
|
+
});
|
|
133
|
+
|
|
104
134
|
test('setup summary guides Discord app creation and records client id', () => {
|
|
105
135
|
const installer = fs.readFileSync(path.join(ROOT, 'scripts', 'install.mjs'), 'utf8');
|
|
106
136
|
const config = fs.readFileSync(path.join(ROOT, 'app-node', 'install_config.mjs'), 'utf8');
|
|
@@ -128,6 +158,7 @@ test('Ubuntu Docker smoke script validates clean install without secrets', () =>
|
|
|
128
158
|
|
|
129
159
|
test('healInstanceProfileFromEnv ensures profile when HERMES_HOME is set', async () => {
|
|
130
160
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-home-'));
|
|
161
|
+
__tempRoots.push(home);
|
|
131
162
|
const calls = [];
|
|
132
163
|
const ensure = async args => {
|
|
133
164
|
calls.push(args);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
parseAgentRoutingCommand, resolveBackendAlias,
|
|
5
|
+
renderAgentPrefix, buildCrossAgentPrompt,
|
|
6
|
+
} from './agent_routing.mjs';
|
|
7
|
+
import { parseDecisionAnswer } from './plan_mode.mjs';
|
|
8
|
+
|
|
9
|
+
test('routing pipeline: ask Codex resolves to single-turn route', () => {
|
|
10
|
+
const cmd = parseAgentRoutingCommand('ask Codex what it thinks', 'en');
|
|
11
|
+
assert.equal(cmd.type, 'route');
|
|
12
|
+
assert.equal(cmd.backend, 'codex');
|
|
13
|
+
assert.equal(cmd.sticky, false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('routing pipeline: switch to Aider resolves to sticky route', () => {
|
|
17
|
+
const cmd = parseAgentRoutingCommand('switch to Aider', 'en');
|
|
18
|
+
assert.equal(cmd.type, 'route');
|
|
19
|
+
assert.equal(cmd.backend, 'aider');
|
|
20
|
+
assert.equal(cmd.sticky, true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('routing pipeline: alias resolves Claude Code to claude', () => {
|
|
24
|
+
assert.equal(resolveBackendAlias('Claude Code'), 'claude');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('routing pipeline: prefix changes only when backend changes', () => {
|
|
28
|
+
let last = 'claude';
|
|
29
|
+
let next = 'codex';
|
|
30
|
+
let prefix = last === next ? '' : renderAgentPrefix(next, 'en');
|
|
31
|
+
assert.equal(prefix, 'Codex says: ');
|
|
32
|
+
last = next;
|
|
33
|
+
next = 'codex';
|
|
34
|
+
prefix = last === next ? '' : renderAgentPrefix(next, 'en');
|
|
35
|
+
assert.equal(prefix, '');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('routing pipeline: cross-agent prompt carries prior decisions', () => {
|
|
39
|
+
const out = buildCrossAgentPrompt({
|
|
40
|
+
prompt: 'finish the OAuth wire-up',
|
|
41
|
+
fromBackend: 'claude', toBackend: 'codex',
|
|
42
|
+
resolvedDecisions: { oauth_provider: 'github' },
|
|
43
|
+
priorUtterances: ['plan it first'],
|
|
44
|
+
language: 'en',
|
|
45
|
+
});
|
|
46
|
+
assert.match(out, /from Claude Code to Codex/);
|
|
47
|
+
assert.match(out, /oauth_provider=github/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('which_agent decision: voice answer maps to backend name', () => {
|
|
51
|
+
const decision = { slot: 'which_agent', question: 'Who?', options: ['codex', 'aider'] };
|
|
52
|
+
assert.equal(parseDecisionAnswer('codex', decision, 'en').choice, 'codex');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('which_agent decision: ordinal answer maps to backend', () => {
|
|
56
|
+
const decision = { slot: 'which_agent', question: 'Who?', options: ['codex', 'aider', 'claude'] };
|
|
57
|
+
assert.equal(parseDecisionAnswer('the third one', decision, 'en').choice, 'claude');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('e2e composition: ask Codex single-turn, then back to default', () => {
|
|
61
|
+
const turn1 = parseAgentRoutingCommand('ask Codex what it thinks', 'en');
|
|
62
|
+
assert.equal(turn1.type, 'route');
|
|
63
|
+
assert.equal(turn1.sticky, false);
|
|
64
|
+
const prompt = buildCrossAgentPrompt({
|
|
65
|
+
prompt: 'ask Codex what it thinks',
|
|
66
|
+
fromBackend: 'claude', toBackend: turn1.backend,
|
|
67
|
+
resolvedDecisions: {}, priorUtterances: [], language: 'en',
|
|
68
|
+
});
|
|
69
|
+
assert.match(prompt, /from Claude Code to Codex/);
|
|
70
|
+
const turn2 = parseAgentRoutingCommand('also add a test', 'en');
|
|
71
|
+
assert.equal(turn2.type, 'none');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('e2e composition: switch sticky until restore', () => {
|
|
75
|
+
assert.equal(parseAgentRoutingCommand('switch to Aider', 'en').sticky, true);
|
|
76
|
+
assert.equal(parseAgentRoutingCommand('write the test', 'en').type, 'none');
|
|
77
|
+
assert.equal(parseAgentRoutingCommand('back to default', 'en').type, 'restore');
|
|
78
|
+
});
|