create-spatius-app 0.1.2 → 0.1.3-beta.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.
Files changed (52) hide show
  1. package/README.md +17 -6
  2. package/dist/cli.js +721 -143
  3. package/dist/templates.js +612 -66
  4. package/package.json +4 -4
  5. package/schemas/result-v3.schema.json +6 -2
  6. package/schemas/result-v4.schema.json +170 -0
  7. package/templates/cloudflare-livekit/agent/env.example +3 -0
  8. package/templates/cloudflare-livekit/agent/src/agent.py +5 -3
  9. package/templates/cloudflare-livekit/package-lock.json +70 -142
  10. package/templates/cloudflare-livekit/package.json +1 -1
  11. package/templates/cloudflare-livekit/pnpm-lock.yaml +96 -153
  12. package/templates/cloudflare-livekit/tests/dev-events.test.mjs +23 -0
  13. package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +7 -3
  14. package/templates/cloudflare-livekit/tsconfig.web.json +1 -0
  15. package/templates/cloudflare-livekit/tsconfig.worker.json +1 -0
  16. package/templates/cloudflare-livekit/web/api.ts +12 -0
  17. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +6 -90
  18. package/templates/cloudflare-livekit/web/components/transcript-view.tsx +90 -0
  19. package/templates/cloudflare-livekit/web/session-attempt.ts +23 -2
  20. package/templates/cloudflare-livekit/worker/capability.test.ts +31 -0
  21. package/templates/cloudflare-livekit/worker/capability.ts +72 -0
  22. package/templates/cloudflare-livekit/worker/index.test.ts +2 -2
  23. package/templates/cloudflare-livekit/worker/index.ts +15 -3
  24. package/templates/cloudflare-livekit/worker/session.ts +34 -0
  25. package/templates/layers/agora/AGENTS.md +8 -0
  26. package/templates/layers/agora/README.md +19 -0
  27. package/templates/layers/agora/env.example +9 -0
  28. package/templates/layers/agora/package-lock.json +6068 -0
  29. package/templates/layers/agora/package.json +54 -0
  30. package/templates/layers/agora/pnpm-lock.yaml +3816 -0
  31. package/templates/layers/agora/tsconfig.json +16 -0
  32. package/templates/layers/agora/vitest.config.ts +4 -0
  33. package/templates/layers/agora/web/App.tsx +174 -0
  34. package/templates/layers/agora/web/agora-session.test.ts +70 -0
  35. package/templates/layers/agora/web/agora-session.ts +267 -0
  36. package/templates/layers/agora/worker/agora.test.ts +60 -0
  37. package/templates/layers/agora/worker/agora.ts +165 -0
  38. package/templates/layers/agora/worker/index.ts +50 -0
  39. package/templates/layers/catalog/DEPLOYMENT.md +3 -0
  40. package/templates/layers/catalog/spatius.config.json +1 -0
  41. package/templates/layers/node/Dockerfile +12 -0
  42. package/templates/layers/node/dockerignore +10 -0
  43. package/templates/layers/node/package-lock.json +5569 -0
  44. package/templates/layers/node/pnpm-lock.yaml +3470 -0
  45. package/templates/layers/node/railway.json +8 -0
  46. package/templates/layers/node/scripts/dev-service.mjs +14 -0
  47. package/templates/layers/node/scripts/dev-web.mjs +22 -0
  48. package/templates/layers/node/server/index.ts +92 -0
  49. package/templates/layers/node/vite.config.ts +17 -0
  50. package/templates/layers/railway-agent/agent/railway.json +5 -0
  51. package/dist/cli.js.map +0 -1
  52. package/dist/templates.js.map +0 -1
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "types": ["node", "vite/client"],
13
+ "resolveJsonModule": true
14
+ },
15
+ "include": ["web", "worker", "server", "vite.config.ts", "vitest.config.ts"]
16
+ }
@@ -0,0 +1,4 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ test: { include: ['worker/**/*.test.ts', 'web/**/*.test.ts'] },
4
+ });
@@ -0,0 +1,174 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { AgoraSession } from './agora-session.js';
3
+ import {
4
+ AgentChatTranscript,
5
+ type TranscriptMessage,
6
+ } from './components/transcript-view.js';
7
+ import { AgentChatInput } from './components/agents-ui/agent-chat-input.js';
8
+ import { TranscriptPanel } from './components/transcript-panel.js';
9
+ import { Icon } from './components/icons.js';
10
+ export default function App() {
11
+ const stage = useRef<HTMLDivElement>(null);
12
+ const session = useRef<AgoraSession | undefined>(undefined);
13
+ const mounted = useRef(true);
14
+ const [phase, setPhase] = useState<
15
+ 'idle' | 'starting' | 'active' | 'stopping' | 'ended'
16
+ >('idle');
17
+ const [error, setError] = useState('');
18
+ const [messages, setMessages] = useState<TranscriptMessage[]>([]);
19
+ const [microphone, setMicrophone] = useState(false);
20
+ const [pending, setPending] = useState(false);
21
+ useEffect(() => {
22
+ mounted.current = true;
23
+ return () => {
24
+ mounted.current = false;
25
+ void session.current?.dispose();
26
+ };
27
+ }, []);
28
+ const stop = async () => {
29
+ const current = session.current;
30
+ if (!current) return;
31
+ session.current = undefined;
32
+ setPhase('stopping');
33
+ setMicrophone(false);
34
+ await current.dispose();
35
+ if (mounted.current) setPhase('ended');
36
+ };
37
+ const start = async () => {
38
+ if (!stage.current || session.current) return;
39
+ setError('');
40
+ setMessages([]);
41
+ setPhase('starting');
42
+ const current = new AgoraSession(
43
+ (items) => {
44
+ if (mounted.current && session.current === current) setMessages(items);
45
+ },
46
+ (message) => {
47
+ if (mounted.current) setError(message);
48
+ },
49
+ () => {
50
+ if (session.current === current) void stop();
51
+ },
52
+ );
53
+ session.current = current;
54
+ try {
55
+ await current.start(stage.current);
56
+ if (!mounted.current || session.current !== current) return;
57
+ setPhase('active');
58
+ try {
59
+ await current.setMicrophone(true);
60
+ if (session.current === current && mounted.current) setMicrophone(true);
61
+ } catch {
62
+ if (session.current === current)
63
+ setError(
64
+ 'Microphone unavailable. Allow access or continue by typing.',
65
+ );
66
+ }
67
+ } catch (failure) {
68
+ if (!mounted.current || session.current !== current) return;
69
+ setError(
70
+ failure instanceof Error ? failure.message : 'Could not connect.',
71
+ );
72
+ await stop();
73
+ }
74
+ };
75
+ return (
76
+ <main className="avatar-app" data-phase={phase}>
77
+ <div ref={stage} className="avatar-canvas" aria-label="Avatar stage" />
78
+ <header className="session-header">
79
+ <a className="brand-pill glass" href="/">
80
+ <Icon name="chat" />
81
+ Spatius starter
82
+ </a>
83
+ </header>
84
+ {phase !== 'active' ? (
85
+ <section className="welcome-state">
86
+ <h1>
87
+ {phase === 'starting'
88
+ ? 'Starting conversation'
89
+ : phase === 'stopping'
90
+ ? 'Ending conversation'
91
+ : phase === 'ended'
92
+ ? 'Conversation ended'
93
+ : 'Voice assistant'}
94
+ </h1>
95
+ <p className="welcome-description">
96
+ Talk to your avatar or send a message. Follow along in the
97
+ transcript.
98
+ </p>
99
+ <button
100
+ className="primary-button"
101
+ disabled={phase === 'stopping'}
102
+ onClick={() => void (phase === 'starting' ? stop() : start())}
103
+ >
104
+ {phase === 'starting'
105
+ ? 'Cancel'
106
+ : phase === 'ended'
107
+ ? 'Start a new conversation'
108
+ : 'Start conversation'}
109
+ </button>
110
+ </section>
111
+ ) : (
112
+ <div className="conversation-tools">
113
+ <div className="call-dock">
114
+ <button
115
+ className="primary-button"
116
+ aria-pressed={microphone}
117
+ disabled={pending}
118
+ onClick={() => {
119
+ const current = session.current;
120
+ setPending(true);
121
+ void current
122
+ ?.setMicrophone(!microphone)
123
+ .then(() => {
124
+ if (session.current === current) setMicrophone(!microphone);
125
+ })
126
+ .catch(() =>
127
+ setError(
128
+ 'Could not change the microphone. Try again or keep typing.',
129
+ ),
130
+ )
131
+ .finally(() => setPending(false));
132
+ }}
133
+ >
134
+ {microphone ? 'Mute microphone' : 'Enable microphone'}
135
+ </button>
136
+ <button className="primary-button" onClick={() => void stop()}>
137
+ End call
138
+ </button>
139
+ </div>
140
+ </div>
141
+ )}
142
+ {error && (
143
+ <p
144
+ className="control-error"
145
+ role="alert"
146
+ style={{
147
+ position: 'absolute',
148
+ bottom: '12rem',
149
+ left: '1.5rem',
150
+ maxWidth: '28rem',
151
+ }}
152
+ >
153
+ {error}
154
+ </p>
155
+ )}
156
+ <footer className="stage-footer">
157
+ Powered by Spatius <span>×</span> Agora
158
+ </footer>
159
+ <TranscriptPanel>
160
+ <AgentChatTranscript messages={messages} agentName="Your agent" />
161
+ <div className="panel-composer">
162
+ <AgentChatInput
163
+ disabled={phase !== 'active'}
164
+ onSend={(text) => {
165
+ if (!session.current)
166
+ return Promise.reject(new Error('Not connected'));
167
+ return session.current.send(text);
168
+ }}
169
+ />
170
+ </div>
171
+ </TranscriptPanel>
172
+ </main>
173
+ );
174
+ }
@@ -0,0 +1,70 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest';
3
+ import { AgoraSession } from './agora-session.js';
4
+ afterEach(() => vi.unstubAllGlobals());
5
+ it('stops a remotely created agent when cancellation precedes the bootstrap response', async () => {
6
+ let complete!: (response: Response) => void;
7
+ const fetcher = vi
8
+ .fn<typeof fetch>()
9
+ .mockImplementationOnce(
10
+ () =>
11
+ new Promise((resolve) => {
12
+ complete = resolve;
13
+ }),
14
+ )
15
+ .mockResolvedValue(new Response(null, { status: 204 }));
16
+ vi.stubGlobal('fetch', fetcher);
17
+ const session = new AgoraSession(vi.fn(), vi.fn());
18
+ const starting = session.start(document.createElement('div'));
19
+ const rejected = expect(starting).rejects.toMatchObject({
20
+ name: 'AbortError',
21
+ });
22
+ const stopping = session.dispose();
23
+ complete(
24
+ Response.json({
25
+ capability: 'signed-session',
26
+ appId: 'app',
27
+ channel: 'channel',
28
+ uid: 123,
29
+ agentUid: 456,
30
+ token: 'client-token',
31
+ spatiusAppId: 'spatius',
32
+ avatarId: 'avatar',
33
+ region: 'region',
34
+ }),
35
+ );
36
+ await rejected;
37
+ await stopping;
38
+ await session.dispose();
39
+ expect(fetcher).toHaveBeenCalledTimes(2);
40
+ expect(fetcher.mock.calls[1]).toEqual([
41
+ '/api/session/stop',
42
+ expect.objectContaining({
43
+ body: JSON.stringify({ capability: 'signed-session' }),
44
+ keepalive: true,
45
+ }),
46
+ ]);
47
+ });
48
+ it('rejects malformed bootstrap responses without initializing media', async () => {
49
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({})));
50
+ const session = new AgoraSession(vi.fn(), vi.fn());
51
+ await expect(session.start(document.createElement('div'))).rejects.toThrow(
52
+ 'Invalid session response',
53
+ );
54
+ await session.dispose();
55
+ });
56
+ it('keeps provider errors out of the browser error message', async () => {
57
+ vi.stubGlobal(
58
+ 'fetch',
59
+ vi
60
+ .fn()
61
+ .mockResolvedValue(
62
+ new Response('private provider diagnostic', { status: 502 }),
63
+ ),
64
+ );
65
+ const session = new AgoraSession(vi.fn(), vi.fn());
66
+ await expect(session.start(document.createElement('div'))).rejects.toThrow(
67
+ 'Could not start the agent.',
68
+ );
69
+ await expect(session.send('hello')).rejects.toThrow('Not connected');
70
+ });
@@ -0,0 +1,267 @@
1
+ import type { IAgoraRTCClient } from 'agora-rtc-sdk-ng';
2
+ import type { AvatarPlayer } from '@spatius/avatarkit-rtc';
3
+ import type { AvatarView } from '@spatius/avatarkit';
4
+ import type { AgoraVoiceAI } from 'agora-agent-client-toolkit';
5
+ import type { RTMClient } from 'agora-rtm-sdk';
6
+ import type { TranscriptMessage } from './components/transcript-view.js';
7
+ interface Credentials {
8
+ capability: string;
9
+ appId: string;
10
+ channel: string;
11
+ uid: number;
12
+ agentUid: number;
13
+ token: string;
14
+ spatiusAppId: string;
15
+ avatarId: string;
16
+ region: string;
17
+ }
18
+ let initialization: Promise<void> | undefined;
19
+ export class AgoraSession {
20
+ private credentials?: Credentials;
21
+ private player?: AvatarPlayer;
22
+ private view?: AvatarView;
23
+ private rtm?: RTMClient;
24
+ private toolkit?: AgoraVoiceAI;
25
+ private microphone?: MediaStream;
26
+ private cancelled = false;
27
+ private starting?: Promise<void>;
28
+ private microphoneBusy = false;
29
+ private readonly pagehide = () => {
30
+ if (this.credentials)
31
+ navigator.sendBeacon(
32
+ '/api/session/stop',
33
+ new Blob(
34
+ [JSON.stringify({ capability: this.credentials.capability })],
35
+ { type: 'application/json' },
36
+ ),
37
+ );
38
+ };
39
+ constructor(
40
+ private readonly onMessages: (messages: TranscriptMessage[]) => void,
41
+ private readonly onError: (message: string) => void,
42
+ private readonly onDisconnected: () => void = () => undefined,
43
+ ) {}
44
+ start(container: HTMLElement): Promise<void> {
45
+ this.starting ??= this.prepare(container).catch(async (error: unknown) => {
46
+ await this.cleanup();
47
+ throw error;
48
+ });
49
+ return this.starting;
50
+ }
51
+ private check() {
52
+ if (this.cancelled) throw new DOMException('Cancelled', 'AbortError');
53
+ }
54
+ private async prepare(container: HTMLElement) {
55
+ // Keep the bootstrap response observable after cancellation so a started remote agent can be stopped.
56
+ const response = await fetch('/api/session', {
57
+ method: 'POST',
58
+ cache: 'no-store',
59
+ signal: AbortSignal.timeout(20000),
60
+ });
61
+ if (!response.ok)
62
+ throw new Error(
63
+ 'Could not start the agent. Check the Agora pipeline and Spatius configuration.',
64
+ );
65
+ const raw: unknown = await response.json();
66
+ if (!raw || typeof raw !== 'object')
67
+ throw new Error('Invalid session response');
68
+ const value = raw as Credentials;
69
+ if (
70
+ ![
71
+ 'capability',
72
+ 'appId',
73
+ 'channel',
74
+ 'token',
75
+ 'spatiusAppId',
76
+ 'avatarId',
77
+ 'region',
78
+ ].every(
79
+ (key) => typeof (raw as Record<string, unknown>)[key] === 'string',
80
+ ) ||
81
+ !Number.isInteger(value.uid) ||
82
+ !Number.isInteger(value.agentUid)
83
+ )
84
+ throw new Error('Invalid session response');
85
+ this.credentials = value;
86
+ window.addEventListener('pagehide', this.pagehide);
87
+ this.check();
88
+ const [
89
+ { AvatarSDK, AvatarManager, AvatarView, DrivingServiceMode },
90
+ { AvatarPlayer, AgoraProvider },
91
+ { default: AgoraRTM },
92
+ toolkit,
93
+ ] = await Promise.all([
94
+ import('@spatius/avatarkit'),
95
+ import('@spatius/avatarkit-rtc'),
96
+ import('agora-rtm-sdk'),
97
+ import('agora-agent-client-toolkit'),
98
+ ]);
99
+ this.check();
100
+ initialization ??= AvatarSDK.initialize(value.spatiusAppId, {
101
+ drivingServiceMode: DrivingServiceMode.rtc,
102
+ region: value.region,
103
+ }).catch((error: unknown) => {
104
+ initialization = undefined;
105
+ throw error;
106
+ });
107
+ await initialization;
108
+ this.check();
109
+ const avatar =
110
+ AvatarManager.shared.retrieve(value.avatarId) ??
111
+ (await AvatarManager.shared.load(value.avatarId));
112
+ this.check();
113
+ if (!avatar) throw new Error('Avatar could not be loaded');
114
+ this.view = new AvatarView(avatar, container);
115
+ this.player = new AvatarPlayer(new AgoraProvider(), this.view, {
116
+ logLevel: 'error',
117
+ });
118
+ await this.player.connect({
119
+ appId: value.appId,
120
+ channel: value.channel,
121
+ token: value.token,
122
+ uid: value.uid,
123
+ });
124
+ this.check();
125
+ const client = this.player.getNativeClient() as IAgoraRTCClient;
126
+ client.on('connection-state-change', (state) => {
127
+ if (state === 'DISCONNECTED' && !this.cancelled) this.onDisconnected();
128
+ });
129
+ this.rtm = new AgoraRTM.RTM(value.appId, String(value.uid));
130
+ await this.rtm.login({ token: value.token });
131
+ this.check();
132
+ this.toolkit = await toolkit.AgoraVoiceAI.init({
133
+ rtcEngine: client,
134
+ rtmEngine: this.rtm,
135
+ enableLog: false,
136
+ });
137
+ this.toolkit.on(toolkit.AgoraVoiceAIEvents.TRANSCRIPT_UPDATED, (items) => {
138
+ if (this.cancelled) return;
139
+ this.onMessages(
140
+ items.map((item) => ({
141
+ id: `${item.uid}:${item.turn_id}:${item.stream_id}`,
142
+ timestamp: item._time || Date.now(),
143
+ message: item.text,
144
+ isUser: item.uid === String(value.uid),
145
+ })),
146
+ );
147
+ });
148
+ this.toolkit.on(toolkit.AgoraVoiceAIEvents.MESSAGE_ERROR, () =>
149
+ this.onError(
150
+ 'The agent could not process the message. Please try again.',
151
+ ),
152
+ );
153
+ this.toolkit.subscribeMessage(value.channel);
154
+ await this.rtm.subscribe(value.channel);
155
+ this.check();
156
+ await new Promise<void>((resolve, reject) => {
157
+ const ready = () =>
158
+ client.remoteUsers.some(
159
+ (user) => String(user.uid) === String(value.agentUid),
160
+ );
161
+ if (ready()) {
162
+ resolve();
163
+ return;
164
+ }
165
+ const timeout = window.setTimeout(() => {
166
+ client.off('user-joined', joined);
167
+ reject(new Error('The agent did not join in time.'));
168
+ }, 30000);
169
+ function joined() {
170
+ if (ready()) {
171
+ window.clearTimeout(timeout);
172
+ client.off('user-joined', joined);
173
+ resolve();
174
+ }
175
+ }
176
+ client.on('user-joined', joined);
177
+ });
178
+ this.check();
179
+ }
180
+ async setMicrophone(enabled: boolean) {
181
+ this.check();
182
+ if (!this.player || this.microphoneBusy) return;
183
+ this.microphoneBusy = true;
184
+ try {
185
+ if (!enabled) {
186
+ await this.player.unpublishAudio();
187
+ this.microphone?.getTracks().forEach((track) => track.stop());
188
+ this.microphone = undefined;
189
+ return;
190
+ }
191
+ const stream = await navigator.mediaDevices.getUserMedia({
192
+ audio: { echoCancellation: true, noiseSuppression: true },
193
+ });
194
+ if (this.cancelled) {
195
+ stream.getTracks().forEach((track) => track.stop());
196
+ this.check();
197
+ }
198
+ this.microphone = stream;
199
+ await this.player.publishAudio(stream.getAudioTracks()[0]);
200
+ if (this.cancelled) {
201
+ stream.getTracks().forEach((track) => track.stop());
202
+ await this.player.unpublishAudio();
203
+ }
204
+ } finally {
205
+ this.microphoneBusy = false;
206
+ }
207
+ }
208
+ async send(text: string) {
209
+ this.check();
210
+ if (!this.toolkit || !this.credentials) throw new Error('Not connected');
211
+ const { ChatMessagePriority, ChatMessageType } =
212
+ await import('agora-agent-client-toolkit');
213
+ await this.toolkit.sendText(String(this.credentials.agentUid), {
214
+ messageType: ChatMessageType.TEXT,
215
+ text,
216
+ priority: ChatMessagePriority.INTERRUPTED,
217
+ responseInterruptable: true,
218
+ });
219
+ }
220
+ async dispose() {
221
+ this.cancelled = true;
222
+ await this.starting?.catch(() => undefined);
223
+ await this.cleanup();
224
+ }
225
+ private async cleanup() {
226
+ window.removeEventListener('pagehide', this.pagehide);
227
+ this.microphone?.getTracks().forEach((track) => track.stop());
228
+ this.microphone = undefined;
229
+ const toolkit = this.toolkit,
230
+ view = this.view,
231
+ player = this.player,
232
+ rtm = this.rtm,
233
+ credentials = this.credentials;
234
+ this.toolkit = undefined;
235
+ this.view = undefined;
236
+ this.player = undefined;
237
+ this.rtm = undefined;
238
+ this.credentials = undefined;
239
+ const results = await Promise.allSettled([
240
+ Promise.resolve().then(() => toolkit?.destroy()),
241
+ Promise.resolve().then(() => player?.disconnect()),
242
+ Promise.resolve().then(() => rtm?.logout()),
243
+ credentials
244
+ ? fetch('/api/session/stop', {
245
+ method: 'POST',
246
+ headers: { 'content-type': 'application/json' },
247
+ body: JSON.stringify({ capability: credentials.capability }),
248
+ keepalive: true,
249
+ signal: AbortSignal.timeout(15000),
250
+ }).then((response) => {
251
+ if (!response.ok) throw new Error('Remote cleanup failed');
252
+ })
253
+ : undefined,
254
+ ]);
255
+ try {
256
+ view?.dispose();
257
+ } catch {
258
+ this.onError(
259
+ 'The avatar renderer could not be fully released. Reload before reconnecting.',
260
+ );
261
+ }
262
+ if (results.some((result) => result.status === 'rejected'))
263
+ this.onError(
264
+ 'Session ended locally. Remote cleanup could not be confirmed; the agent idle timeout is the fallback.',
265
+ );
266
+ }
267
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ createAgoraSession,
4
+ stopAgoraSession,
5
+ type AgoraEnvironment,
6
+ } from './agora.js';
7
+ const env: AgoraEnvironment = {
8
+ AGORA_APP_ID: 'a'.repeat(32),
9
+ AGORA_APP_CERTIFICATE: 'b'.repeat(32),
10
+ AGORA_PIPELINE_ID: 'test-pipeline',
11
+ SPATIUS_API_KEY: 'private-key',
12
+ SPATIUS_APP_ID: 'test-app',
13
+ SPATIUS_AVATAR_ID: 'test-avatar',
14
+ };
15
+ describe('hosted Agora sessions', () => {
16
+ it('keeps secrets server-side and authorizes idempotent cleanup', async () => {
17
+ const fetcher = vi
18
+ .fn<typeof fetch>()
19
+ .mockResolvedValueOnce(Response.json({ agent_id: 'hosted-agent' }))
20
+ .mockResolvedValueOnce(new Response(null, { status: 404 }));
21
+ const session = await createAgoraSession(env, fetcher);
22
+ expect(session.uid).not.toBe(session.agentUid);
23
+ expect(JSON.stringify(session)).not.toContain(env.AGORA_APP_CERTIFICATE);
24
+ expect(JSON.stringify(session)).not.toContain(env.SPATIUS_API_KEY);
25
+ const body = JSON.parse(fetcher.mock.calls[0][1]!.body as string) as {
26
+ pipeline_id: string;
27
+ properties: {
28
+ agent_rtc_uid: string;
29
+ remote_rtc_uids: string[];
30
+ avatar: { params: { agora_uid: string; sample_rate: number } };
31
+ };
32
+ };
33
+ expect(body.pipeline_id).toBe('test-pipeline');
34
+ expect(body.properties.remote_rtc_uids).toEqual([String(session.uid)]);
35
+ expect(body.properties.agent_rtc_uid).toBe(String(session.agentUid));
36
+ expect(body.properties.avatar.params.agora_uid).not.toBe(
37
+ String(session.uid),
38
+ );
39
+ expect(body.properties.avatar.params.sample_rate).toBe(24000);
40
+ await stopAgoraSession(env, session.capability, fetcher);
41
+ expect(fetcher.mock.calls[1][0]).toContain('/agents/hosted-agent/leave');
42
+ await expect(
43
+ stopAgoraSession(env, session.capability + 'bad', fetcher),
44
+ ).rejects.toThrow();
45
+ expect(fetcher).toHaveBeenCalledTimes(2);
46
+ });
47
+ it('validates configuration before starting a billable agent and redacts upstream errors', async () => {
48
+ const fetcher = vi.fn<typeof fetch>();
49
+ await expect(
50
+ createAgoraSession({ ...env, AGORA_AVATAR_SAMPLE_RATE: '123' }, fetcher),
51
+ ).rejects.toThrow('sample rate');
52
+ expect(fetcher).not.toHaveBeenCalled();
53
+ fetcher.mockResolvedValue(
54
+ Response.json({ error: env.SPATIUS_API_KEY }, { status: 400 }),
55
+ );
56
+ await expect(createAgoraSession(env, fetcher)).rejects.toThrow(
57
+ 'Agora request failed (400)',
58
+ );
59
+ });
60
+ });