Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dull-items-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/agents": patch
---

Create Awaitable Proxy
169 changes: 168 additions & 1 deletion agents/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import { AudioFrame } from '@livekit/rtc-node';
import { ReadableStream } from 'node:stream/web';
import { describe, expect, it } from 'vitest';
import { initializeLogger } from '../src/log.js';
import { Event, Task, TaskResult, delay, isPending, resampleStream } from '../src/utils.js';
import {
Event,
Task,
TaskResult,
delay,
isPending,
makeAwaitable,
resampleStream,
} from '../src/utils.js';

describe('utils', () => {
// initialize logger
Expand Down Expand Up @@ -648,4 +656,163 @@ describe('utils', () => {
expect(outputFrames).toEqual([]);
});
});

describe('makeAwaitable', () => {
class BaseTask {
value: string;
constructor(value: string) {
this.value = value;
}
run(): Promise<string> {
return Promise.resolve(`result:${this.value}`);
}
}

const AwaitableTask = makeAwaitable(BaseTask, (instance) => instance.run());

it('should resolve with handler result when awaited', async () => {
const result = await new AwaitableTask('hello');
expect(result).toBe('result:hello');
});

it('should preserve instance properties and methods', () => {
const task = new AwaitableTask('test');
expect(task.value).toBe('test');
expect(typeof task.run).toBe('function');
});

it('should support instanceof for the base class', () => {
const task = new AwaitableTask('test');
expect(task).toBeInstanceOf(BaseTask);
});

it('should work with extends (subclass)', async () => {
class DerivedTask extends AwaitableTask {
constructor(value: string) {
super(value.toUpperCase());
}
}

const result = await new DerivedTask('hello');
expect(result).toBe('result:HELLO');

const task = new DerivedTask('test');
expect(task).toBeInstanceOf(DerivedTask);
expect(task).toBeInstanceOf(BaseTask);
expect(task.value).toBe('TEST');
});

it('should call handler only once even if .then is accessed multiple times', async () => {
let callCount = 0;

class CountedTask {
run(): Promise<number> {
callCount++;
return Promise.resolve(callCount);
}
}
const AwaitableCounted = makeAwaitable(CountedTask, (t) => t.run());

const task = new AwaitableCounted();
const p1 = task.then((v) => v);
const p2 = task.then((v) => v);
const [r1, r2] = await Promise.all([p1, p2]);

expect(r1).toBe(1);
expect(r2).toBe(1);
expect(callCount).toBe(1);
});

it('should propagate handler rejections', async () => {
class FailingTask {
run(): Promise<never> {
return Promise.reject(new Error('task failed'));
}
}
const AwaitableFailing = makeAwaitable(FailingTask, (t) => t.run());

await expect(new AwaitableFailing()).rejects.toThrow('task failed');
});

it('should not trigger handler until awaited', () => {
let triggered = false;
class LazyTask {
run(): Promise<void> {
triggered = true;
return Promise.resolve();
}
}
const AwaitableLazy = makeAwaitable(LazyTask, (t) => t.run());

new AwaitableLazy();
expect(triggered).toBe(false);
});

it('should have separate promises per instance', async () => {
const t1 = new AwaitableTask('a');
const t2 = new AwaitableTask('b');

const [r1, r2] = await Promise.all([t1, t2]);
expect(r1).toBe('result:a');
expect(r2).toBe('result:b');
});

it('should call methods and return correct results', async () => {
const task = new AwaitableTask('world');
const result = await task.run();
expect(result).toBe('result:world');
});

it('should support property mutation', () => {
const task = new AwaitableTask('initial');
expect(task.value).toBe('initial');
task.value = 'changed';
expect(task.value).toBe('changed');
});

it('should preserve correct this inside methods', () => {
class Greeter {
name: string;
constructor(name: string) {
this.name = name;
}
greet(): string {
return `hello ${this.name}`;
}
}
const AwaitableGreeter = makeAwaitable(Greeter, async (g) => g.greet());

const g = new AwaitableGreeter('world');
expect(g.greet()).toBe('hello world');
g.name = 'proxy';
expect(g.greet()).toBe('hello proxy');
});

it('should expose static members of the original class', () => {
class WithStatic {
static defaultName = 'agent';
static create(): WithStatic {
return new WithStatic();
}
}
const AwaitableWithStatic = makeAwaitable(WithStatic, async () => 'done');

expect(AwaitableWithStatic.defaultName).toBe('agent');
expect(typeof AwaitableWithStatic.create).toBe('function');
expect(AwaitableWithStatic.create()).toBeInstanceOf(WithStatic);
});

it('should call overridden methods in subclass', async () => {
class DerivedTask extends AwaitableTask {
override run(): Promise<string> {
return Promise.resolve(`overridden:${this.value}`);
}
}

const task = new DerivedTask('sub');
expect(await task.run()).toBe('overridden:sub');
// await the instance itself — handler calls instance.run(), which is the override
expect(await new DerivedTask('sub')).toBe('overridden:sub');
});
});
});
56 changes: 56 additions & 0 deletions agents/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,3 +898,59 @@ export const isCloud = (url: URL) => {
const hostname = url.hostname;
return hostname.endsWith('.livekit.cloud') || hostname.endsWith('.livekit.run');
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AwaitableConstructor<C extends new (...args: any[]) => any, R> = {
[K in keyof C]: C[K];
} & {
new (...args: ConstructorParameters<C>): InstanceType<C> & PromiseLike<R>;
};

/**
* Wraps a class constructor so that instances are both fully functional
* (all original properties/methods) and awaitable via a user-provided handler.
*
* This mirrors Python's `__await__` protocol. In JS, `await` works on any
* "thenable" (object with a `.then()` method), so the returned constructor
* produces instances with a transparent `.then` that delegates to the handler.
*
* The returned class is safe to `extends` — subclass prototype chains are
* preserved via `Reflect.construct` with `newTarget` forwarding.
*
* @example
* ```ts
* class BaseTask {
* run(): Promise<string> { return Promise.resolve('done'); }
* }
* const Task = makeAwaitable(BaseTask, (instance) => instance.run());
*
* const result = await new Task(); // 'done'
*
* class MyTask extends Task { ... }
* const r = await new MyTask(); // also works
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function makeAwaitable<C extends new (...args: any[]) => any, R>(
Cls: C,
handler: (instance: InstanceType<C>) => Promise<R>,
): AwaitableConstructor<C, R> {
return new Proxy(Cls, {
construct(target, args, newTarget) {
const instance = Reflect.construct(target, args, newTarget);
let promise: Promise<R> | undefined;

return new Proxy(instance, {
get(obj, prop, receiver) {
if (prop === 'then') {
if (!promise) {
promise = handler(obj);
}
return promise.then.bind(promise);
}
return Reflect.get(obj, prop, receiver);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve target receiver when exposing instance members

The get trap returns non-then members via Reflect.get(obj, prop, receiver), so method/getter calls execute with this bound to the proxy instead of the real instance. For classes that use ECMAScript private fields (#field), this triggers runtime brand-check failures (Cannot read private member ...) as soon as a proxied method/getter is invoked, which breaks the “fully functional” guarantee of makeAwaitable and can crash any awaitable wrapper around such classes.

Useful? React with 👍 / 👎.

},
});
},
}) as AwaitableConstructor<C, R>;
}