diff --git a/.changeset/fuzzy-crabs-express.md b/.changeset/fuzzy-crabs-express.md
new file mode 100644
index 000000000..e6663882b
--- /dev/null
+++ b/.changeset/fuzzy-crabs-express.md
@@ -0,0 +1,7 @@
+---
+'@livekit/agents': patch
+'@livekit/agents-plugin-anthropic': patch
+'@livekit/agents-plugin-phonic': patch
+---
+
+Preserve raw expressive markup for provider-facing text while stripping LiveKit expr tags from assistant text content.
diff --git a/agents/src/llm/chat_context.test.ts b/agents/src/llm/chat_context.test.ts
index bfb51aa2e..9388dd546 100644
--- a/agents/src/llm/chat_context.test.ts
+++ b/agents/src/llm/chat_context.test.ts
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import { initializeLogger } from '../log.js';
+import { stripExprMarkup } from '../tts/provider_format.js';
import { INSTRUCTIONS_MESSAGE_ID, applyInstructionsModality } from '../voice/generation.js';
import { FakeLLM } from '../voice/testing/fake_llm.js';
import {
@@ -26,6 +27,16 @@ initializeLogger({ pretty: false, level: 'error' });
const summaryXml = (summary: string) =>
['', summary, ''].join('\n');
+const mixedMarkup =
+ ' Press [Enter] to see bold, ' +
+ 'read [the docs](https://docs.livekit.io), then 1 < 2. ' +
+ 'keep it secret';
+
+const mixedMarkupClean =
+ ' Press [Enter] to see bold, ' +
+ 'read [the docs](https://docs.livekit.io), then 1 < 2. ' +
+ 'keep it secret';
+
class TrackingFakeLLM extends FakeLLM {
chatCalls = 0;
@@ -305,28 +316,41 @@ describe('ChatContext.toJSON', () => {
});
});
-describe('ChatMessage text content', () => {
- const mixed =
- ' Press [Enter] to see bold, ' +
- 'read [the docs](https://docs.livekit.io), then 1 < 2. ' +
- 'keep it secret';
- const mixedClean =
- ' Press [Enter] to see bold, ' +
- 'read [the docs](https://docs.livekit.io), then 1 < 2. ' +
- 'keep it secret';
-
- it('strips only expr markup from assistant textContent', () => {
- const msg = ChatMessage.create({ role: 'assistant', content: [mixed] });
-
- expect(msg.textContent).toBe(mixedClean);
- expect(msg.rawTextContent).toBe(mixed);
+describe('stripExprMarkup and ChatMessage text content', () => {
+ it('stripExprMarkup only touches expr tags', () => {
+ expect(stripExprMarkup(mixedMarkup)).toBe(mixedMarkupClean);
+ });
+
+ it('stripExprMarkup is a noop without expr tags', () => {
+ const text = 'plain text with [brackets] and ';
+ expect(stripExprMarkup(text)).toBe(text);
+ });
+
+ it('strips an unmatched opening expr marker', () => {
+ expect(stripExprMarkup('Alpha bravo')).toBe('Alpha bravo');
+ });
+
+ it('strips an unmatched closing expr marker', () => {
+ expect(stripExprMarkup('Alpha bravo')).toBe('Alpha bravo');
+ });
+
+ it('strips a marker assembled from stream-split chunks', () => {
+ const chunks = ['Alpha bravo'];
+ expect(stripExprMarkup(chunks.join(''))).toBe('Alpha bravo');
+ });
+
+ it('strips expr tags from assistant textContent only', () => {
+ const msg = ChatMessage.create({ role: 'assistant', content: [mixedMarkup] });
+
+ expect(msg.textContent).toBe(mixedMarkupClean);
+ expect(msg.rawTextContent).toBe(mixedMarkup);
});
it.each(['user', 'system', 'developer'] as const)('keeps %s textContent raw', (role) => {
- const msg = ChatMessage.create({ role, content: [mixed] });
+ const msg = ChatMessage.create({ role, content: [mixedMarkup] });
- expect(msg.textContent).toBe(mixed);
- expect(msg.rawTextContent).toBe(mixed);
+ expect(msg.textContent).toBe(mixedMarkup);
+ expect(msg.rawTextContent).toBe(mixedMarkup);
});
it('returns undefined without text content', () => {
@@ -338,19 +362,19 @@ describe('ChatMessage text content', () => {
it('toJSON stripMarkup is expr-only and assistant-only', () => {
const chatCtx = new ChatContext();
- chatCtx.addMessage({ role: 'user', content: [mixed] });
- chatCtx.addMessage({ role: 'assistant', content: [mixed] });
+ chatCtx.addMessage({ role: 'user', content: [mixedMarkup] });
+ chatCtx.addMessage({ role: 'assistant', content: [mixedMarkup] });
const stripped = chatCtx.toJSON({ stripMarkup: true });
expect(stripped.items).toEqual([
- expect.objectContaining({ content: [mixed], role: 'user' }),
- expect.objectContaining({ content: [mixedClean], role: 'assistant' }),
+ expect.objectContaining({ content: [mixedMarkup], role: 'user' }),
+ expect.objectContaining({ content: [mixedMarkupClean], role: 'assistant' }),
]);
const raw = chatCtx.toJSON();
expect(raw.items).toEqual([
- expect.objectContaining({ content: [mixed], role: 'user' }),
- expect.objectContaining({ content: [mixed], role: 'assistant' }),
+ expect.objectContaining({ content: [mixedMarkup], role: 'user' }),
+ expect.objectContaining({ content: [mixedMarkup], role: 'assistant' }),
]);
});
});
diff --git a/agents/src/llm/chat_context.ts b/agents/src/llm/chat_context.ts
index 65f4ff052..0803c4a3e 100644
--- a/agents/src/llm/chat_context.ts
+++ b/agents/src/llm/chat_context.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: Apache-2.0
import type { AudioFrame, VideoFrame } from '@livekit/rtc-node';
+import { stripExprMarkup } from '../tts/provider_format.js';
import { createImmutableArray, shortuuid } from '../utils.js';
import type { LLM } from './llm.js';
import { type ProviderFormat, toChatCtx } from './provider_format/index.js';
@@ -228,10 +229,6 @@ export function concatInstructions(...parts: Array): stri
export type ChatContent = ImageContent | AudioContent | Instructions | string;
-function stripExprMarkup(text: string): string {
- return text.replace(/]*>/g, '').replace(/<\/expr\s*>/g, '');
-}
-
export function createImageContent(params: {
image: string | VideoFrame;
id?: string;
diff --git a/agents/src/tts/provider_format.ts b/agents/src/tts/provider_format.ts
new file mode 100644
index 000000000..54790acca
--- /dev/null
+++ b/agents/src/tts/provider_format.ts
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+/** Strip only LiveKit expressive `` tags, leaving provider-native markup untouched. */
+export function stripExprMarkup(text: string): string {
+ let stripped = text;
+ let previous: string;
+
+ do {
+ previous = stripped;
+ stripped = stripped
+ .replace(/]*\/>/gi, '')
+ .replace(/]*>([\s\S]*?)<\/expr>/gi, '$1');
+ } while (stripped !== previous);
+
+ stripped = stripped.replace(/]*\/?>/gi, '').replace(/<\/expr\s*>/gi, '');
+
+ return stripped;
+}