Summary
CallSession.play_and_collect(..., interrupt=True) starts the DTMF timeout at the same time as prompt playback. If no digit arrives, it cancels the prompt when that timeout expires and returns "", even if the prompt is still playing.
For real IVR menus, this means a prompt longer than the configured DTMF timeout can be cut off before the caller hears the full menu. Even for shorter prompts, the caller effectively gets timeout - prompt_duration seconds to respond rather than the documented timeout after the prompt.
Evidence
Affected code:
callstack/voice/service.py:291-311 starts self.play(audio_path) and immediately awaits collector.collect_one_from_stream(..., timeout=timeout).
- On no input, it cancels
play_task and returns "" without waiting for prompt completion or starting a post-prompt no-input window.
tests/test_ivr.py mocks session.play_and_collect and does not exercise the real playback/timeout interaction.
Minimal reproduction on main (1c883c59575676fe6ce28214f27aeded692769b8):
PYTHONPATH=. uv run --no-project --with pyserial-asyncio python - <<'PY'
import asyncio
from callstack.events.bus import EventBus
from callstack.voice.service import CallSession
class FakeAudio:
def __init__(self):
self.cancelled = False
async def play_file(self, path, cancel=None):
try:
await asyncio.sleep(1.0) # stand in for a long menu prompt
except asyncio.CancelledError:
self.cancelled = True
raise
class FakeService:
def __init__(self):
self._bus = EventBus()
self._audio = FakeAudio()
async def main():
service = FakeService()
session = CallSession(number='unknown', direction='inbound', service=service)
started = asyncio.get_running_loop().time()
result = await session.play_and_collect('long-menu.wav', timeout=0.05, interrupt=True)
elapsed = asyncio.get_running_loop().time() - started
print('play_and_collect result:', repr(result))
print('play_and_collect elapsed_lt_prompt:', elapsed < 0.2)
print('playback_cancelled:', service._audio.cancelled)
asyncio.run(main())
PY
Actual output:
play_and_collect result: ''
play_and_collect elapsed_lt_prompt: True
playback_cancelled: True
The no-input timeout cancelled the still-playing prompt.
Repository health checks from this scout run:
git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
# 307 passed in 4.24s
Duplicate searches run before filing:
gh issue list --state all --search "play_and_collect cancels prompt timeout IVR"
gh pr list --state all --search "play_and_collect cancels prompt timeout IVR"
# no results
Expected behavior
For interrupt=True:
- A DTMF digit during playback should interrupt/cancel playback and be returned/continued as today.
- If no digit arrives during playback, playback should be allowed to finish.
- After playback finishes, the configured no-input timeout should give the caller a response window.
Actual behavior
The same timeout is used as a deadline from the start of playback. When it expires with no digit, playback is cancelled and the method returns "".
Suggested fix direction
- Race prompt completion against the first DTMF event.
- If a digit wins, cancel playback and collect remaining digits from the same event stream.
- If playback wins, then start the normal collection timeout from that point.
- Add focused tests with a fake audio pipeline covering:
- digit during prompt interrupts playback;
- no digit during prompt does not cancel a long prompt early;
- input after prompt completion is accepted within the timeout.
Acceptance criteria
play_and_collect(interrupt=True) no longer cancels prompt playback solely because the no-input timeout expires while no DTMF was received.
- IVR menu callers get the configured response timeout after the prompt completes.
- Existing DTMF buffering/no-event-loss behavior remains covered.
Verification gates
git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
Summary
CallSession.play_and_collect(..., interrupt=True)starts the DTMF timeout at the same time as prompt playback. If no digit arrives, it cancels the prompt when that timeout expires and returns"", even if the prompt is still playing.For real IVR menus, this means a prompt longer than the configured DTMF timeout can be cut off before the caller hears the full menu. Even for shorter prompts, the caller effectively gets
timeout - prompt_durationseconds to respond rather than the documented timeout after the prompt.Evidence
Affected code:
callstack/voice/service.py:291-311startsself.play(audio_path)and immediately awaitscollector.collect_one_from_stream(..., timeout=timeout).play_taskand returns""without waiting for prompt completion or starting a post-prompt no-input window.tests/test_ivr.pymockssession.play_and_collectand does not exercise the real playback/timeout interaction.Minimal reproduction on
main(1c883c59575676fe6ce28214f27aeded692769b8):Actual output:
The no-input timeout cancelled the still-playing prompt.
Repository health checks from this scout run:
git diff --check PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q # 307 passed in 4.24sDuplicate searches run before filing:
Expected behavior
For
interrupt=True:Actual behavior
The same timeout is used as a deadline from the start of playback. When it expires with no digit, playback is cancelled and the method returns
"".Suggested fix direction
Acceptance criteria
play_and_collect(interrupt=True)no longer cancels prompt playback solely because the no-input timeout expires while no DTMF was received.Verification gates