voicecc 1.3.0 → 1.3.1

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Claude Voice</title>
7
- <script type="module" crossorigin src="/assets/index-CVP_3PYo.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-ZP9Js-Pi.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DeN1WDo9.css">
9
9
  </head>
10
10
  <body>
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for chat proxy route -- verifies resumeSessionId forwarding.
3
+ *
4
+ * Run: npx tsx --test dashboard/routes/chat.test.ts
5
+ */
6
+
7
+ import { test, describe, afterEach } from "node:test";
8
+ import { strict as assert } from "node:assert";
9
+
10
+ import { chatRoutes } from "./chat.js";
11
+
12
+ // ============================================================================
13
+ // HELPERS
14
+ // ============================================================================
15
+
16
+ const originalFetch = globalThis.fetch;
17
+
18
+ /** Restore global fetch after each test */
19
+ afterEach(() => {
20
+ globalThis.fetch = originalFetch;
21
+ });
22
+
23
+ /**
24
+ * Build a Hono Request to POST /send with the given JSON body.
25
+ * Uses x-forwarded-for: 127.0.0.1 to bypass device token validation.
26
+ *
27
+ * @param body - JSON body to send
28
+ * @returns Response from the Hono app
29
+ */
30
+ async function postSend(body: Record<string, unknown>): Promise<Response> {
31
+ const app = chatRoutes();
32
+ const req = new Request("http://localhost/send", {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ "x-forwarded-for": "127.0.0.1",
37
+ },
38
+ body: JSON.stringify(body),
39
+ });
40
+ return app.request(req);
41
+ }
42
+
43
+ // ============================================================================
44
+ // TESTS
45
+ // ============================================================================
46
+
47
+ describe("POST /send - resumeSessionId forwarding", () => {
48
+ test("forwards resumeSessionId as resume_session_id to Python", async () => {
49
+ let capturedBody: Record<string, unknown> | undefined;
50
+
51
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
52
+ const bodyText = typeof init?.body === "string" ? init.body : "";
53
+ capturedBody = JSON.parse(bodyText);
54
+ // Return a minimal SSE response so the handler succeeds
55
+ return new Response("data: done\n\n", {
56
+ status: 200,
57
+ headers: { "Content-Type": "text/event-stream" },
58
+ });
59
+ };
60
+
61
+ await postSend({
62
+ token: "test-token",
63
+ agentId: "agent-1",
64
+ text: "hello",
65
+ resumeSessionId: "ses_abc123",
66
+ });
67
+
68
+ assert.ok(capturedBody, "fetch should have been called");
69
+ assert.equal(capturedBody!.resume_session_id, "ses_abc123");
70
+ assert.equal(capturedBody!.session_key, "test-token");
71
+ assert.equal(capturedBody!.text, "hello");
72
+ });
73
+
74
+ test("omits resume_session_id when resumeSessionId is absent", async () => {
75
+ let capturedBody: Record<string, unknown> | undefined;
76
+
77
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
78
+ const bodyText = typeof init?.body === "string" ? init.body : "";
79
+ capturedBody = JSON.parse(bodyText);
80
+ return new Response("data: done\n\n", {
81
+ status: 200,
82
+ headers: { "Content-Type": "text/event-stream" },
83
+ });
84
+ };
85
+
86
+ await postSend({
87
+ token: "test-token",
88
+ text: "hello",
89
+ });
90
+
91
+ assert.ok(capturedBody, "fetch should have been called");
92
+ assert.equal("resume_session_id" in capturedBody!, false, "resume_session_id should not be present");
93
+ });
94
+ });
@@ -30,6 +30,7 @@ interface ChatSendBody {
30
30
  token: string;
31
31
  agentId?: string;
32
32
  text: string;
33
+ resumeSessionId?: string;
33
34
  }
34
35
 
35
36
  /** Request body for POST /stop and /close */
