Skip to content

Commit e8df3d6

Browse files
committed
feat: introduce comprehensive test suite and harness for agent, covering various behaviors and adding simulated clock support
1 parent 5508cc9 commit e8df3d6

18 files changed

Lines changed: 1786 additions & 0 deletions

packages/talkio/src/agent/create-agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ export function createAgent<
438438
});
439439
const actor = createActor(agentMachine, {
440440
input: { config: normalizedConfig, audioStreamController },
441+
clock: config.simulatedClock,
441442
});
442443

443444
// Track event subscription for cleanup

packages/talkio/src/types/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* @module types/config
77
*/
88

9+
import type { SimulatedClock } from "xstate";
10+
911
import type { AudioConfig, AudioFormat, NormalizedAudioConfig } from "../audio/types";
1012
import type {
1113
ExtractSTTInputFormat,
@@ -366,6 +368,14 @@ export interface AgentConfig<
366368
* @default false
367369
*/
368370
debug?: boolean;
371+
372+
/**
373+
* **Optional.** Custom clock for the XState actor system.
374+
*
375+
* Pass a `SimulatedClock` in tests to control time deterministically for
376+
* delayed events and timeouts managed by the actor system.
377+
*/
378+
simulatedClock?: SimulatedClock;
369379
}
370380

