fix(voice): preserve pending message intent across reconnects

This commit is contained in:
stu2not
2026-10-30 16:46:55 +08:00
parent 9ebb1a78ec
commit 475a2447d1
4 changed files with 290 additions and 33 deletions
@@ -20,6 +20,7 @@ from services.target_audio_cache import TargetAudioCache
DEVICE = 'TalkingQ_local_voice'
PROCESS_PARENT_MESSAGE = messages.process_parent_leave_message
PROCESS_DEVICE_MESSAGE = messages.process_cached_audio
def packet(sid, kind, audio=b''):
@@ -79,6 +80,223 @@ async def set_target(state, kind):
return await state.targets.get_voice_target(DEVICE)
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
@pytest.mark.parametrize('old_disconnect', ['before_connect', 'after_connect', 'after_start', 'none'])
async def test_fresh_command_survives_socket_replacement_and_completes_message(state, kind, old_disconnect):
"""Reproduce 03111: card command, then a new recording socket nine seconds later."""
old = state.ws
await state.connections.add_connection(DEVICE, old)
target = await set_target(state, kind)
deadline = target.expires_at
state.now[0] += 9
if old_disconnect == 'before_connect':
await state.connections.remove_connection(DEVICE, old)
await cleanup.cleanup_device_sessions(DEVICE, old)
new = SimpleNamespace(send_text=AsyncMock())
await state.connections.add_connection(DEVICE, new)
await state.targets.bind_connection(DEVICE, new)
if old_disconnect == 'after_connect':
await cleanup.cleanup_device_sessions(DEVICE, old)
assert await state.targets.get_voice_target(DEVICE) is target
assert target.websocket is None
assert target.expires_at == deadline
await send(state, 'fresh-message', 1, ws=new)
if old_disconnect == 'after_start':
await cleanup.cleanup_device_sessions(DEVICE, old)
await send(state, 'old-late', 1, ws=old)
await send(state, 'fresh-message', 4, b'first-', ws=new)
await send(state, 'fresh-message', 4, b'tail', ws=new)
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
archived = []
async def process(*args, **kwargs):
archived.append(await state.buffers.get_audio_data(target.audio_cache_key))
processor.side_effect = process
await send(state, 'fresh-message', 2, ws=new)
processor.assert_awaited_once()
assert archived == [b'first-tail']
assert state.started == []
assert [c.args[0] for c in new.send_text.call_args_list] == ['START_ACK', 'END_ACK']
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
@pytest.mark.asyncio
async def test_unowned_message_reports_failure_without_starting_ai_or_consuming_new_intent(state):
await send(state, 'unowned', 1)
await send(state, 'unowned', 4, b'unowned-audio')
fresh = await set_target(state, 'parent')
await send(state, 'unowned', 0, b'wrong-mode')
await send(state, 'unowned', 2)
assert state.started == []
assert state.ended == []
assert await state.targets.get_voice_target(DEVICE) is fresh
replies = [c.args[0] for c in state.ws.send_text.call_args_list]
assert replies[:2] == ['START_ACK', 'END_ACK']
assert replies[2].endswith('/assets/audio/save_audio_fail.mp3')
messages.process_parent_leave_message.assert_not_awaited()
messages.process_cached_audio.assert_not_awaited()
@pytest.mark.asyncio
async def test_silent_ai_still_starts_and_finishes_once(state):
await send(state, 'silent-ai', 1)
await send(state, 'silent-ai', 2)
assert state.started == ['silent-ai']
assert state.ended == [('silent-ai', b'')]
assert [c.args[0] for c in state.ws.send_text.call_args_list] == ['START_ACK', 'END_ACK']
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
@pytest.mark.parametrize('previous', ['message_cancelled', 'message_disconnected', 'message_expired', 'ai_disconnected'])
async def test_incomplete_recording_then_fresh_message_saves_only_new_audio(state, kind, previous):
await state.connections.add_connection(DEVICE, state.ws)
old_target = None
if previous.startswith('message'):
old_target = await set_target(state, kind)
await send(state, 'incomplete', 1)
await send(state, 'incomplete', 0 if previous == 'ai_disconnected' else 4, b'old-audio')
if previous == 'message_cancelled':
await send(state, 'incomplete', 3)
elif previous == 'message_expired':
state.now[0] += 10
assert await state.targets.get_voice_target(DEVICE) is None
else:
session = await state.sessions.get_session((DEVICE, 'incomplete'))
session.set_interrupted = AsyncMock()
await state.connections.remove_connection(DEVICE, state.ws)
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
fresh = await set_target(state, kind)
new = SimpleNamespace(send_text=AsyncMock())
await state.connections.add_connection(DEVICE, new)
await state.targets.bind_connection(DEVICE, new)
await send(state, 'fresh', 1, ws=new)
await send(state, 'fresh', 4, b'new-audio', ws=new)
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
archived = []
async def process(*args, **kwargs):
archived.append(await state.buffers.get_audio_data(fresh.audio_cache_key))
processor.side_effect = process
await send(state, 'fresh', 2, ws=new)
assert archived == [b'new-audio']
processor.assert_awaited_once()
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.buffers.get_audio_data(fresh.audio_cache_key) is None
if old_target:
assert await state.buffers.get_audio_data(old_target.audio_cache_key) is None
assert state.started == (['incomplete'] if previous == 'ai_disconnected' else [])
@pytest.mark.asyncio
async def test_reconnect_does_not_extend_pending_target_lifetime(state):
target = await set_target(state, 'parent')
for _ in range(2):
new = SimpleNamespace(send_text=AsyncMock())
state.now[0] += 4
await state.connections.add_connection(DEVICE, new)
await state.targets.bind_connection(DEVICE, new)
assert await state.targets.get_voice_target(DEVICE) is target
state.now[0] += 2
await state.targets.bind_connection(DEVICE, new)
assert await state.targets.get_voice_target(DEVICE) is None
await send(state, 'expired-message', 1, ws=new)
await send(state, 'expired-message', 4, b'late', ws=new)
await send(state, 'expired-message', 2, ws=new)
assert state.started == []
assert new.send_text.call_args.args[0].endswith('/assets/audio/save_audio_fail.mp3')
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
async def test_mqtt_command_then_replacement_ws_reaches_real_message_processor(state, monkeypatch, kind):
"""Use actual MQTT handlers, /ws and message processors; stub external I/O."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.websocket import router
from handlers import mqtt_handler as mqtt
monkeypatch.setattr(mqtt, 'device_target_cache', state.targets)
monkeypatch.setattr(messages, 'process_parent_leave_message', PROCESS_PARENT_MESSAGE)
monkeypatch.setattr(messages, 'process_cached_audio', PROCESS_DEVICE_MESSAGE)
monkeypatch.setattr(mqtt.card_service, 'get_card_by_uuid',
AsyncMock(return_value=SimpleNamespace(device_id='TalkingQ_receiver', status=1)))
monkeypatch.setattr(mqtt.card_service, 'is_active_for_device', AsyncMock(return_value=False))
upload = AsyncMock(return_value=SimpleNamespace(file_key='local-test/parent.wav'))
create = AsyncMock(return_value=(SimpleNamespace(child_name='local-test'), None, []))
notify = Mock()
save = AsyncMock(return_value=('http://localhost/peer.wav', '/local-test/peer.wav'))
archive = AsyncMock(return_value=SimpleNamespace(message_id=123, media_file_key='local-test/peer.wav'))
pending = AsyncMock()
monkeypatch.setattr(messages, 'upload_message_audio', upload)
monkeypatch.setattr(messages.im_conversation_service, 'create_device_parent_leave_message', create)
monkeypatch.setattr(messages.wechat_mp_notification_service, 'schedule_leave_message_notification', notify)
monkeypatch.setattr(messages.device_audio_cache_service, 'save_device_audio', save)
monkeypatch.setattr(messages.device_voice_archive_service, 'archive_peer_voice_message', archive)
monkeypatch.setattr(messages.pending_voice_message_service, 'add_pending_message', pending)
service = mqtt.TalkingQMQTTService({'broker': ''})
service._publish = AsyncMock(return_value=True)
async def authenticate(ws):
await state.connections.add_connection(DEVICE, ws)
await ws.send_text('LOCAL_AUTHENTICATED')
return True, DEVICE, 'LOCAL-SERIAL'
monkeypatch.setattr(endpoint, 'authenticate_websocket', authenticate)
app = FastAPI()
app.include_router(router)
@app.post('/local-command')
async def command():
if kind == 'parent':
await service._handle_short_press_message(DEVICE, {'params': {'uuid': DEVICE}})
else:
await service._handle_nfc_listen_report(DEVICE, {'params': {'uuid': 'LOCAL-CARD'}})
return {'ok': True}
audio = b'\x01\x00' * 800
with TestClient(app) as client:
with client.websocket_connect('/ws') as old:
assert old.receive_text() == 'LOCAL_AUTHENTICATED'
assert client.post('/local-command').json() == {'ok': True}
payload = service._publish.call_args.args[1]
assert payload['msg_id'] == ('011' if kind == 'parent' else '005')
if kind == 'device':
assert payload['type'] == 1
state.now[0] += 9
with client.websocket_connect('/ws') as new:
assert new.receive_text() == 'LOCAL_AUTHENTICATED'
new.send_bytes(packet('replacement', 1))
assert new.receive_text() == 'START_ACK'
old.close()
new.send_bytes(packet('replacement', 4, audio[:800]))
new.send_bytes(packet('replacement', 4, audio[800:]))
new.send_bytes(packet('replacement', 2))
assert new.receive_text() == 'END_ACK'
assert new.receive_text().endswith('/assets/audio/message_ok.mp3')
assert state.started == []
assert await state.targets.get_voice_target(DEVICE) is None
assert not state.buffers.audio_data_cache
assert await state.sessions.get_all_sessions() == []
if kind == 'parent':
assert create.call_args.kwargs['audio_content'][44:] == audio
upload.assert_awaited_once()
notify.assert_called_once()
pending.assert_not_awaited()
else:
save.assert_awaited_once_with(audio, device_id='TalkingQ_receiver')
archive.assert_awaited_once_with(sender_device_id=DEVICE, receiver_device_id='TalkingQ_receiver',
local_audio_path='/local-test/peer.wav')
assert pending.call_args.kwargs['im_message_id'] == 123
assert pending.call_args.kwargs['sender_device_id'] == DEVICE
assert pending.call_args.kwargs['target_device_id'] == 'TalkingQ_receiver'
create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['parent', 'device'])
async def test_ai_audio_recovers_from_pending_message_without_losing_first_chunk(state, kind):
@@ -213,7 +431,7 @@ async def test_two_senders_to_same_receiver_use_separate_buffers(state):
@pytest.mark.asyncio
@pytest.mark.parametrize('claimed', [False, True])
async def test_disconnect_clears_pending_or_partial_target(state, claimed):
async def test_disconnect_only_clears_target_claimed_by_that_recording(state, claimed):
await state.connections.add_connection(DEVICE, state.ws)
target = await set_target(state, 'parent')
if claimed:
@@ -222,7 +440,7 @@ async def test_disconnect_clears_pending_or_partial_target(state, claimed):
session = await state.sessions.get_session((DEVICE, 'message'))
session.set_interrupted = AsyncMock()
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
assert await state.targets.get_voice_target(DEVICE) is None
assert await state.targets.get_voice_target(DEVICE) is (None if claimed else target)
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
assert await state.sessions.get_all_sessions() == []
@@ -256,12 +474,15 @@ async def test_old_socket_cleanup_and_late_packets_preserve_new_connection(state
@pytest.mark.asyncio
async def test_command_before_connect_is_adopted_and_cleared_on_early_disconnect(state):
async def test_pending_command_survives_early_disconnect_without_extending_deadline(state):
target = await set_target(state, 'parent')
assert target.websocket is None
await state.connections.add_connection(DEVICE, state.ws)
await state.targets.bind_connection(DEVICE, state.ws)
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
assert await state.targets.get_voice_target(DEVICE) is target
assert target.websocket is None
state.now[0] += 10
assert await state.targets.get_voice_target(DEVICE) is None
@@ -343,3 +564,4 @@ async def test_new_recording_recovers_abandoned_message_without_reusing_audio(st
assert state.ended == [('new-ai', b'question')]
assert await state.buffers.get_audio_data(abandoned.audio_cache_key) is None
messages.process_parent_leave_message.assert_not_awaited()
assert not any('save_audio_fail.mp3' in c.args[0] for c in state.ws.send_text.call_args_list)