create-spatius-app 0.1.0-beta.0

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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/cli.js +9046 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/templates.js +2856 -0
  6. package/dist/templates.js.map +1 -0
  7. package/package.json +79 -0
  8. package/schemas/result-v1.schema.json +129 -0
  9. package/schemas/result-v2.schema.json +146 -0
  10. package/templates/cloudflare-livekit/.editorconfig +15 -0
  11. package/templates/cloudflare-livekit/.prettierignore +6 -0
  12. package/templates/cloudflare-livekit/.prettierrc.json +4 -0
  13. package/templates/cloudflare-livekit/AGENTS.md +41 -0
  14. package/templates/cloudflare-livekit/README.md +37 -0
  15. package/templates/cloudflare-livekit/agent/Dockerfile.pip +31 -0
  16. package/templates/cloudflare-livekit/agent/Dockerfile.uv +29 -0
  17. package/templates/cloudflare-livekit/agent/README.md +84 -0
  18. package/templates/cloudflare-livekit/agent/dockerignore +11 -0
  19. package/templates/cloudflare-livekit/agent/env.example +8 -0
  20. package/templates/cloudflare-livekit/agent/pyproject.toml +21 -0
  21. package/templates/cloudflare-livekit/agent/python-version +1 -0
  22. package/templates/cloudflare-livekit/agent/src/__init__.py +1 -0
  23. package/templates/cloudflare-livekit/agent/src/agent.py +97 -0
  24. package/templates/cloudflare-livekit/agent/src/dispatch_metadata.py +64 -0
  25. package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +211 -0
  26. package/templates/cloudflare-livekit/agent/tests/test_dispatch_metadata.py +97 -0
  27. package/templates/cloudflare-livekit/agent/uv.lock +2272 -0
  28. package/templates/cloudflare-livekit/components.json +23 -0
  29. package/templates/cloudflare-livekit/contracts/agent-dispatch-metadata.schema.json +40 -0
  30. package/templates/cloudflare-livekit/dev.vars.example +12 -0
  31. package/templates/cloudflare-livekit/eslint.config.mjs +41 -0
  32. package/templates/cloudflare-livekit/gitignore +30 -0
  33. package/templates/cloudflare-livekit/index.html +17 -0
  34. package/templates/cloudflare-livekit/npmrc +4 -0
  35. package/templates/cloudflare-livekit/package-lock.json +6514 -0
  36. package/templates/cloudflare-livekit/package.json +66 -0
  37. package/templates/cloudflare-livekit/playwright.config.ts +29 -0
  38. package/templates/cloudflare-livekit/pnpm-lock.yaml +4012 -0
  39. package/templates/cloudflare-livekit/pnpm-workspace.yaml +8 -0
  40. package/templates/cloudflare-livekit/scripts/agent-runtime.mjs +129 -0
  41. package/templates/cloudflare-livekit/scripts/command.mjs +52 -0
  42. package/templates/cloudflare-livekit/scripts/dev-agent.mjs +77 -0
  43. package/templates/cloudflare-livekit/scripts/dev-web.mjs +18 -0
  44. package/templates/cloudflare-livekit/scripts/managed-process.mjs +144 -0
  45. package/templates/cloudflare-livekit/scripts/process-tree.mjs +111 -0
  46. package/templates/cloudflare-livekit/scripts/run-agent-python.mjs +25 -0
  47. package/templates/cloudflare-livekit/tests/dev-agent.test.mjs +327 -0
  48. package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +235 -0
  49. package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +203 -0
  50. package/templates/cloudflare-livekit/tests/e2e/conversation.spec.ts +376 -0
  51. package/templates/cloudflare-livekit/tests/e2e/fake-avatar.ts +24 -0
  52. package/templates/cloudflare-livekit/tests/e2e/fake-components.tsx +173 -0
  53. package/templates/cloudflare-livekit/tests/e2e/fake-livekit.ts +123 -0
  54. package/templates/cloudflare-livekit/tests/e2e/harness.html +12 -0
  55. package/templates/cloudflare-livekit/tests/e2e/harness.tsx +1 -0
  56. package/templates/cloudflare-livekit/tests/e2e/vite.config.ts +48 -0
  57. package/templates/cloudflare-livekit/tests/web/api.test.ts +64 -0
  58. package/templates/cloudflare-livekit/tests/web/app.test.tsx +125 -0
  59. package/templates/cloudflare-livekit/tests/web/audio-visualizer.test.tsx +53 -0
  60. package/templates/cloudflare-livekit/tests/web/avatar-session.test.ts +119 -0
  61. package/templates/cloudflare-livekit/tests/web/components.test.tsx +152 -0
  62. package/templates/cloudflare-livekit/tests/web/conversation.test.tsx +76 -0
  63. package/templates/cloudflare-livekit/tests/web/session-attempt.test.ts +178 -0
  64. package/templates/cloudflare-livekit/tsconfig.json +9 -0
  65. package/templates/cloudflare-livekit/tsconfig.node.json +23 -0
  66. package/templates/cloudflare-livekit/tsconfig.web.json +26 -0
  67. package/templates/cloudflare-livekit/tsconfig.worker.json +22 -0
  68. package/templates/cloudflare-livekit/vite.config.ts +49 -0
  69. package/templates/cloudflare-livekit/vitest.config.ts +11 -0
  70. package/templates/cloudflare-livekit/web/App.tsx +176 -0
  71. package/templates/cloudflare-livekit/web/api.ts +69 -0
  72. package/templates/cloudflare-livekit/web/assets/FONT-PROVENANCE.md +8 -0
  73. package/templates/cloudflare-livekit/web/assets/OFL.txt +92 -0
  74. package/templates/cloudflare-livekit/web/assets/inter-latin.woff2 +0 -0
  75. package/templates/cloudflare-livekit/web/avatar-session.ts +67 -0
  76. package/templates/cloudflare-livekit/web/components/agents-ui/LICENSE +201 -0
  77. package/templates/cloudflare-livekit/web/components/agents-ui/PROVENANCE.md +32 -0
  78. package/templates/cloudflare-livekit/web/components/agents-ui/agent-audio-visualizer-bar.tsx +43 -0
  79. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-input.tsx +78 -0
  80. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +145 -0
  81. package/templates/cloudflare-livekit/web/components/agents-ui/agent-disconnect-button.tsx +27 -0
  82. package/templates/cloudflare-livekit/web/components/agents-ui/agent-session-provider.tsx +29 -0
  83. package/templates/cloudflare-livekit/web/components/agents-ui/agent-track-toggle.tsx +51 -0
  84. package/templates/cloudflare-livekit/web/components/agents-ui/start-audio-button.tsx +13 -0
  85. package/templates/cloudflare-livekit/web/components/conversation.tsx +352 -0
  86. package/templates/cloudflare-livekit/web/components/icons.tsx +39 -0
  87. package/templates/cloudflare-livekit/web/components/transcript-panel.tsx +12 -0
  88. package/templates/cloudflare-livekit/web/main.tsx +17 -0
  89. package/templates/cloudflare-livekit/web/session-attempt.ts +110 -0
  90. package/templates/cloudflare-livekit/web/styles.css +571 -0
  91. package/templates/cloudflare-livekit/worker/dispatch-metadata.ts +22 -0
  92. package/templates/cloudflare-livekit/worker/index.test.ts +152 -0
  93. package/templates/cloudflare-livekit/worker/index.ts +61 -0
  94. package/templates/cloudflare-livekit/worker/session.ts +103 -0
  95. package/templates/cloudflare-livekit/worker-configuration.d.ts +17184 -0
  96. package/templates/cloudflare-livekit/wrangler.jsonc +27 -0