371381
/**
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { float32ToLinear16 } from "../../src/audio/conversions";
4+
import { createAgentHarness } from "../helpers/harness";
5+
6+
const audioConfig = {
7+
input: { encoding: "linear16", sampleRate: 16000, channels: 1 },
8+
output: { encoding: "linear16", sampleRate: 24000, channels: 1 },
9+
} as const;
10+
11+
describe("audio input normalization", () => {
12+
it("normalizes Float32Array input to linear16", async () => {
13+
const harness = createAgentHarness({ audio: audioConfig });
14+
const { agent, events, stt } = harness;
15+
16+
agent.start();
17+
await events.waitForEvent("agent:started");
18+
19+
const float32 = new Float32Array([0, 0.5, -0.5, 1, -1]);
20+
const expected = new Int16Array(float32ToLinear16(float32));
21+
22+
agent.sendAudio(float32);
23+
24+
expect(stt.receivedAudio.length).toBe(1);
25+
const received = new Int16Array(stt.receivedAudio[0]);
26+
expect(received).toEqual(expected);
27+
28+
agent.stop();
29+
await events.waitForEvent("agent:stopped");
30+
});
31+
32+
it("preserves Int16Array input for linear16", async () => {
33+
const harness = createAgentHarness({ audio: audioConfig });
34+
const { agent, events, stt } = harness;
35+
36+
agent.start();
37+
await events.waitForEvent("agent:started");
38+
39+
const int16 = new Int16Array([1, -2, 3, -4]);
40+
agent.sendAudio(int16);
41+
42+
expect(stt.receivedAudio.length).toBe(1);
43+
expect(new Int16Array(stt.receivedAudio[0])).toEqual(int16);
44+
45+
agent.stop();
46+
await events.waitForEvent("agent:stopped");
47+
});
48+
49+
it("normalizes Uint8Array input to configured encoding", async () => {
50+
const harness = createAgentHarness({ audio: audioConfig });
51+
const { agent, events, stt } = harness;
52+
53+
agent.start();
54+
await events.waitForEvent("agent:started");
55+
56+
const bytes = new Uint8Array([1, 2, 3, 4]);
57+
agent.sendAudio(bytes);
58+
59+
expect(stt.receivedAudio.length).toBe(1);
60+
expect(new Uint8Array(stt.receivedAudio[0])).toEqual(bytes);
61+
62+
agent.stop();
63+
await events.waitForEvent("agent:stopped");
64+
});
65+
66+
it("normalizes Buffer input to configured encoding", async () => {
67+
const harness = createAgentHarness({ audio: audioConfig });
68+
const { agent, events, stt } = harness;
69+
70+
agent.start();
71+
await events.waitForEvent("agent:started");
72+
73+
const buffer = Buffer.from([5, 6, 7]);
74+
agent.sendAudio(buffer);
75+
76+
expect(stt.receivedAudio.length).toBe(1);
77+
expect(new Uint8Array(stt.receivedAudio[0])).toEqual(new Uint8Array(buffer));
78+
79+
agent.stop();
80+
await events.waitForEvent("agent:stopped");
81+
});
82+
83+
it("rejects Blob input with a guidance error", async () => {
84+
const harness = createAgentHarness({ audio: audioConfig });
85+
const { agent, events } = harness;
86+
87+
agent.start();
88+
await events.waitForEvent("agent:started");
89+
90+
const blob = new Blob([new Uint8Array([1, 2, 3])]);
91+
expect(() => agent.sendAudio(blob)).toThrow(
92+
"Blob input requires async conversion. Use `await blob.arrayBuffer()` before calling sendAudio()",
93+
);
94+
95+
agent.stop();
96+
await events.waitForEvent("agent:stopped");
97+
});
98+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { createAgentHarness } from "../helpers/harness";
4+
5+
function makeAudioChunk(value: number): ArrayBuffer {
6+
return new Uint8Array([value, value]).buffer;
7+
}
8+
9+
describe("backpressure", () => {
10+
it("handles slow audio consumers without crashing", async () => {
11+
const harness = createAgentHarness();
12+
const { agent, events, stt, llm, tts } = harness;
13+
14+
agent.start();
15+
await events.waitForEvent("agent:started");
16+
17+
stt.emitTranscript("hello", true);
18+
await events.waitForEvent("ai-turn:started");
19+
20+
llm.emitSentence("Hello.", 0);
21+
22+
tts.emitAudio(makeAudioChunk(1));
23+
tts.emitAudio(makeAudioChunk(2));
24+
tts.emitAudio(makeAudioChunk(3));
25+
26+
llm.complete("Hello.");
27+
tts.complete();
28+
29+
await events.waitForEvent("ai-turn:ended");
30+
31+
const audioEvents = events.byType("ai-turn:audio");
32+
expect(audioEvents.length).toBe(3);
33+
34+
agent.stop();
35+
await events.waitForEvent("agent:stopped");
36+
});
37+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { createAgentHarness, drainMicrotasks } from "../helpers/harness";
4+
5+
function makeAudioChunk(value: number, size = 4): ArrayBuffer {
6+
return new Uint8Array(Array.from({ length: size }, () => value)).buffer;
7+
}
8+
9+
describe("basic conversation", () => {
10+
it("runs a golden flow and emits the expected sequence", async () => {
11+
const harness = createAgentHarness();
12+
const { agent, events, stt, llm, tts } = harness;
13+
const reader = agent.audioStream.getReader();
14+
15+
agent.start();
16+
await events.waitForEvent("agent:started");
17+
18+
stt.emitTranscript("hello", false);
19+
stt.emitTranscript("hello", true);
20+
21+
await events.waitForEvent("ai-turn:started");
22+
23+
llm.emitToken("Hi");
24+
llm.emitSentence("Hi there.", 0);
25+
26+
const audioChunk = makeAudioChunk(7);
27+
const audioRead = reader.read();
28+
tts.emitAudio(audioChunk);
29+
const readResult = await audioRead;
30+
31+
llm.complete("Hi there.");
32+
tts.complete();
33+
34+
const aiEnded = await events.waitForEvent("ai-turn:ended");
35+
expect(aiEnded.wasSpoken).toBe(true);
36+
37+
const state = agent.getSnapshot();
38+
expect(state.messages).toEqual([
39+
{ role: "user", content: "hello" },
40+
{ role: "assistant", content: "Hi there." },
41+
]);
42+
43+
expect(readResult.done).toBe(false);
44+
expect(new Uint8Array(readResult.value ?? new ArrayBuffer(0))).toEqual(
45+
new Uint8Array(audioChunk),
46+
);
47+
48+
agent.stop();
49+
await events.waitForEvent("agent:stopped");
50+
51+
events.assertSequence([
52+
"agent:started",
53+
"human-turn:started",
54+
"human-turn:transcript",
55+
"human-turn:transcript",
56+
"human-turn:ended",
57+
"ai-turn:started",
58+
"ai-turn:token",
59+
"ai-turn:sentence",
60+
"ai-turn:audio",
61+
"ai-turn:ended",
62+
"agent:stopped",
63+
]);
64+
});
65+
66+
it("maintains invariant ordering for public events", async () => {
67+
const harness = createAgentHarness();
68+
const { agent, events, stt, llm, tts } = harness;
69+
70+
agent.start();
71+
await events.waitForEvent("agent:started");
72+
73+
stt.emitTranscript("hi", false);
74+
stt.emitTranscript("hi", true);
75+
76+
await events.waitForEvent("ai-turn:started");
77+
llm.emitSentence("Hello.", 0);
78+
tts.emitAudio(makeAudioChunk(5));
79+
llm.complete("Hello.");
80+
tts.complete();
81+
82+
await events.waitForEvent("ai-turn:ended");
83+
84+
agent.stop();
85+
await events.waitForEvent("agent:stopped");
86+
87+
await drainMicrotasks();
88+
89+
events.assertInvariants();
90+
});
91+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { createAgentHarness } from "../helpers/harness";
4+
5+
describe("cancellation", () => {
6+
it("aborts providers when stopped while listening", async () => {
7+
const harness = createAgentHarness({ useVAD: true, useTurnDetector: true });
8+
const { agent, events, stt, vad, turnDetector } = harness;
9+
10+
agent.start();
11+
await events.waitForEvent("agent:started");
12+
13+
agent.stop();
14+
await events.waitForEvent("agent:stopped");
15+
16+
expect(stt.aborted).toBe(true);
17+
expect(vad?.aborted).toBe(true);
18+
expect(turnDetector?.aborted).toBe(true);
19+
});
20+
21+
it("aborts LLM when stopped during generation", async () => {
22+
const harness = createAgentHarness();
23+
const { agent, events, stt, llm } = harness;
24+
25+
agent.start();
26+
await events.waitForEvent("agent:started");
27+
28+
stt.emitTranscript("hello", true);
29+
await events.waitForEvent("ai-turn:started");
30+
31+
agent.stop();
32+
await events.waitForEvent("agent:stopped");
33+
34+
expect(llm.aborted).toBe(true);
35+
});
36+
37+
it("aborts TTS when stopped during synthesis", async () => {
38+
const harness = createAgentHarness();
39+
const { agent, events, stt, llm, tts } = harness;
40+
41+
agent.start();
42+
await events.waitForEvent("agent:started");
43+
44+
stt.emitTranscript("hello", true);
45+
await events.waitForEvent("ai-turn:started");
46+
47+
llm.emitSentence("Hello.", 0);
48+
49+
agent.stop();
50+
await events.waitForEvent("agent:stopped");
51+
52+
expect(tts.abortedIds.length).toBeGreaterThan(0);
53+
});
54+
55+
it("aborts streaming audio when stopped", async () => {
56+
const harness = createAgentHarness();
57+
const { agent, events, stt, llm, tts } = harness;
58+
59+
agent.start();
60+
await events.waitForEvent("agent:started");
61+
62+
stt.emitTranscript("hello", true);
63+
await events.waitForEvent("ai-turn:started");
64+
65+
llm.emitSentence("Hello.", 0);
66+
tts.emitAudio(new Uint8Array([1, 2]).buffer);
67+
68+
agent.stop();
69+
await events.waitForEvent("agent:stopped");
70+
71+
expect(tts.abortedIds.length).toBeGreaterThan(0);
72+
});
73+
});

0 commit comments

Comments
 (0)