{"version":3,"sources":["../../src/voice/expressive.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { describe, expect, it } from 'vitest';\nimport { ChatContext } from '../llm/chat_context.js';\nimport { splitWords } from '../tokenize/basic/word.js';\nimport {\n  DEFAULT_SPEECH_STEERING_OPTIONS,\n  TranscriptMarkupStripper,\n} from '../tts/provider_format.js';\nimport {\n  DEFAULT_EXPRESSIVE_OPTIONS,\n  type ExpressiveOptions,\n  TTS_INSTRUCTIONS_PLACEHOLDER,\n  resolveExpressiveOptions,\n} from './agent_session.js';\nimport { AgentSession } from './agent_session.js';\nimport {\n  EXPRESSIVE_INSTRUCTIONS_MESSAGE_ID,\n  hasExpressiveInstructions,\n  removeExpressiveInstructions,\n  stripAssistantMarkup,\n  updateExpressiveInstructions,\n} from './generation.js';\n\n// what an expressive turn actually leaves in history: the expr markers the LLM emitted,\n// plus (defensively) a hallucinated native tag. Square brackets are *not* markup here —\n// they reach history as prose or markdown links, so the scrub must leave them alone.\nconst MARKED_UP =\n  '<expr type=\"expression\" label=\"happy\"/> Welcome back! <sound value=\"chuckle\"/> ' +\n  'Glad you called again. Docs: [the guide](https://docs.livekit.io).';\n\ndescribe('stripAssistantMarkup', () => {\n  it('scrubs assistant markup, keeps prose brackets and user content', () => {\n    const ctx = ChatContext.empty();\n    ctx.addMessage({ role: 'assistant', content: MARKED_UP });\n    ctx.addMessage({ role: 'user', content: 'I typed <expression value=\"happy\"/> literally' });\n    const plain = ctx.addMessage({ role: 'assistant', content: 'No tags here.' });\n    const plainContent = plain.content;\n\n    stripAssistantMarkup(ctx);\n\n    const assistantTexts = ctx.items\n      .filter((item) => item.type === 'message' && item.role === 'assistant')\n      .map((item) => (item.type === 'message' ? item.textContent : undefined));\n\n    expect(assistantTexts[0]).not.toContain('<expr');\n    expect(assistantTexts[0]).not.toContain('<sound');\n    expect(assistantTexts[0]).toContain('Welcome back!');\n    expect(assistantTexts[0]).toContain('Glad you called again.');\n    // markdown links survive: brackets are prose, not markup\n    expect(assistantTexts[0]).toContain('[the guide](https://docs.livekit.io)');\n\n    // user content is never touched, tag-shaped or not\n    const userItem = ctx.items.find((item) => item.type === 'message' && item.role === 'user')!;\n    expect(userItem.type === 'message' && userItem.textContent).toContain(\n      '<expression value=\"happy\"/>',\n    );\n\n    // tag-free assistant content is left as-is (fast path)\n    expect(plain.content).toBe(plainContent);\n  });\n});\n\ndescribe('expressive instruction message', () => {\n  it('replaces rather than stacks, and can be removed', () => {\n    const ctx = ChatContext.empty();\n    updateExpressiveInstructions(ctx, { text: 'markup guide v1' });\n    updateExpressiveInstructions(ctx, { text: 'markup guide v2' });\n\n    const guides = ctx.items.filter((item) => item.id === EXPRESSIVE_INSTRUCTIONS_MESSAGE_ID);\n    expect(guides, 're-injection must replace, not stack').toHaveLength(1);\n    expect(guides[0]!.type === 'message' && guides[0]!.textContent).toBe('markup guide v2');\n\n    removeExpressiveInstructions(ctx);\n    expect(ctx.items.every((item) => item.id !== EXPRESSIVE_INSTRUCTIONS_MESSAGE_ID)).toBe(true);\n  });\n});\n\ndescribe('history-scrub gate', () => {\n  // The scrub is destructive and uses the union of every provider's tag names, and its\n  // branch is the default path for every session that never enabled expressive — so both\n  // signals that license it must stay off until expressive was actually live.\n\n  it('is off for a session that never enabled expressive', () => {\n    expect(new AgentSession()._expressiveEverActive).toBe(false);\n    expect(new AgentSession({ expressive: true })._expressiveEverActive).toBe(\n      false,\n      // still false: the latch flips when the guide is injected, not when the flag is set,\n      // so a TTS with no markup dialect never licenses the scrub\n    );\n  });\n\n  it('detects a restored history carrying the guide', () => {\n    const ctx = ChatContext.empty();\n    expect(hasExpressiveInstructions(ctx)).toBe(false);\n\n    updateExpressiveInstructions(ctx, { text: 'markup guide' });\n    expect(hasExpressiveInstructions(ctx)).toBe(true);\n\n    removeExpressiveInstructions(ctx);\n    expect(hasExpressiveInstructions(ctx)).toBe(false);\n  });\n\n  it('leaves angle-bracketed assistant text alone when nothing licenses a scrub', () => {\n    // what the gate protects: an agent that legitimately writes provider-shaped tags in a\n    // session that never used expressive\n    const ctx = ChatContext.empty();\n    ctx.addMessage({ role: 'assistant', content: 'Hold on <break time=\"1s\"/> nearly there.' });\n\n    expect(hasExpressiveInstructions(ctx)).toBe(false);\n    // (the gate skips stripAssistantMarkup entirely; calling it would destroy this)\n    stripAssistantMarkup(ctx);\n    const scrubbed = ctx.items[0]!;\n    expect(scrubbed.type === 'message' && scrubbed.textContent).toBe('Hold on nearly there.');\n  });\n});\n\ndescribe('transcript pacing', () => {\n  it('recognizes a markup tag shredded across word tokens', () => {\n    // the word tokenizer emits whitespace-free runs, so the synchronizer must replay the\n    // original slices of pushedText — feeding it the bare word tokens reassembles\n    // `<exprtype=\"expression\"label=\"warm surprise\"/>`, which matches nothing and gets\n    // paced as if it were spoken\n    const pushedText = '<expr type=\"expression\" label=\"warm surprise\"/> Hello there';\n    const stripper = new TranscriptMarkupStripper();\n\n    let cursor = 0;\n    let paced = '';\n    for (const [word] of splitWords(pushedText, false)) {\n      let end = pushedText.indexOf(word, cursor) + word.length;\n      while (end < pushedText.length && !/\\s/.test(pushedText[end]!)) end++;\n      paced += stripper.push(pushedText.slice(cursor, end));\n      cursor = end;\n    }\n    paced += stripper.flush();\n\n    expect(paced).not.toContain('<expr');\n    expect(paced.trim()).toBe('Hello there');\n  });\n});\n\ndescribe('AgentSession expressive option', () => {\n  it('defaults to off and round-trips what was passed', () => {\n    expect(new AgentSession()._expressive).toBe(false);\n    expect(new AgentSession({ expressive: true })._expressive).toBe(true);\n\n    const opts: ExpressiveOptions = { ttsInstructionsAppend: 'Stay upbeat.' };\n    expect(new AgentSession({ expressive: opts })._expressive).toEqual(opts);\n  });\n});\n\ndescribe('custom template without the markup-guide placeholder', () => {\n  // The placeholder is the only channel the provider's vocabulary has. A template that\n  // omits it still injects a message and still turns on xml-aware chunking, markup\n  // conversion and transcript stripping — the model just never learns the tags.\n\n  const render = (expr: ExpressiveOptions) => {\n    const resolved = resolveExpressiveOptions(expr, {\n      providerKey: 'inworld',\n      defaults: DEFAULT_EXPRESSIVE_OPTIONS,\n    });\n    return String(resolved.ttsInstructionsTemplate);\n  };\n\n  it('drops the vocabulary when the placeholder is missing', () => {\n    const text = render({ ttsInstructionsTemplate: 'Be expressive, please.' });\n    expect(text).not.toContain(TTS_INSTRUCTIONS_PLACEHOLDER);\n    // ...yet it still renders to a non-empty guide message, so nothing downstream notices\n    expect(text.trim()).not.toBe('');\n  });\n\n  it('still renders non-empty for an empty template, via the steering fragment', () => {\n    // the appended delivery guidelines make `rendered.trim()` truthy even here\n    const text = render({ ttsInstructionsTemplate: '' });\n    expect(text).not.toContain(TTS_INSTRUCTIONS_PLACEHOLDER);\n    expect(text.trim()).not.toBe('');\n    expect(text).toContain('Delivery guidelines:');\n  });\n\n  it('keeps the placeholder when only appending', () => {\n    // the documented way to add your own rules keeps the guide intact\n    const text = render({ ttsInstructionsAppend: 'Stay upbeat.' });\n    expect(text).toContain(TTS_INSTRUCTIONS_PLACEHOLDER);\n    expect(text.endsWith('Stay upbeat.')).toBe(true);\n  });\n\n  it('warns at most once per session', () => {\n    expect(new AgentSession()._warnedExpressiveTemplate).toBe(false);\n  });\n});\n\ndescribe('resolveExpressiveOptions', () => {\n  const resolve = (expr: ExpressiveOptions, providerKey = 'inworld') =>\n    resolveExpressiveOptions(expr, { providerKey, defaults: DEFAULT_EXPRESSIVE_OPTIONS });\n\n  it('keeps the default template and steering when nothing is overridden', () => {\n    const resolved = resolve({});\n    const text = String(resolved.ttsInstructionsTemplate);\n    // the raw placeholder survives resolution — it is filled at injection time\n    expect(text.startsWith(String(DEFAULT_EXPRESSIVE_OPTIONS.ttsInstructionsTemplate))).toBe(true);\n    expect(text).toContain(TTS_INSTRUCTIONS_PLACEHOLDER);\n    // the default steering renders its one non-default field (fillers on)\n    expect(text).toContain('Sprinkle in natural fillers');\n    expect(resolved.speechSteering).toEqual(DEFAULT_SPEECH_STEERING_OPTIONS);\n  });\n\n  it('appends rendered steering guidelines to the template', () => {\n    const resolved = resolve({ speechSteering: { nonverbalSounds: { laughing: false } } });\n    const text = String(resolved.ttsInstructionsTemplate);\n    expect(text).toContain('Delivery guidelines:');\n    expect(text).toContain('Non-verbal sounds:');\n    // an explicit steering value wins over the default, unset ones fall back\n    expect(resolved.speechSteering).toEqual({\n      disfluencies: true,\n      nonverbalSounds: { laughing: false },\n    });\n  });\n\n  it('lets an explicit template override the default, with append applied last', () => {\n    const resolved = resolve({\n      ttsInstructionsTemplate: `Custom. ${TTS_INSTRUCTIONS_PLACEHOLDER}`,\n      ttsInstructionsAppend: 'Stay upbeat.',\n      speechSteering: { disfluencies: false },\n    });\n    const text = String(resolved.ttsInstructionsTemplate);\n    expect(text.startsWith('Custom. ')).toBe(true);\n    expect(text).toContain('No fillers');\n    // the user's free-form rules always win, so they come last\n    expect(text.endsWith('Stay upbeat.')).toBe(true);\n  });\n\n  it('adds nothing for a provider with no steerable vocabulary', () => {\n    const resolved = resolve({ speechSteering: {} }, 'cartesia');\n    expect(String(resolved.ttsInstructionsTemplate)).toBe(\n      String(DEFAULT_EXPRESSIVE_OPTIONS.ttsInstructionsTemplate) +\n        '\\n\\nDelivery guidelines:\\n- Sprinkle in natural fillers (um, uh) and openers ' +\n        '(oh, well, so), zero to two per turn, never mechanical.',\n    );\n  });\n});\n"],"mappings":"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","names":["import_agent_session"]}