@@ -66,24 +67,30 @@ export function chatRoutes(): Hono {
66
67
  return c.json({ error: "Missing 'token' field" }, 400);
67
68
  }
68
69
 
69
- // Validate device token (localhost bypass via x-forwarded-for)
70
+ // Validate device token (localhost bypass via x-forwarded-for, resume bypass for dashboard)
70
71
  const forwarded = c.req.header("x-forwarded-for") ?? "";
71
72
  const isLocalhost = forwarded === "127.0.0.1";
73
+ const isResumeFlow = !!body.resumeSessionId;
72
74
 
73
- if (!isLocalhost && !isValidDeviceToken(body.token)) {
75
+ if (!isLocalhost && !isResumeFlow && !isValidDeviceToken(body.token)) {
74
76
  return c.json({ error: "Invalid device token" }, 401);
75
77
  }
76
78
 
77
79
  // Proxy to Python server
78
80
  try {
81
+ const pythonBody: Record<string, string | undefined> = {
82
+ session_key: body.token,
83
+ agent_id: body.agentId,
84
+ text: body.text.trim(),
85
+ };
86
+ if (body.resumeSessionId) {
87
+ pythonBody.resume_session_id = body.resumeSessionId;
88
+ }
89
+
79
90
  const response = await fetch(`${VOICE_SERVER_URL}/chat/send`, {
80
91
  method: "POST",
81
92
  headers: { "Content-Type": "application/json" },
82
- body: JSON.stringify({
83
- session_key: body.token,
84
- agent_id: body.agentId,
85
- text: body.text.trim(),
86
- }),
93
+ body: JSON.stringify(pythonBody),
87
94
  });
88
95
 
89
96
  if (!response.ok && !response.headers.get("content-type")?.includes("text/event-stream")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voicecc",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Voice Agent Platform running on Claude Code -- create and deploy conversational voice agents with ElevenLabs STT/TTS and VAD",
5
5
  "repository": {
6
6
  "type": "git",
@@ -110,7 +110,7 @@ def load_config() -> VoiceServerConfig:
110
110
  """
111
111
  voicecc_dir = os.environ.get("VOICECC_DIR", DEFAULT_VOICECC_DIR)
112
112
  env_path = os.path.join(voicecc_dir, ".env")
113
- load_dotenv(env_path)
113
+ load_dotenv(env_path, override=True)
114
114
 
115
115
  api_key = os.environ.get("ELEVENLABS_API_KEY", "")
116
116
  if not api_key:
@@ -0,0 +1,96 @@
1
+ """Test that load_config() always reads fresh values from the .env file.
2
+
3
+ Catches regressions where env vars get cached at startup instead of being
4
+ re-read on each call. If a new setting is added to VoiceServerConfig and
5
+ backed by an env var, add it to ENV_BACKED_FIELDS below so it's covered.
6
+
7
+ Run: cd voice-server && python -m pytest config_reload_test.py -v
8
+ """
9
+
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from config import load_config, VoiceServerConfig
17
+
18
+
19
+ # Map of VoiceServerConfig field -> (env var name, first value, second value)
20
+ # Add new env-backed settings here to keep them covered.
21
+ ENV_BACKED_FIELDS: dict[str, tuple[str, str, str]] = {
22
+ "twilio_account_sid": ("TWILIO_ACCOUNT_SID", "AC_old_sid", "AC_new_sid"),
23
+ "twilio_auth_token": ("TWILIO_AUTH_TOKEN", "old_token", "new_token"),
24
+ "twilio_phone_number": ("TWILIO_PHONE_NUMBER", "+10000000000", "+19999999999"),
25
+ "user_phone_number": ("USER_PHONE_NUMBER", "+11111111111", "+12222222222"),
26
+ "elevenlabs_api_key": ("ELEVENLABS_API_KEY", "ek_old_key", "ek_new_key"),
27
+ "elevenlabs_voice_id": ("ELEVENLABS_VOICE_ID", "voice_old", "voice_new"),
28
+ "elevenlabs_tts_model": ("ELEVENLABS_MODEL_ID", "model_old", "model_new"),
29
+ "elevenlabs_stt_model": ("ELEVENLABS_STT_MODEL_ID", "stt_old", "stt_new"),
30
+ }
31
+
32
+ # Env vars we write into every .env so load_config() doesn't fail validation
33
+ REQUIRED_DEFAULTS = {
34
+ "ELEVENLABS_API_KEY": "ek_placeholder",
35
+ }
36
+
37
+
38
+ def _write_env(path: Path, overrides: dict[str, str]) -> None:
39
+ """Write a .env file with required defaults + overrides."""
40
+ merged = {**REQUIRED_DEFAULTS, **overrides}
41
+ path.write_text("\n".join(f"{k}={v}" for k, v in merged.items()) + "\n")
42
+
43
+
44
+ @pytest.fixture(autouse=True)
45
+ def _isolated_env(tmp_path, monkeypatch):
46
+ """Point VOICECC_DIR at a temp dir and clean up env vars after each test."""
47
+ monkeypatch.setenv("VOICECC_DIR", str(tmp_path))
48
+
49
+ # Clear all env vars we test so there's no bleed between tests
50
+ for _, (env_var, _, _) in ENV_BACKED_FIELDS.items():
51
+ monkeypatch.delenv(env_var, raising=False)
52
+ for k in REQUIRED_DEFAULTS:
53
+ monkeypatch.delenv(k, raising=False)
54
+
55
+
56
+ def test_load_config_picks_up_changed_values(tmp_path):
57
+ """load_config() must return updated values after the .env file changes."""
58
+ env_file = tmp_path / ".env"
59
+
60
+ # Write initial values
61
+ initial = {env_var: v1 for env_var, v1, _ in ENV_BACKED_FIELDS.values()}
62
+ _write_env(env_file, initial)
63
+
64
+ config1 = load_config()
65
+ for field_name, (_, v1, _) in ENV_BACKED_FIELDS.items():
66
+ assert getattr(config1, field_name) == v1, (
67
+ f"{field_name} was not {v1!r} on first read"
68
+ )
69
+
70
+ # Overwrite with new values
71
+ updated = {env_var: v2 for env_var, _, v2 in ENV_BACKED_FIELDS.values()}
72
+ _write_env(env_file, updated)
73
+
74
+ config2 = load_config()
75
+ for field_name, (_, _, v2) in ENV_BACKED_FIELDS.items():
76
+ assert getattr(config2, field_name) == v2, (
77
+ f"{field_name} was not updated to {v2!r} on second read — "
78
+ "load_config() is returning stale values"
79
+ )
80
+
81
+
82
+ def test_new_env_var_appears_after_file_update(tmp_path):
83
+ """If TWILIO_PHONE_NUMBER is absent initially and added later, it's picked up."""
84
+ env_file = tmp_path / ".env"
85
+
86
+ # Start without TWILIO_PHONE_NUMBER
87
+ _write_env(env_file, {})
88
+ config1 = load_config()
89
+ assert config1.twilio_phone_number == ""
90
+
91
+ # Add it
92
+ _write_env(env_file, {"TWILIO_PHONE_NUMBER": "+15550001234"})
93
+ config2 = load_config()
94
+ assert config2.twilio_phone_number == "+15550001234", (
95
+ "TWILIO_PHONE_NUMBER not picked up after being added to .env"
96
+ )
@@ -37,6 +37,7 @@ from config import (
37
37
  build_system_prompt,
38
38
  list_agents,
39
39
  load_agent,
40
+ load_config,
40
41
  DEFAULT_AGENTS_DIR,
41
42
  PROJECT_ROOT,
42
43
  )
@@ -108,9 +109,6 @@ _in_flight_checks: set[str] = set()
108
109
  # Pending calls keyed by token, waiting for Twilio WebSocket connection
109
110
  _pending_calls: dict[str, PendingCall] = {}
110
111
 
111
- # Reference to config (set on start)
112
- _config: VoiceServerConfig | None = None
113
-
114
112
  # Getter for tunnel URL (set on start, imported from server module)
115
113
  _get_tunnel_url = None
116
114
 
@@ -125,15 +123,14 @@ def start_heartbeat(config: VoiceServerConfig, get_tunnel_url_fn) -> None:
125
123
  Runs _check_all_agents every 60 seconds via an asyncio task.
126
124
 
127
125
  Args:
128
- config: Voice server configuration with Twilio credentials
126
+ config: Voice server configuration (unused, kept for API compat)
129
127
  get_tunnel_url_fn: Callable that returns the current tunnel URL
130
128
  """
131
- global _interval_task, _config, _get_tunnel_url
129
+ global _interval_task, _get_tunnel_url
132
130
 
133
131
  if _interval_task is not None:
134
132
  return
135
133
 
136
- _config = config
137
134
  _get_tunnel_url = get_tunnel_url_fn
138
135
 
139
136
  _interval_task = asyncio.create_task(_interval_loop())
@@ -217,10 +214,8 @@ async def _interval_loop() -> None:
217
214
 
218
215
  async def _check_all_agents() -> None:
219
216
  """Check all enabled agents and spawn heartbeat sessions for those that are due."""
220
- if not _config:
221
- return
222
-
223
- agents = list_agents(_config.agents_dir)
217
+ config = load_config()
218
+ agents = list_agents(config.agents_dir)
224
219
  if not agents:
225
220
  return
226
221
 
@@ -396,17 +391,16 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
396
391
  Returns:
397
392
  The Twilio call SID
398
393
  """
399
- if not _config:
400
- raise RuntimeError("Heartbeat not started -- no config")
394
+ config = load_config()
401
395
 
402
396
  tunnel_url = _get_tunnel_url() if _get_tunnel_url else None
403
397
  if not tunnel_url:
404
398
  raise RuntimeError("Tunnel is not running. Cannot place outbound call.")
405
399
 
406
- if not _config.twilio_account_sid or not _config.twilio_auth_token:
400
+ if not config.twilio_account_sid or not config.twilio_auth_token:
407
401
  raise RuntimeError("TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set")
408
402
 
409
- if not _config.user_phone_number:
403
+ if not config.user_phone_number:
410
404
  raise RuntimeError("USER_PHONE_NUMBER must be set in Settings > General")
411
405
 
412
406
  token = str(uuid4())
@@ -427,11 +421,11 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
427
421
  # Get the configured Twilio phone number
428
422
  from twilio.rest import Client as TwilioClient
429
423
 
430
- from_number: str = _config.twilio_phone_number
424
+ from_number: str = config.twilio_phone_number
431
425
  if not from_number:
432
426
  raise RuntimeError("No TWILIO_PHONE_NUMBER configured")
433
427
 
434
- twilio_client = TwilioClient(_config.twilio_account_sid, _config.twilio_auth_token)
428
+ twilio_client = TwilioClient(config.twilio_account_sid, config.twilio_auth_token)
435
429
 
436
430
  # Build TwiML with WebSocket stream URL
437
431
  tunnel_host = tunnel_url.replace("https://", "").replace("http://", "")
@@ -442,13 +436,13 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
442
436
  )
443
437
 
444
438
  call = twilio_client.calls.create(
445
- to=_config.user_phone_number,
439
+ to=config.user_phone_number,
446
440
  from_=from_number,
447
441
  twiml=twiml,
448
442
  )
449
443
 
450
444
  logger.info(
451
- f"[heartbeat] outbound call placed to {_config.user_phone_number} "
445
+ f"[heartbeat] outbound call placed to {config.user_phone_number} "
452
446
  f"(callSid={call.sid})"
453
447
  )
454
448
  return call.sid or ""