@@ -61,6 +61,7 @@ import { RecorderIO } from './recorder_io/index.js';
6161import { RoomIO , type RoomInputOptions , type RoomOutputOptions } from './room_io/index.js' ;
6262import type { UnknownUserData } from './run_context.js' ;
6363import type { SpeechHandle } from './speech_handle.js' ;
64+ import { RunResult } from './testing/run_result.js' ;
6465
6566export interface VoiceOptions {
6667 allowInterruptions : boolean ;
@@ -167,6 +168,9 @@ export class AgentSession<
167168 /** @internal - Timestamp when the session started (milliseconds) */
168169 _startedAt ?: number ;
169170
171+ /** @internal - Current run state for testing */
172+ _globalRunState ?: RunResult ;
173+
170174 constructor ( opts : AgentSessionOptions < UserData > ) {
171175 super ( ) ;
172176
@@ -272,7 +276,7 @@ export class AgentSession<
272276 span,
273277 } : {
274278 agent : Agent ;
275- room : Room ;
279+ room ? : Room ;
276280 inputOptions ?: Partial < RoomInputOptions > ;
277281 outputOptions ?: Partial < RoomOutputOptions > ;
278282 span : Span ;
@@ -283,41 +287,45 @@ export class AgentSession<
283287 this . _updateAgentState ( 'initializing' ) ;
284288
285289 const tasks : Promise < void > [ ] = [ ] ;
286- // Check for existing input/output configuration and warn if needed
287- if ( this . input . audio && inputOptions ?. audioEnabled !== false ) {
288- this . logger . warn ( 'RoomIO audio input is enabled but input.audio is already set, ignoring..' ) ;
289- }
290290
291- if ( this . output . audio && outputOptions ?. audioEnabled !== false ) {
292- this . logger . warn (
293- 'RoomIO audio output is enabled but output.audio is already set, ignoring..' ,
294- ) ;
295- }
291+ if ( room && ! this . roomIO ) {
292+ // Check for existing input/output configuration and warn if needed
293+ if ( this . input . audio && inputOptions ?. audioEnabled !== false ) {
294+ this . logger . warn (
295+ 'RoomIO audio input is enabled but input.audio is already set, ignoring..' ,
296+ ) ;
297+ }
296298
297- if ( this . output . transcription && outputOptions ?. transcriptionEnabled !== false ) {
298- this . logger . warn (
299- 'RoomIO transcription output is enabled but output.transcription is already set, ignoring..' ,
300- ) ;
301- }
299+ if ( this . output . audio && outputOptions ?. audioEnabled !== false ) {
300+ this . logger . warn (
301+ 'RoomIO audio output is enabled but output.audio is already set, ignoring..' ,
302+ ) ;
303+ }
302304
303- this . roomIO = new RoomIO ( {
304- agentSession : this ,
305- room,
306- inputOptions,
307- outputOptions,
308- } ) ;
309- this . roomIO . start ( ) ;
305+ if ( this . output . transcription && outputOptions ?. transcriptionEnabled !== false ) {
306+ this . logger . warn (
307+ 'RoomIO transcription output is enabled but output.transcription is already set, ignoring..' ,
308+ ) ;
309+ }
310+
311+ this . roomIO = new RoomIO ( {
312+ agentSession : this ,
313+ room,
314+ inputOptions,
315+ outputOptions,
316+ } ) ;
317+ this . roomIO . start ( ) ;
318+ }
310319
311320 let ctx : JobContext | undefined = undefined ;
312321 try {
313322 ctx = getJobContext ( ) ;
314- } catch ( error ) {
323+ } catch {
315324 // JobContext is not available in evals
316- this . logger . warn ( 'JobContext is not available' ) ;
317325 }
318326
319327 if ( ctx ) {
320- if ( ctx . room === room && ! room . isConnected ) {
328+ if ( room && ctx . room === room && ! room . isConnected ) {
321329 this . logger . debug ( 'Auto-connecting to room via job context' ) ;
322330 tasks . push ( ctx . connect ( ) ) ;
323331 }
@@ -370,7 +378,7 @@ export class AgentSession<
370378 record,
371379 } : {
372380 agent : Agent ;
373- room : Room ;
381+ room ? : Room ;
374382 inputOptions ?: Partial < RoomInputOptions > ;
375383 outputOptions ?: Partial < RoomOutputOptions > ;
376384 record ?: boolean ;
@@ -497,13 +505,50 @@ export class AgentSession<
497505
498506 // attach to the session span if called outside of the AgentSession
499507 const activeSpan = trace . getActiveSpan ( ) ;
508+ let handle : SpeechHandle ;
500509 if ( ! activeSpan && this . rootSpanContext ) {
501- return otelContext . with ( this . rootSpanContext , ( ) =>
510+ handle = otelContext . with ( this . rootSpanContext , ( ) =>
502511 doGenerateReply ( this . activity ! , this . nextActivity ) ,
503512 ) ;
513+ } else {
514+ handle = doGenerateReply ( this . activity ! , this . nextActivity ) ;
504515 }
505516
506- return doGenerateReply ( this . activity ! , this . nextActivity ) ;
517+ if ( this . _globalRunState ) {
518+ this . _globalRunState . _watchHandle ( handle ) ;
519+ }
520+
521+ return handle ;
522+ }
523+
524+ /**
525+ * Run a test with user input and return a result for assertions.
526+ *
527+ * This method is primarily used for testing agent behavior without
528+ * requiring a real room connection.
529+ *
530+ * @example
531+ * ```typescript
532+ * const result = await session.run({ userInput: 'Hello' });
533+ * result.expect.nextEvent().isMessage({ role: 'assistant' });
534+ * result.expect.noMoreEvents();
535+ * ```
536+ *
537+ * @param options - Run options including user input
538+ * @returns A RunResult that resolves when the agent finishes responding
539+ *
540+ * TODO: Add outputType parameter for typed outputs (parity with Python)
541+ */
542+ run ( options : { userInput : string } ) : RunResult {
543+ if ( this . _globalRunState && ! this . _globalRunState . done ( ) ) {
544+ throw new Error ( 'nested runs are not supported' ) ;
545+ }
546+
547+ const runState = new RunResult ( { userInput : options . userInput } ) ;
548+ this . _globalRunState = runState ;
549+ this . generateReply ( { userInput : options . userInput } ) ;
550+
551+ return runState ;
507552 }
508553
509554 private async updateActivity ( agent : Agent ) : Promise < void > {
0 commit comments