From 383a872bd02bf948e93cd53acb25879613c3396c Mon Sep 17 00:00:00 2001 From: "rosetta-livekit-bot[bot]" <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:05:13 +0000 Subject: [PATCH] fix: timestamp say transcripts at speech start --- .changeset/timestamp-say-transcripts.md | 5 ++ agents/src/voice/agent_activity.ts | 1 + .../agent_activity_interrupted_commit.test.ts | 82 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 .changeset/timestamp-say-transcripts.md diff --git a/.changeset/timestamp-say-transcripts.md b/.changeset/timestamp-say-transcripts.md new file mode 100644 index 0000000000..26d1091bdd --- /dev/null +++ b/.changeset/timestamp-say-transcripts.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Timestamp `say()` transcripts when speech starts so interrupted turns remain chronologically ordered. diff --git a/agents/src/voice/agent_activity.ts b/agents/src/voice/agent_activity.ts index 92e2bb7325..bc20e9c903 100644 --- a/agents/src/voice/agent_activity.ts +++ b/agents/src/voice/agent_activity.ts @@ -3249,6 +3249,7 @@ export class AgentActivity implements RecognitionHooks { role: 'assistant', content: textOut?.text || '', interrupted: speechHandle.interrupted, + createdAt: replyStartedSpeakingAt ?? Date.now(), metrics: replyAssistantMetrics, }); this.agent._chatCtx.insert(message); diff --git a/agents/src/voice/agent_activity_interrupted_commit.test.ts b/agents/src/voice/agent_activity_interrupted_commit.test.ts index 5941e6579f..f9dc80bde6 100644 --- a/agents/src/voice/agent_activity_interrupted_commit.test.ts +++ b/agents/src/voice/agent_activity_interrupted_commit.test.ts @@ -43,6 +43,38 @@ class InterruptibleOutput extends AudioOutput { } } +class SayTimestampOutput extends AudioOutput { + onFirstFrame?: () => void; + private firstSegment = true; + + constructor() { + super(24000); + } + + async captureFrame(f: AudioFrame): Promise { + const previousSegments = this.capturedPlayoutSegments; + await super.captureFrame(f); + if (this.capturedPlayoutSegments === previousSegments) return; + + this.onPlaybackStarted(Date.now()); + if (this.firstSegment) { + this.onFirstFrame?.(); + } + } + + flush(): void { + super.flush(); + if (!this.firstSegment) { + this.onPlaybackFinished({ playbackPosition: 0.2, interrupted: false }); + } + } + + clearBuffer(): void { + this.firstSegment = false; + this.onPlaybackFinished({ playbackPosition: 0.02, interrupted: true }); + } +} + // Audio sink that mimics a DataStream avatar output (waitPlaybackStart: true): // frames are accepted faster than real time and playback-started is only // reported LATER, via an out-of-band notification (the `lk.playback_started` @@ -172,6 +204,18 @@ class FrameAgent extends Agent { } } +class SayOnEnterAgent extends FrameAgent { + async onEnter(): Promise { + const audio = new ReadableStream({ + start(controller) { + for (let i = 0; i < 10; i++) controller.enqueue(frame()); + controller.close(); + }, + }); + this.session.say('I saw your application for a Honda', { audio }); + } +} + // Agent whose TTS produces no frames and stays open long enough that an // interruption (anchored to ttsNode being invoked, fired well before the // close) always lands mid-forwarding with zero audio captured for the segment. @@ -193,6 +237,44 @@ class NoFrameAgent extends Agent { describe('AgentActivity interrupted-speech commit', () => { initializeLogger({ pretty: false, level: 'silent' }); + it('timestamps an interrupted say transcript at speech start', async () => { + const session = new AgentSession({ + llm: new FakeLLM([{ input: 'Who is this?', content: 'This is John.' }]), + }); + const audioOut = new SayTimestampOutput(); + session.output.audio = audioOut; + let reply: ReturnType | undefined; + audioOut.onFirstFrame = () => { + setTimeout(() => { + session.interrupt(); + reply = session.generateReply({ userInput: 'Who is this?' }); + }, 10); + }; + + const agent = new SayOnEnterAgent(); + await session.start({ agent }); + try { + await vi.waitFor(() => expect(reply).toBeDefined()); + await reply!.waitForPlayout(); + + const messages = agent.chatCtx.items.filter((item) => item.type === 'message'); + expect(messages.map((message) => message.role)).toEqual([ + 'system', + 'assistant', + 'user', + 'assistant', + ]); + expect(messages[1]!.interrupted).toBe(true); + const startedSpeakingAt = messages[1]!.metrics?.startedSpeakingAt; + expect(startedSpeakingAt).toBeDefined(); + expect(messages[1]!.createdAt).toBe(startedSpeakingAt! * 1_000); + expect(messages[2]!.textContent).toBe('Who is this?'); + expect(messages[3]!.textContent).toBe('This is John.'); + } finally { + await session.close(); + } + }); + it('commits an interrupted reply to chat ctx when no synchronized transcript is available', async () => { const session = new AgentSession({ llm: new FakeLLM([{ input: 'hello', content: 'A fairly long spoken reply.' }]),