verbalcoding 0.2.11 → 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 +98 -2
- package/README.es.md +134 -0
- package/README.fr.md +134 -0
- package/README.ja.md +134 -0
- package/README.ko.md +134 -0
- package/README.md +118 -74
- package/README.ru.md +134 -0
- package/README.zh.md +133 -0
- package/app-node/agent_adapters.mjs +37 -5
- package/app-node/agent_adapters.test.mjs +27 -1
- package/app-node/agent_detect.mjs +73 -0
- package/app-node/agent_detect.test.mjs +77 -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 +113 -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 +513 -1058
- 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/notify.mjs +73 -0
- package/app-node/notify.test.mjs +68 -0
- package/app-node/plan_dispatcher.mjs +215 -0
- package/app-node/plan_dispatcher.test.mjs +101 -0
- package/app-node/plan_mode.mjs +203 -0
- package/app-node/plan_mode.test.mjs +231 -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 +94 -0
- package/app-node/smart_progress.test.mjs +66 -0
- package/app-node/stream_sentencer.mjs +91 -0
- package/app-node/stream_sentencer.test.mjs +129 -0
- package/app-node/streaming_tts_queue.mjs +52 -0
- package/app-node/streaming_tts_queue.test.mjs +64 -0
- package/app-node/stt_whisper.mjs +24 -0
- package/app-node/stt_whisper.test.mjs +32 -0
- package/app-node/text_routing.mjs +22 -0
- package/app-node/text_routing.test.mjs +23 -1
- 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 +79 -96
- package/docs/FRESH_INSTALL.md +105 -63
- 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/HERMES_VOICE.md +65 -0
- package/docs/MULTI_INSTANCE.md +16 -0
- package/docs/README.md +50 -0
- package/docs/RELEASE.md +42 -19
- package/docs/ROADMAP.md +53 -0
- package/docs/TROUBLESHOOTING.md +126 -0
- package/docs/TTS_BACKENDS.md +227 -0
- package/docs/USAGE.md +94 -40
- package/docs/assets/figures/verbalcoding-flow.svg +1 -1
- 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/CONFIGURATION.es.md +25 -0
- package/docs/i18n/CONFIGURATION.fr.md +25 -0
- package/docs/i18n/CONFIGURATION.ja.md +25 -0
- package/docs/i18n/CONFIGURATION.ko.md +25 -0
- package/docs/i18n/CONFIGURATION.ru.md +25 -0
- package/docs/i18n/CONFIGURATION.zh.md +25 -0
- package/docs/i18n/FRESH_INSTALL.es.md +27 -2
- package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
- package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
- 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/i18n/HERMES_VOICE.es.md +46 -0
- package/docs/i18n/HERMES_VOICE.fr.md +46 -0
- package/docs/i18n/HERMES_VOICE.ja.md +46 -0
- package/docs/i18n/HERMES_VOICE.ko.md +65 -0
- package/docs/i18n/HERMES_VOICE.ru.md +46 -0
- package/docs/i18n/HERMES_VOICE.zh.md +46 -0
- package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
- package/docs/i18n/README.es.md +20 -134
- package/docs/i18n/README.fr.md +20 -134
- package/docs/i18n/README.ja.md +20 -134
- package/docs/i18n/README.ko.md +20 -133
- package/docs/i18n/README.ru.md +20 -134
- package/docs/i18n/README.zh.md +20 -133
- package/docs/i18n/RELEASE.es.md +26 -1
- package/docs/i18n/RELEASE.fr.md +26 -1
- package/docs/i18n/RELEASE.ja.md +26 -1
- package/docs/i18n/RELEASE.ko.md +26 -1
- package/docs/i18n/RELEASE.ru.md +26 -1
- package/docs/i18n/RELEASE.zh.md +26 -1
- package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
- package/docs/i18n/USAGE.es.md +25 -0
- package/docs/i18n/USAGE.fr.md +25 -0
- package/docs/i18n/USAGE.ja.md +25 -0
- package/docs/i18n/USAGE.ko.md +25 -0
- package/docs/i18n/USAGE.ru.md +25 -0
- package/docs/i18n/USAGE.zh.md +25 -0
- package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
- package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
- package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
- package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
- package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -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 +7 -1
- package/scripts/cli.mjs +88 -3
- package/scripts/doctor.mjs +115 -4
- package/scripts/install.mjs +20 -2
- 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,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
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
}
|
|
8
|
+
|
|
9
|
+
const SKIP_EN = /\bskip\s+step\s+(\d+)\b/i;
|
|
10
|
+
const SKIP_KO = /step\s*(\d+)\s*건너뛰/i;
|
|
11
|
+
const ADD_EN = /\badd\s+(.+?)\s+after\s+step\s+(\d+)\b/i;
|
|
12
|
+
const ADD_KO = /step\s*(\d+)\s*다음에\s+(.+?)\s*추가/i;
|
|
13
|
+
const APPROVE_EN = /\b(approve|go\s*ahead|let'?s\s+go|run\s+it|proceed)\b/i;
|
|
14
|
+
const APPROVE_KO = /(실행|진행|승인)/i;
|
|
15
|
+
const CANCEL_EN = /\b(cancel|stop|nevermind|never\s+mind)\b/i;
|
|
16
|
+
const CANCEL_KO = /(취소|그만)/i;
|
|
17
|
+
const ENTER_EN = /\b(plan\s+(it\s+)?first|make\s+a\s+plan)\b/i;
|
|
18
|
+
const ENTER_KO = /(먼저\s*계획|계획\s*먼저|계획부터)/i;
|
|
19
|
+
|
|
20
|
+
export function parsePlanOutput(text) {
|
|
21
|
+
const planMatch = lastMatch(text, PLAN_RE);
|
|
22
|
+
if (!planMatch) return { steps: [], decisions: [] };
|
|
23
|
+
const steps = planMatch[1]
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.map(line => line.match(/^\s*(\d+)\.\s*(.+)$/))
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map(m => ({ id: Number(m[1]), text: m[2].trim(), status: 'pending' }));
|
|
28
|
+
const afterPlan = String(text || '').slice(planMatch.index + planMatch[0].length);
|
|
29
|
+
const decisions = parseDecisions(afterPlan);
|
|
30
|
+
return { steps, decisions };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseDecisions(text) {
|
|
34
|
+
const match = lastMatch(text, DECISIONS_RE);
|
|
35
|
+
if (!match) return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
let counter = 1;
|
|
38
|
+
for (const raw of match[1].split(/\r?\n/)) {
|
|
39
|
+
const line = raw.replace(/^\s*-\s*/, '').trim();
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
const parts = line.split('|').map(s => s.trim()).filter(Boolean);
|
|
42
|
+
if (parts.length < 3) continue;
|
|
43
|
+
const [slot, question, ...options] = parts;
|
|
44
|
+
out.push({
|
|
45
|
+
slot: slot || `decision_${counter++}`,
|
|
46
|
+
question,
|
|
47
|
+
options: options.filter(Boolean),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ORDINAL_EN = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, '1st': 1, '2nd': 2, '3rd': 3 };
|
|
54
|
+
const ORDINAL_KO = { '첫': 1, '첫번째': 1, '첫 번째': 1, '두번째': 2, '두 번째': 2, '세번째': 3, '세 번째': 3, '네번째': 4 };
|
|
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;
|
|
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
|
+
|
|
71
|
+
export function parseDecisionAnswer(utterance, decision, language = 'en') {
|
|
72
|
+
const text = String(utterance || '').trim();
|
|
73
|
+
if (!text || !decision || !Array.isArray(decision.options) || decision.options.length === 0) {
|
|
74
|
+
return { type: 'unknown', choice: null };
|
|
75
|
+
}
|
|
76
|
+
const lower = text.toLowerCase();
|
|
77
|
+
if (DEFER_RE.test(text)) return { type: 'auto', choice: null };
|
|
78
|
+
for (const opt of decision.options) {
|
|
79
|
+
const optLower = String(opt).toLowerCase();
|
|
80
|
+
if (optionMatchesText(lower, optLower)) return { type: 'option', choice: opt };
|
|
81
|
+
}
|
|
82
|
+
const numMatch = text.match(/\b(\d+)\b/);
|
|
83
|
+
if (numMatch) {
|
|
84
|
+
const idx = Number(numMatch[1]);
|
|
85
|
+
if (idx >= 1 && idx <= decision.options.length) return { type: 'option', choice: decision.options[idx - 1] };
|
|
86
|
+
}
|
|
87
|
+
for (const [word, idx] of Object.entries(ORDINAL_EN)) {
|
|
88
|
+
if (lower.includes(word)) {
|
|
89
|
+
if (idx <= decision.options.length) return { type: 'option', choice: decision.options[idx - 1] };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const [word, idx] of Object.entries(ORDINAL_KO)) {
|
|
93
|
+
if (text.includes(word)) {
|
|
94
|
+
if (idx <= decision.options.length) return { type: 'option', choice: decision.options[idx - 1] };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { type: 'unknown', choice: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function renderDecisionPrompt(decision, language = 'en') {
|
|
101
|
+
if (!decision) return '';
|
|
102
|
+
const opts = decision.options.map((o, i) => `${i + 1}) ${o}`).join(' ');
|
|
103
|
+
if (/^en/i.test(String(language || ''))) {
|
|
104
|
+
return `${decision.question} Options: ${opts}. Or say "either" to let me pick.`;
|
|
105
|
+
}
|
|
106
|
+
return `${decision.question} 옵션: ${opts}. "아무거나"라고 하면 내가 고를게.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function renderResolvedDecisions(resolved, language = 'en') {
|
|
110
|
+
const keys = Object.keys(resolved || {});
|
|
111
|
+
if (!keys.length) return '';
|
|
112
|
+
const parts = keys.map(k => `${k}=${resolved[k] === null ? '(agent picks)' : resolved[k]}`);
|
|
113
|
+
return /^en/i.test(String(language || ''))
|
|
114
|
+
? `Resolved decisions: ${parts.join(', ')}.`
|
|
115
|
+
: `결정 사항: ${parts.join(', ')}.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isPlanEntryUtterance(text, language = 'en') {
|
|
119
|
+
const t = String(text || '');
|
|
120
|
+
if (language === 'ko') return ENTER_KO.test(t) || ENTER_EN.test(t);
|
|
121
|
+
return ENTER_EN.test(t) || ENTER_KO.test(t);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseVoiceCommand(text, language = 'en') {
|
|
125
|
+
const t = String(text || '').trim();
|
|
126
|
+
let m = t.match(SKIP_EN) || t.match(SKIP_KO);
|
|
127
|
+
if (m) return { type: 'skip', index: Number(m[1]) };
|
|
128
|
+
m = t.match(ADD_EN);
|
|
129
|
+
if (m) return { type: 'insert', after: Number(m[2]), text: m[1].trim() };
|
|
130
|
+
m = t.match(ADD_KO);
|
|
131
|
+
if (m) return { type: 'insert', after: Number(m[1]), text: m[2].trim() };
|
|
132
|
+
if (APPROVE_EN.test(t) || APPROVE_KO.test(t)) return { type: 'approve' };
|
|
133
|
+
if (CANCEL_EN.test(t) || CANCEL_KO.test(t)) return { type: 'cancel' };
|
|
134
|
+
return { type: 'unknown' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function applyCommand(steps, cmd) {
|
|
138
|
+
if (!Array.isArray(steps)) return [];
|
|
139
|
+
if (cmd.type === 'skip') {
|
|
140
|
+
return steps.map(s => (s.id === cmd.index ? { ...s, status: 'skipped' } : s));
|
|
141
|
+
}
|
|
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
|
+
}
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const s of steps) {
|
|
152
|
+
out.push(s);
|
|
153
|
+
if (s.id === cmd.after) {
|
|
154
|
+
out.push({ id: proposed, text: cmd.text, status: 'added' });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
return steps;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function renderFinalPlan(steps) {
|
|
163
|
+
const active = (steps || []).filter(s => s.status !== 'skipped');
|
|
164
|
+
return active.map((s, i) => `${i + 1}. ${s.text}`).join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function planModePreamble(language = 'en') {
|
|
168
|
+
if (language === 'ko') {
|
|
169
|
+
return [
|
|
170
|
+
'지금은 PLAN MODE다. 파일을 절대 수정하지 마라.',
|
|
171
|
+
'아래 형식으로 짧은 계획을 답하고, 결정이 필요한 분기마다 DECISIONS 블록에 질문을 적어라.',
|
|
172
|
+
'PLAN_BEGIN',
|
|
173
|
+
'1. ...',
|
|
174
|
+
'2. ...',
|
|
175
|
+
'PLAN_END',
|
|
176
|
+
'DECISIONS_BEGIN',
|
|
177
|
+
'- <slot> | <한 문장 질문> | <옵션1> | <옵션2> | ...',
|
|
178
|
+
'DECISIONS_END',
|
|
179
|
+
'각 단계는 12단어 이하 한국어 한 줄. slot은 oauth_provider, session_store 같은 짧은 영문 키.',
|
|
180
|
+
'slot이 "which_agent"이면 다음에 답할 CLI 에이전트를 묻는 분기다 (options: codex, aider, claude, gemini, opencode, openclaw, cursor, hermes).',
|
|
181
|
+
'결정이 필요 없으면 DECISIONS 블록 자체를 생략해라.',
|
|
182
|
+
].join('\n');
|
|
183
|
+
}
|
|
184
|
+
return [
|
|
185
|
+
'You are in PLAN MODE. Do NOT modify any files.',
|
|
186
|
+
'Reply with a short plan AND list any forks/decisions you would normally pick yourself.',
|
|
187
|
+
'PLAN_BEGIN',
|
|
188
|
+
'1. ...',
|
|
189
|
+
'2. ...',
|
|
190
|
+
'PLAN_END',
|
|
191
|
+
'DECISIONS_BEGIN',
|
|
192
|
+
'- <slot> | <one-sentence question> | <option1> | <option2> | ...',
|
|
193
|
+
'DECISIONS_END',
|
|
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).',
|
|
196
|
+
'Omit the DECISIONS block entirely if there is nothing to ask.',
|
|
197
|
+
].join('\n');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function planExecutionPreamble(language = 'en') {
|
|
201
|
+
if (language === 'ko') return '아래 계획에 따라 작업을 실행해라. 각 단계가 끝나면 다음으로 진행해라.';
|
|
202
|
+
return 'Execute the following plan. Move to the next step as each one completes.';
|
|
203
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
parsePlanOutput,
|
|
5
|
+
parseDecisions,
|
|
6
|
+
parseDecisionAnswer,
|
|
7
|
+
renderDecisionPrompt,
|
|
8
|
+
renderResolvedDecisions,
|
|
9
|
+
parseVoiceCommand,
|
|
10
|
+
applyCommand,
|
|
11
|
+
renderFinalPlan,
|
|
12
|
+
isPlanEntryUtterance,
|
|
13
|
+
planModePreamble,
|
|
14
|
+
} from './plan_mode.mjs';
|
|
15
|
+
import { isAgentRoutingDecision } from './agent_routing.mjs';
|
|
16
|
+
|
|
17
|
+
test('parsePlanOutput extracts numbered steps between markers', () => {
|
|
18
|
+
const out = parsePlanOutput('intro\nPLAN_BEGIN\n1. Read auth.ts\n2. Add login route\n3. Write test\nPLAN_END\nthanks');
|
|
19
|
+
assert.deepEqual(out.steps.map(s => s.text), ['Read auth.ts', 'Add login route', 'Write test']);
|
|
20
|
+
assert.equal(out.steps[0].status, 'pending');
|
|
21
|
+
assert.equal(out.steps[0].id, 1);
|
|
22
|
+
assert.deepEqual(out.decisions, []);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('parsePlanOutput returns empty steps when markers missing', () => {
|
|
26
|
+
assert.deepEqual(parsePlanOutput('no plan here'), { steps: [], decisions: [] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parsePlanOutput extracts decisions block', () => {
|
|
30
|
+
const text = [
|
|
31
|
+
'PLAN_BEGIN',
|
|
32
|
+
'1. Add OAuth',
|
|
33
|
+
'2. Wire callback',
|
|
34
|
+
'PLAN_END',
|
|
35
|
+
'DECISIONS_BEGIN',
|
|
36
|
+
'- oauth_provider | Which OAuth provider? | google | github | both',
|
|
37
|
+
'- session_store | Where to store sessions? | redis | memory',
|
|
38
|
+
'DECISIONS_END',
|
|
39
|
+
].join('\n');
|
|
40
|
+
const out = parsePlanOutput(text);
|
|
41
|
+
assert.equal(out.steps.length, 2);
|
|
42
|
+
assert.equal(out.decisions.length, 2);
|
|
43
|
+
assert.equal(out.decisions[0].slot, 'oauth_provider');
|
|
44
|
+
assert.deepEqual(out.decisions[0].options, ['google', 'github', 'both']);
|
|
45
|
+
assert.equal(out.decisions[1].slot, 'session_store');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('parseDecisions skips malformed lines', () => {
|
|
49
|
+
const text = 'DECISIONS_BEGIN\n- bad line\n- ok | question | a | b\nDECISIONS_END';
|
|
50
|
+
const out = parseDecisions(text);
|
|
51
|
+
assert.equal(out.length, 1);
|
|
52
|
+
assert.equal(out[0].slot, 'ok');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('parseDecisionAnswer matches option by name', () => {
|
|
56
|
+
const decision = { slot: 'x', question: 'Pick one', options: ['redis', 'memory'] };
|
|
57
|
+
assert.deepEqual(parseDecisionAnswer('use redis', decision), { type: 'option', choice: 'redis' });
|
|
58
|
+
assert.deepEqual(parseDecisionAnswer('go with memory', decision), { type: 'option', choice: 'memory' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('parseDecisionAnswer matches by number and ordinal', () => {
|
|
62
|
+
const decision = { slot: 'x', question: 'Pick one', options: ['alpha', 'beta', 'gamma'] };
|
|
63
|
+
assert.deepEqual(parseDecisionAnswer('option 2', decision), { type: 'option', choice: 'beta' });
|
|
64
|
+
assert.deepEqual(parseDecisionAnswer('the first one', decision), { type: 'option', choice: 'alpha' });
|
|
65
|
+
assert.deepEqual(parseDecisionAnswer('세번째', decision), { type: 'option', choice: 'gamma' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('parseDecisionAnswer detects defer phrases', () => {
|
|
69
|
+
const decision = { slot: 'x', question: 'q', options: ['a', 'b'] };
|
|
70
|
+
assert.equal(parseDecisionAnswer('either is fine', decision).type, 'auto');
|
|
71
|
+
assert.equal(parseDecisionAnswer('you decide', decision).type, 'auto');
|
|
72
|
+
assert.equal(parseDecisionAnswer('아무거나', decision).type, 'auto');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('parseDecisionAnswer returns unknown on no match', () => {
|
|
76
|
+
const decision = { slot: 'x', question: 'q', options: ['alpha', 'beta'] };
|
|
77
|
+
assert.equal(parseDecisionAnswer('hello world', decision).type, 'unknown');
|
|
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
|
+
|
|
89
|
+
test('renderDecisionPrompt formats question with numbered options', () => {
|
|
90
|
+
const decision = { slot: 'x', question: 'Pick auth?', options: ['google', 'github'] };
|
|
91
|
+
assert.match(renderDecisionPrompt(decision, 'en'), /Pick auth\?\s+Options:\s+1\) google\s+2\) github/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('renderResolvedDecisions handles auto choices', () => {
|
|
95
|
+
const resolved = { provider: 'github', store: null };
|
|
96
|
+
const out = renderResolvedDecisions(resolved, 'en');
|
|
97
|
+
assert.match(out, /provider=github/);
|
|
98
|
+
assert.match(out, /store=\(agent picks\)/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('parseVoiceCommand recognizes skip in en and ko', () => {
|
|
102
|
+
assert.deepEqual(parseVoiceCommand('skip step 3', 'en'), { type: 'skip', index: 3 });
|
|
103
|
+
assert.deepEqual(parseVoiceCommand('step 2 건너뛰어', 'ko'), { type: 'skip', index: 2 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('parseVoiceCommand recognizes insert in en', () => {
|
|
107
|
+
assert.deepEqual(parseVoiceCommand('add write a test after step 1', 'en'), { type: 'insert', after: 1, text: 'write a test' });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('parseVoiceCommand recognizes insert in ko', () => {
|
|
111
|
+
assert.deepEqual(parseVoiceCommand('step 2 다음에 테스트 작성 추가', 'ko'), { type: 'insert', after: 2, text: '테스트 작성' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('parseVoiceCommand recognizes approve in both languages', () => {
|
|
115
|
+
assert.deepEqual(parseVoiceCommand('approve', 'en'), { type: 'approve' });
|
|
116
|
+
assert.deepEqual(parseVoiceCommand('go ahead', 'en'), { type: 'approve' });
|
|
117
|
+
assert.deepEqual(parseVoiceCommand('실행', 'ko'), { type: 'approve' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('parseVoiceCommand recognizes cancel', () => {
|
|
121
|
+
assert.deepEqual(parseVoiceCommand('cancel', 'en'), { type: 'cancel' });
|
|
122
|
+
assert.deepEqual(parseVoiceCommand('취소', 'ko'), { type: 'cancel' });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('parseVoiceCommand falls through to unknown', () => {
|
|
126
|
+
assert.deepEqual(parseVoiceCommand('what is the meaning of life', 'en'), { type: 'unknown' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('applyCommand skip marks status', () => {
|
|
130
|
+
const steps = [{ id: 1, text: 'a', status: 'pending' }, { id: 2, text: 'b', status: 'pending' }];
|
|
131
|
+
const after = applyCommand(steps, { type: 'skip', index: 2 });
|
|
132
|
+
assert.equal(after[1].status, 'skipped');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('applyCommand insert places new step after target', () => {
|
|
136
|
+
const steps = [{ id: 1, text: 'a', status: 'pending' }, { id: 2, text: 'b', status: 'pending' }];
|
|
137
|
+
const after = applyCommand(steps, { type: 'insert', after: 1, text: 'extra' });
|
|
138
|
+
assert.equal(after.length, 3);
|
|
139
|
+
assert.equal(after[1].text, 'extra');
|
|
140
|
+
assert.equal(after[1].status, 'added');
|
|
141
|
+
assert.equal(after[2].text, 'b');
|
|
142
|
+
});
|
|
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
|
+
|
|
190
|
+
test('renderFinalPlan skips skipped steps and renumbers', () => {
|
|
191
|
+
const steps = [
|
|
192
|
+
{ id: 1, text: 'a', status: 'pending' },
|
|
193
|
+
{ id: 2, text: 'b', status: 'skipped' },
|
|
194
|
+
{ id: 3, text: 'c', status: 'pending' },
|
|
195
|
+
];
|
|
196
|
+
assert.equal(renderFinalPlan(steps), '1. a\n2. c');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('isPlanEntryUtterance detects entry phrases', () => {
|
|
200
|
+
assert.equal(isPlanEntryUtterance('plan it first', 'en'), true);
|
|
201
|
+
assert.equal(isPlanEntryUtterance('make a plan', 'en'), true);
|
|
202
|
+
assert.equal(isPlanEntryUtterance('먼저 계획 짜줘', 'ko'), true);
|
|
203
|
+
assert.equal(isPlanEntryUtterance('just do it', 'en'), false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('planModePreamble contains PLAN_BEGIN marker', () => {
|
|
207
|
+
assert.match(planModePreamble('en'), /PLAN_BEGIN/);
|
|
208
|
+
assert.match(planModePreamble('ko'), /PLAN_BEGIN/);
|
|
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
|
+
});
|