@@ -0,0 +1,64 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+
4
+ AGENT_DISPATCH_METADATA_VERSION = 1
5
+ DEFAULT_VOICE_ID = "9626c31c-bec5-4cca-baa8-f8ba9e84c8bc" # Jacqueline
6
+
7
+
8
+ class DispatchMetadataError(ValueError):
9
+ """Raised when LiveKit dispatch metadata does not match the contract."""
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AgentDispatchMetadata:
14
+ avatar_id: str
15
+ voice_id: str = DEFAULT_VOICE_ID
16
+
17
+
18
+ def parse_agent_dispatch_metadata(raw_metadata: str) -> AgentDispatchMetadata:
19
+ if not raw_metadata:
20
+ raise DispatchMetadataError("Agent dispatch metadata is required.")
21
+
22
+ try:
23
+ payload = json.loads(raw_metadata)
24
+ except json.JSONDecodeError as error:
25
+ raise DispatchMetadataError(
26
+ "Agent dispatch metadata must be valid JSON."
27
+ ) from error
28
+
29
+ if not isinstance(payload, dict):
30
+ raise DispatchMetadataError("Agent dispatch metadata must be a JSON object.")
31
+
32
+ version = payload.get("version")
33
+ if type(version) is not int or version != AGENT_DISPATCH_METADATA_VERSION:
34
+ raise DispatchMetadataError(
35
+ f"Unsupported agent dispatch metadata version: {version!r}."
36
+ )
37
+
38
+ avatar = payload.get("avatar")
39
+ if not isinstance(avatar, dict):
40
+ raise DispatchMetadataError(
41
+ "Agent dispatch metadata must include an avatar object."
42
+ )
43
+
44
+ avatar_id = avatar.get("id")
45
+ if not isinstance(avatar_id, str) or not avatar_id.strip():
46
+ raise DispatchMetadataError(
47
+ "Agent dispatch metadata must include a non-empty avatar.id."
48
+ )
49
+
50
+ # Optional in v1 so older Workers keep their original standard female voice.
51
+ voice_id = DEFAULT_VOICE_ID
52
+ if "voice" in payload:
53
+ voice = payload["voice"]
54
+ if not isinstance(voice, dict):
55
+ raise DispatchMetadataError(
56
+ "Agent dispatch metadata voice must be an object."
57
+ )
58
+ voice_id = voice.get("id")
59
+ if not isinstance(voice_id, str) or not voice_id.strip():
60
+ raise DispatchMetadataError(
61
+ "Agent dispatch metadata needs a non-empty voice.id."
62
+ )
63
+
64
+ return AgentDispatchMetadata(avatar_id=avatar_id.strip(), voice_id=voice_id.strip())
@@ -0,0 +1,211 @@
1
+ import asyncio
2
+ import json
3
+ import unittest
4
+ from types import SimpleNamespace
5
+ from unittest.mock import AsyncMock, patch
6
+
7
+ from src.dispatch_metadata import DispatchMetadataError
8
+
9
+ # Import the real entrypoint without loading local development credentials.
10
+ with patch("dotenv.load_dotenv"):
11
+ from src import agent as agent_module
12
+
13
+
14
+ class AgentStartupTests(unittest.IsolatedAsyncioTestCase):
15
+ def setUp(self) -> None:
16
+ self.events: list[str] = []
17
+ self.connected = False
18
+ self.room = SimpleNamespace(name="test-room")
19
+ self.browser_joined = asyncio.Event()
20
+ self.browser_joined.set()
21
+ self.waiting_for_browser = asyncio.Event()
22
+ self.ctx = SimpleNamespace(
23
+ room=self.room,
24
+ job=SimpleNamespace(
25
+ metadata=json.dumps({"version": 1, "avatar": {"id": "avatar-test"}})
26
+ ),
27
+ connect=AsyncMock(side_effect=self.connect),
28
+ wait_for_participant=AsyncMock(side_effect=self.wait_for_browser),
29
+ )
30
+ self.session = SimpleNamespace(
31
+ start=AsyncMock(side_effect=self.start_session),
32
+ say=AsyncMock(side_effect=self.say_greeting),
33
+ )
34
+ self.avatar = SimpleNamespace(start=AsyncMock(side_effect=self.start_avatar))
35
+ self.session_factory = self.enterContext(
36
+ patch.object(agent_module, "AgentSession", return_value=self.session)
37
+ )
38
+ self.avatar_factory = self.enterContext(
39
+ patch.object(
40
+ agent_module.spatius, "AvatarSession", return_value=self.avatar
41
+ )
42
+ )
43
+ self.assistant_factory = self.enterContext(
44
+ patch.object(agent_module, "Assistant")
45
+ )
46
+ self.inference = self.enterContext(patch.object(agent_module, "inference"))
47
+ self.bvc = self.enterContext(
48
+ patch.object(agent_module.noise_cancellation, "BVC")
49
+ )
50
+ self.enterContext(patch.object(agent_module, "TurnHandlingOptions"))
51
+
52
+ async def connect(self) -> None:
53
+ # Ensure startup waits for connection completion, not just invocation.
54
+ await asyncio.sleep(0)
55
+ self.connected = True
56
+ self.events.append("connect")
57
+
58
+ async def start_avatar(self, session, *, room) -> None:
59
+ self.assertTrue(self.connected, "Spatius started before the room connected")
60
+ self.assertIs(session, self.session)
61
+ self.assertIs(room, self.room)
62
+ await asyncio.sleep(0)
63
+ self.events.append("avatar")
64
+
65
+ async def start_session(self, *, agent, room, room_options) -> None:
66
+ self.assertEqual(self.events, ["connect", "avatar"])
67
+ self.assertIs(agent, self.assistant_factory.return_value)
68
+ self.assertIs(room, self.room)
69
+ self.assertIs(
70
+ room_options.audio_input.noise_cancellation, self.bvc.return_value
71
+ )
72
+ self.events.append("session")
73
+
74
+ async def wait_for_browser(self, *, kind):
75
+ self.assertEqual(
76
+ kind, agent_module.rtc.ParticipantKind.PARTICIPANT_KIND_STANDARD
77
+ )
78
+ self.assertEqual(self.events, ["connect", "avatar", "session"])
79
+ self.waiting_for_browser.set()
80
+ await self.browser_joined.wait()
81
+ self.events.append("browser")
82
+
83
+ async def say_greeting(self, text, *, allow_interruptions) -> None:
84
+ self.assertEqual(self.events, ["connect", "avatar", "session", "browser"])
85
+ self.assertTrue(allow_interruptions)
86
+ self.events.append("greeting")
87
+
88
+ async def test_connects_before_avatar_and_voice_session_startup(self) -> None:
89
+ await agent_module.spatius_agent(self.ctx)
90
+
91
+ self.assertEqual(
92
+ self.events, ["connect", "avatar", "session", "browser", "greeting"]
93
+ )
94
+ self.session.say.assert_awaited_once_with(
95
+ "Hi! I'm here to help. What would you like to talk about?",
96
+ allow_interruptions=True,
97
+ )
98
+ self.ctx.connect.assert_awaited_once_with()
99
+ self.avatar_factory.assert_called_once_with(avatar_id="avatar-test")
100
+ self.avatar.start.assert_awaited_once_with(self.session, room=self.room)
101
+ self.session.start.assert_awaited_once_with(
102
+ agent=self.assistant_factory.return_value,
103
+ room=self.room,
104
+ room_options=self.session.start.call_args.kwargs["room_options"],
105
+ )
106
+ self.bvc.assert_called_once_with()
107
+ self.inference.TTS.assert_called_once_with(
108
+ model="cartesia/sonic-3.6",
109
+ voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
110
+ language="en",
111
+ )
112
+ self.assertTrue(self.session_factory.call_args.kwargs["expressive"])
113
+ self.assertTrue(
114
+ self.session_factory.call_args.kwargs["use_tts_aligned_transcript"]
115
+ )
116
+
117
+ async def test_connection_failure_prevents_avatar_and_voice_startup(self) -> None:
118
+ self.ctx.connect.side_effect = ConnectionError("room connection failed")
119
+
120
+ with self.assertRaisesRegex(ConnectionError, "room connection failed"):
121
+ await agent_module.spatius_agent(self.ctx)
122
+
123
+ self.avatar_factory.assert_not_called()
124
+ self.avatar.start.assert_not_awaited()
125
+ self.session.start.assert_not_awaited()
126
+
127
+ async def test_connection_cancellation_prevents_avatar_and_voice_startup(
128
+ self,
129
+ ) -> None:
130
+ self.ctx.connect.side_effect = asyncio.CancelledError()
131
+
132
+ with self.assertRaises(asyncio.CancelledError):
133
+ await agent_module.spatius_agent(self.ctx)
134
+
135
+ self.avatar_factory.assert_not_called()
136
+ self.avatar.start.assert_not_awaited()
137
+ self.session.start.assert_not_awaited()
138
+
139
+ async def test_invalid_metadata_is_rejected_before_connecting(self) -> None:
140
+ self.ctx.job.metadata = "invalid-json"
141
+
142
+ with self.assertRaises(DispatchMetadataError):
143
+ await agent_module.spatius_agent(self.ctx)
144
+
145
+ self.ctx.connect.assert_not_awaited()
146
+ self.session_factory.assert_not_called()
147
+ self.avatar_factory.assert_not_called()
148
+
149
+ async def test_avatar_failure_prevents_voice_session_startup(self) -> None:
150
+ self.avatar.start.side_effect = RuntimeError("avatar startup failed")
151
+
152
+ with self.assertRaisesRegex(RuntimeError, "avatar startup failed"):
153
+ await agent_module.spatius_agent(self.ctx)
154
+
155
+ self.ctx.connect.assert_awaited_once_with()
156
+ self.session.start.assert_not_awaited()
157
+ self.session.say.assert_not_awaited()
158
+
159
+ async def test_session_failure_does_not_greet(self) -> None:
160
+ self.session.start.side_effect = RuntimeError("session startup failed")
161
+ with self.assertRaisesRegex(RuntimeError, "session startup failed"):
162
+ await agent_module.spatius_agent(self.ctx)
163
+ self.session.say.assert_not_awaited()
164
+
165
+ async def test_slow_browser_delays_greeting_until_it_joins(self) -> None:
166
+ self.browser_joined.clear()
167
+ task = asyncio.create_task(agent_module.spatius_agent(self.ctx))
168
+ try:
169
+ await asyncio.wait_for(self.waiting_for_browser.wait(), timeout=1)
170
+ self.session.start.assert_awaited_once()
171
+ self.avatar.start.assert_awaited_once()
172
+ self.session.say.assert_not_awaited()
173
+ self.browser_joined.set()
174
+ await asyncio.wait_for(task, timeout=1)
175
+ self.session.say.assert_awaited_once()
176
+ finally:
177
+ task.cancel()
178
+ await asyncio.gather(task, return_exceptions=True)
179
+
180
+ async def test_cancellation_while_waiting_never_greets(self) -> None:
181
+ self.browser_joined.clear()
182
+ task = asyncio.create_task(agent_module.spatius_agent(self.ctx))
183
+ try:
184
+ await asyncio.wait_for(self.waiting_for_browser.wait(), timeout=1)
185
+ task.cancel()
186
+ with self.assertRaises(asyncio.CancelledError):
187
+ await task
188
+ self.session.say.assert_not_awaited()
189
+ finally:
190
+ task.cancel()
191
+ await asyncio.gather(task, return_exceptions=True)
192
+
193
+ async def test_uses_the_business_layer_selected_masculine_voice(self) -> None:
194
+ voice_id = "a167e0f3-df7e-4d52-a9c3-f949145efdab"
195
+ self.ctx.job.metadata = json.dumps(
196
+ {
197
+ "version": 1,
198
+ "avatar": {"id": "avatar-test"},
199
+ "voice": {"id": voice_id},
200
+ }
201
+ )
202
+ await agent_module.spatius_agent(self.ctx)
203
+ self.inference.TTS.assert_called_once_with(
204
+ model="cartesia/sonic-3.6",
205
+ voice=voice_id,
206
+ language="en",
207
+ )
208
+
209
+
210
+ if __name__ == "__main__":
211
+ unittest.main()
@@ -0,0 +1,97 @@
1
+ import json
2
+ import unittest
3
+
4
+ from src.dispatch_metadata import (
5
+ AGENT_DISPATCH_METADATA_VERSION,
6
+ DEFAULT_VOICE_ID,
7
+ DispatchMetadataError,
8
+ parse_agent_dispatch_metadata,
9
+ )
10
+
11
+
12
+ class AgentDispatchMetadataTests(unittest.TestCase):
13
+ def test_parses_avatar_id(self) -> None:
14
+ metadata = parse_agent_dispatch_metadata(
15
+ json.dumps(
16
+ {
17
+ "version": AGENT_DISPATCH_METADATA_VERSION,
18
+ "avatar": {"id": "avatar-123"},
19
+ }
20
+ )
21
+ )
22
+
23
+ self.assertEqual(metadata.avatar_id, "avatar-123")
24
+ self.assertEqual(metadata.voice_id, DEFAULT_VOICE_ID)
25
+
26
+ def test_parses_selected_voice(self) -> None:
27
+ for voice_id in [DEFAULT_VOICE_ID, "a167e0f3-df7e-4d52-a9c3-f949145efdab"]:
28
+ with self.subTest(voice_id=voice_id):
29
+ metadata = parse_agent_dispatch_metadata(
30
+ json.dumps(
31
+ {
32
+ "version": 1,
33
+ "avatar": {"id": "avatar-123"},
34
+ "voice": {"id": voice_id},
35
+ }
36
+ )
37
+ )
38
+ self.assertEqual(metadata.voice_id, voice_id)
39
+
40
+ def test_rejects_invalid_voice_without_silently_using_a_different_voice(
41
+ self,
42
+ ) -> None:
43
+ for voice in [None, "masculine", {}, {"id": " "}, {"id": 123}]:
44
+ with self.subTest(voice=voice), self.assertRaises(DispatchMetadataError):
45
+ parse_agent_dispatch_metadata(
46
+ json.dumps(
47
+ {
48
+ "version": 1,
49
+ "avatar": {"id": "avatar-123"},
50
+ "voice": voice,
51
+ }
52
+ )
53
+ )
54
+
55
+ def test_allows_future_optional_fields(self) -> None:
56
+ metadata = parse_agent_dispatch_metadata(
57
+ json.dumps(
58
+ {
59
+ "version": AGENT_DISPATCH_METADATA_VERSION,
60
+ "avatar": {"id": "avatar-123", "style": "friendly"},
61
+ "session": {"locale": "en-US"},
62
+ }
63
+ )
64
+ )
65
+
66
+ self.assertEqual(metadata.avatar_id, "avatar-123")
67
+
68
+ def test_rejects_invalid_json(self) -> None:
69
+ with self.assertRaisesRegex(DispatchMetadataError, "valid JSON"):
70
+ parse_agent_dispatch_metadata("not-json")
71
+
72
+ def test_rejects_unsupported_version(self) -> None:
73
+ with self.assertRaisesRegex(DispatchMetadataError, "Unsupported"):
74
+ parse_agent_dispatch_metadata(
75
+ json.dumps({"version": 2, "avatar": {"id": "avatar-123"}})
76
+ )
77
+
78
+ def test_rejects_missing_avatar_id(self) -> None:
79
+ with self.assertRaisesRegex(DispatchMetadataError, "avatar.id"):
80
+ parse_agent_dispatch_metadata(
81
+ json.dumps({"version": AGENT_DISPATCH_METADATA_VERSION, "avatar": {}})
82
+ )
83
+
84
+ def test_rejects_blank_avatar_id(self) -> None:
85
+ with self.assertRaisesRegex(DispatchMetadataError, "avatar.id"):
86
+ parse_agent_dispatch_metadata(
87
+ json.dumps(
88
+ {
89
+ "version": AGENT_DISPATCH_METADATA_VERSION,
90
+ "avatar": {"id": " "},
91
+ }
92
+ )
93
+ )
94
+
95
+
96
+ if __name__ == "__main__":
97
+ unittest.main()