fix(wecombot): stream replies after HITL cards

This commit is contained in:
fdc310
2026-10-29 19:29:32 +08:00
parent 2721a213e8
commit d611f687eb
15 changed files with 918 additions and 59 deletions
+78 -8
View File
@@ -60,6 +60,7 @@ _UPLOAD_CHUNK_SIZE = 512 * 1024
_DEDUP_CACHE_MAX = 4096
_STREAM_CACHE_MAX = 1024
_INTERACTION_STREAM_MAX_AGE_SECONDS = 570
_FEEDBACK_CACHE_MAX = 4096
_PENDING_FORM_MAX = 1024
_PENDING_FORM_TTL_SECONDS = 1800
@@ -140,6 +141,7 @@ class WecomBotWsClient:
self._completed_stream_content: dict[str, str] = {}
# Stream session info for feedback tracking
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
self._interaction_stream_origins: dict[str, dict] = {} # msg_id -> original callback context
# Feedback tracking: feedback_id -> session info
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
# msg_id -> feedback_id (for associating feedback with message)
@@ -177,6 +179,72 @@ class WecomBotWsClient:
if task_id:
self._pending_forms_by_task.pop(task_id, None)
async def finish_stream_for_interaction(self, msg_id: str) -> bool:
"""Release an unused stream or finish text already shown before a card."""
key = self._stream_ids.get(msg_id)
if not key:
return True
session = self._stream_sessions.get(msg_id, {})
if not self._stream_sessions.get(msg_id, {}).get('stream_started'):
self._remember_interaction_stream_origin(msg_id, session)
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True
req_id, stream_id = key.split('|', 1)
content = self._stream_last_content.get(msg_id, '')
try:
ack = await self.reply_stream(req_id, stream_id, content, finish=True)
except Exception:
await self.logger.warning(f'Failed to finish WeCom loading stream: {traceback.format_exc()}')
return False
if not isinstance(ack, dict) or ack.get('errcode', 0) != 0:
detail = ack.get('errmsg', ack.get('errcode')) if isinstance(ack, dict) else 'no acknowledgement'
await self.logger.warning(f'Failed to finish WeCom loading stream: {detail}')
return False
self._remember_interaction_stream_origin(msg_id, session)
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True
def _remember_interaction_stream_origin(self, msg_id: str, session: dict) -> None:
if session.get('req_id'):
self._interaction_stream_origins[msg_id] = {
key: session.get(key) for key in ('req_id', 'user_id', 'chat_id', 'created_at')
}
self._cap_mapping(self._interaction_stream_origins, _STREAM_CACHE_MAX)
def begin_interaction_stream(self, msg_id: str, target_type: str, target_id: str) -> bool:
"""Use a fresh stream ID so the resumed answer appears after the card."""
origin = self._interaction_stream_origins.get(msg_id)
if not origin or msg_id in self._stream_ids:
return False
age = time.monotonic() - float(origin.get('created_at') or 0)
if age >= _INTERACTION_STREAM_MAX_AGE_SECONDS:
self._interaction_stream_origins.pop(msg_id, None)
return False
expected = origin.get('chat_id') if target_type == 'group' else origin.get('user_id')
if target_type not in {'group', 'person', 'private'} or str(expected or '') != target_id:
return False
stream_id = _generate_req_id('stream')
self._stream_ids[msg_id] = f'{origin["req_id"]}|{stream_id}'
self._stream_sessions[msg_id] = {**origin, 'stream_id': stream_id, 'stream_started': False}
self._prune_stream_state()
return True
async def push_interaction_stream_chunk(self, msg_id: str, content: str, is_final: bool) -> bool:
first_frame = not self._stream_sessions.get(msg_id, {}).get('stream_started')
if await self.push_stream_chunk(msg_id, content, is_final=is_final):
if first_frame and (is_final or self._stream_sessions.get(msg_id, {}).get('stream_started')):
await self.logger.info(f'WeCom interaction reply stream accepted for message {msg_id}')
return True
await self.logger.warning(f'WeCom interaction reply stream unavailable for message {msg_id}')
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return False
def _prune_pending_forms(self) -> None:
cutoff = time.monotonic() - _PENDING_FORM_TTL_SECONDS
for task_id, pending in tuple(self._pending_forms_by_task.items()):
@@ -254,6 +322,7 @@ class WecomBotWsClient:
self._stream_ids.clear()
self._stream_last_content.clear()
self._stream_sessions.clear()
self._interaction_stream_origins.clear()
self._feedback_sessions.clear()
self._msg_feedback_ids.clear()
self._pending_forms_by_task.clear()
@@ -727,8 +796,13 @@ class WecomBotWsClient:
# WeCom replaces the displayed stream content on each refresh, so
# every frame must contain the complete snapshot, not only a delta.
await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id)
ack = await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id)
if not isinstance(ack, dict) or ack.get('errcode', 0) != 0:
detail = ack.get('errmsg', ack.get('errcode')) if isinstance(ack, dict) else 'no acknowledgement'
raise RuntimeError(f'WeCom stream frame rejected: {detail}')
self._stream_last_content[msg_id] = next_content
if msg_id in self._stream_sessions:
self._stream_sessions[msg_id]['stream_started'] = True
if is_final:
self._completed_stream_content[msg_id] = hashlib.sha256(next_content.encode()).hexdigest()
self._cap_mapping(self._completed_stream_content, _STREAM_CACHE_MAX)
@@ -962,15 +1036,11 @@ class WecomBotWsClient:
'user_id': message_data.get('userid', ''),
'chat_id': message_data.get('chatid', ''),
'chat_type': message_data.get('type', 'single'),
'stream_started': False,
'created_at': time.monotonic(),
}
self._remember_interaction_stream_origin(msg_id, self._stream_sessions[msg_id])
self._prune_stream_state()
# Send an initial empty stream frame so the WeCom client
# shows its built-in loading spinner while the pipeline
# processes the message (e.g. RAG retrieval).
try:
await self.reply_stream(req_id, stream_id, '', finish=False)
except Exception:
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
@@ -402,7 +402,12 @@ class InteractionManager:
except pydantic.ValidationError:
return
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
async def acknowledge_submission(
self,
record: dict[str, typing.Any],
adapter: typing.Any,
callback_context: dict[str, typing.Any] | None = None,
) -> None:
"""Best-effort transition of submitted controls into a read-only state."""
delivery_result = record.get('delivery_result')
if not isinstance(delivery_result, dict) or not self._supports_platform_api(
@@ -416,6 +421,7 @@ class InteractionManager:
'request': record.get('request') or {},
'submission': record.get('submission') or {},
'update_target': delivery_result,
**({'callback_context': callback_context} if callback_context else {}),
},
)
if isinstance(delivery_result, dict):
@@ -375,6 +375,103 @@ class InteractionStore:
raise InteractionNotFoundError('Interaction disappeared after submission')
return self._to_dict(refreshed)
async def consume_text_reply(
self,
*,
workspace_id: str,
bot_id: str,
conversation_id: str,
actor_id: str,
binding_id: str,
runner_id: str,
processor_id: str,
text: str,
message_id: str,
message_time: int | float | None = None,
) -> dict[str, typing.Any] | None:
"""Consume the latest scoped, plain-text form waiting for this user."""
answer = text.strip()
if (
not answer
or len(answer) > 20000
or not all(
(workspace_id, bot_id, conversation_id, actor_id, binding_id, runner_id, processor_id, message_id)
)
):
return None
now = _utc_now()
source_marker = 'text-reply:' + _token_hash(message_id)
scope = (
AgentInteraction.workspace_id == workspace_id,
AgentInteraction.bot_id == bot_id,
AgentInteraction.conversation_id == conversation_id,
AgentInteraction.actor_id == actor_id,
AgentInteraction.binding_id == binding_id,
AgentInteraction.runner_id == runner_id,
AgentInteraction.processor_type == 'agent',
AgentInteraction.processor_id == processor_id,
)
async with self._session_factory() as session:
duplicate = await session.execute(
sqlalchemy.select(AgentInteraction.id)
.where(
*scope,
AgentInteraction.status == 'submitted',
AgentInteraction.status_reason == source_marker,
)
.limit(1)
)
if duplicate.scalar_one_or_none() is not None:
return {'duplicate_text_reply': True}
result = await session.execute(
sqlalchemy.select(AgentInteraction)
.where(*scope)
.order_by(AgentInteraction.created_at.desc(), AgentInteraction.id.desc())
.limit(1)
)
row = result.scalar_one_or_none()
if row is None or row.status != 'pending' or (row.expires_at and _as_utc(row.expires_at) <= now):
return None
if message_time is not None and float(message_time) < _as_utc(row.created_at).timestamp() - 1:
return None
delivery = _json_loads(row.delivery_result_json, None)
request = _json_loads(row.request_json, {})
fields = request.get('fields') if isinstance(request, dict) else None
if not (isinstance(delivery, dict) and delivery.get('ok') is True and delivery.get('rich') is False):
return None
if not (
isinstance(fields, list)
and len(fields) == 1
and isinstance(fields[0], dict)
and fields[0].get('type') in {'text', 'textarea'}
and fields[0].get('id')
and not request.get('actions')
):
return None
submission = {
'interaction_id': row.interaction_id,
'action_id': None,
'values': {fields[0]['id']: answer},
'submitted_at': int(now.timestamp()),
}
updated = await session.execute(
sqlalchemy.update(AgentInteraction)
.where(AgentInteraction.id == row.id, AgentInteraction.status == 'pending')
.values(
status='submitted',
status_reason=source_marker,
submitted_at=_db_datetime(now),
submission_json=_json_dumps(submission),
updated_at=_db_datetime(now),
)
)
if updated.rowcount != 1:
await session.rollback()
return None
await session.commit()
refreshed = await session.get(AgentInteraction, row.id)
return self._to_dict(refreshed)
async def mark_delivery_failed(self, run_id: str, interaction_id: str, reason: str) -> bool:
"""Mark an undelivered pending interaction terminal."""
now = _utc_now()
@@ -539,6 +539,16 @@ class AgentRunOrchestrator:
auto_reply_deferred = False
explicit_reply_text = None
if auto_reply_enabled and result_type == 'run.completed' and auto_reply_stream_open:
await reply_streams.apply(
ReplyStreamRequest(
stream_id=auto_reply_stream_id,
operation='finish',
text=auto_reply_stream_text,
)
)
auto_reply_stream_open = False
if result is not None:
yield result
+55 -3
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Literal
from uuid import UUID, uuid4
@@ -12,6 +14,9 @@ from langbot_plugin.api.entities.builtin.platform import events, message
from langbot_plugin.api.entities.builtin.provider import message as provider_message
logger = logging.getLogger(__name__)
class ReplyStreamRequest(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra='forbid')
@@ -26,6 +31,8 @@ class _Reply:
text: str = ''
started: bool = False
native: bool = False
interaction_origin: str = ''
last_sent_at: float = 0.0
final_attempted: bool = False
sequence: int = 0
status: str = 'open'
@@ -41,6 +48,11 @@ class ReplyStreamSession:
if not isinstance(self.source, events.MessageEvent):
self.source = None
self.target = dict(event.delivery.reply_target or {})
self.interaction_origin = (
str(self.target.get('message_id') or '')
if getattr(event, 'event_type', None) == 'interaction.submitted'
else ''
)
self.mock = (
event.delivery.surface == 'webui' and (event.delivery.platform_capabilities or {}).get('debug_mock') is True
)
@@ -91,12 +103,24 @@ class ReplyStreamSession:
and callable(getattr(self.adapter, 'create_message_card_for_target', None))
and callable(getattr(self.adapter, 'reply_message_chunk_for_target', None))
)
interaction_streaming = (
self.source is None
and bool(self.interaction_origin)
and callable(getattr(self.adapter, 'begin_interaction_stream', None))
and callable(getattr(self.adapter, 'reply_interaction_stream_chunk', None))
)
reply.native = (
self.source is not None or target_streaming
self.source is not None or target_streaming or interaction_streaming
) and await self.adapter.is_stream_output_supported()
if reply.native:
if self.source is not None:
await self.adapter.create_message_card(reply.delivery_id, self.source)
elif interaction_streaming:
reply.native = await self.adapter.begin_interaction_stream(
self.interaction_origin, self.target
)
if reply.native:
reply.interaction_origin = self.interaction_origin
else:
target_type, target_id = self.target.get('target_type'), self.target.get('target_id')
if not target_type or not target_id:
@@ -105,8 +129,30 @@ class ReplyStreamSession:
reply.delivery_id, str(target_type), str(target_id)
)
if reply.started and reply.native:
await self._update_native(key, reply, final=request.operation != 'update')
elif request.operation == 'finish' and reply.text and not self.mock:
should_refresh = (
request.operation != 'update'
or not reply.interaction_origin
or reply.sequence == 0
or time.monotonic() - reply.last_sent_at >= 0.5
)
if should_refresh:
try:
await self._update_native(key, reply, final=request.operation != 'update')
reply.last_sent_at = time.monotonic()
except Exception:
if not reply.interaction_origin:
raise
logger.warning(
'WeCom interaction stream failed; sending the final reply proactively', exc_info=True
)
reply.native = False
if (
reply.started
and not reply.native
and request.operation == 'finish'
and reply.text
and not self.mock
):
if self.source is not None:
await self.adapter.reply_message(
message_source=self.source, message=self._message(reply.text), quote_origin=False
@@ -154,6 +200,12 @@ class ReplyStreamSession:
quote_origin=False,
is_final=final,
)
elif reply.interaction_origin:
await self.adapter.reply_interaction_stream_chunk(
source_message_id=reply.interaction_origin,
content=reply.text,
is_final=final,
)
else:
await self.adapter.reply_message_chunk_for_target(
message_id=reply.delivery_id, content=reply.text, is_final=final
@@ -18,6 +18,7 @@ from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEvent
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.wecombot.interaction import (
acknowledge_interaction,
interaction_delivery_capabilities,
interaction_event_from_native,
send_interaction,
@@ -123,7 +124,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
'call_platform_api',
]
if not self.config.get('enable-webhook', False) and hasattr(self.bot, 'send_template_card'):
apis.append('interaction.request')
apis.extend(['interaction.request', 'interaction.acknowledge'])
return apis
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
@@ -174,6 +175,9 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
if list(self._iter_media_components(items)):
raise NotSupportedError('send_message:media requires an originating message callback')
raw = await self.bot.send_message(str(target_id), content)
if not isinstance(raw, dict) or raw.get('errcode', 0) != 0:
detail = raw.get('errmsg', raw.get('errcode')) if isinstance(raw, dict) else 'no acknowledgement'
raise RuntimeError(f'WeCom message delivery failed: {detail}')
return platform_events.MessageResult(raw={'result': raw})
async def reply_to_event(
@@ -222,8 +226,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
if not self.config.get('enable-webhook', False) and event.get('req_id'):
if content:
finished = await self.bot.push_stream_chunk(event.message_id, content, is_final=True)
if not finished:
raw = await self.bot.reply_text(event.get('req_id'), content)
if not finished and content.strip(' \t\r\n\u200b\u200c\u200d\ufeff'):
raw = await self.bot.send_message(str(event.get('chatid') or event.get('userid') or ''), content)
for item in self._iter_media_components(items):
await self._send_media(self.bot, event.get('req_id'), item)
else:
@@ -248,8 +252,14 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
content = format_stream_text(self._join_text_components(items))
success = await self.bot.push_stream_chunk(event.message_id, content, is_final=is_final)
if not success and is_final and not self.config.get('enable-webhook', False) and event.get('req_id'):
await self.bot.reply_text(event.get('req_id'), content)
if (
not success
and is_final
and content.strip(' \t\r\n\u200b\u200c\u200d\ufeff')
and not self.config.get('enable-webhook', False)
and event.get('req_id')
):
await self.bot.send_message(str(event.get('chatid') or event.get('userid') or ''), content)
if is_final:
if not self.config.get('enable-webhook', False) and event.get('req_id'):
for item in self._iter_media_components(items):
@@ -261,9 +271,29 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
async def is_stream_output_supported(self) -> bool:
return self.config.get('enable-stream-reply', True)
async def begin_interaction_stream(self, source_message_id: str, target: dict) -> bool:
if self.config.get('enable-webhook', False):
return False
started = self.bot.begin_interaction_stream(
source_message_id,
str(target.get('target_type') or ''),
str(target.get('target_id') or ''),
)
if not started:
await self.logger.warning('WeCom interaction stream context unavailable; buffering final reply')
return started
async def reply_interaction_stream_chunk(self, *, source_message_id: str, content: str, is_final: bool) -> None:
from .stream_text import format_stream_text
if not await self.bot.push_interaction_stream_chunk(source_message_id, format_stream_text(content), is_final):
raise RuntimeError('WeCom rejected the new interaction reply stream')
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis():
return await send_interaction(self, params)
if action == 'interaction.acknowledge' and 'interaction.acknowledge' in self.get_supported_apis():
return await acknowledge_interaction(self, params)
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
@@ -7,7 +7,7 @@ import time
import typing
import uuid
from langbot.libs.wecom_ai_bot_api.api import extract_template_card_event_payload
from langbot.libs.wecom_ai_bot_api.api import extract_template_card_action, extract_template_card_event_payload
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -126,7 +126,59 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
if not isinstance(raw, dict) or raw.get('errcode', 0) != 0:
detail = raw.get('errmsg', raw.get('errcode')) if isinstance(raw, dict) else 'no acknowledgement'
raise RuntimeError(f'WeCom template card delivery failed: {detail}')
return {'ok': True, 'message_id': None, 'rich': True, 'raw': raw}
source_message_id = str(reply_target.get('message_id') or '')
if source_message_id and hasattr(adapter.bot, 'finish_stream_for_interaction'):
await adapter.bot.finish_stream_for_interaction(source_message_id)
return {
'ok': True,
'message_id': None,
'rich': True,
'task_id': card['template_card']['task_id'],
'raw': raw,
}
async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
submission = params.get('submission')
target = params.get('update_target')
callback = params.get('callback_context')
if not all(isinstance(value, dict) for value in (request, submission, target, callback)):
raise ValueError('interaction.acknowledge requires request, submission, update_target, and callback_context')
task_id = str(callback.get('task_id') or '')
req_id = str(callback.get('req_id') or '')
if not req_id or not task_id or task_id != target.get('task_id') or target.get('rich') is not True:
raise ValueError('WeCom interaction callback does not match the delivered card')
card = build_interaction_card(request, 'submitted')
if card is None:
raise ValueError('WeCom interaction has no card to update')
updated = card['template_card']
updated['task_id'] = task_id
action_id = submission.get('action_id')
if updated['card_type'] == 'button_interaction':
for index, action in enumerate((request.get('actions') or [])[:6]):
updated['button_list'][index]['key'] = 'submitted'
if isinstance(action, dict) and action.get('id') == action_id:
button = updated['button_list'][index]
button['text'] = f'✓ {button["text"]}'
button['replace_text'] = button['text']
break
else:
field = request['fields'][0]
selected = (submission.get('values') or {}).get(field['id'])
for index, option in enumerate(field.get('options') or []):
if option.get('value') == selected:
updated['select_list'][0]['selected_id'] = f'opt_{index + 1}'
break
updated['select_list'][0]['disable'] = True
updated['submit_button']['text'] = '✓ Submitted'
updated['submit_button']['key'] = 'submitted'
raw = await adapter.bot.update_template_card(req_id, updated)
if not isinstance(raw, dict) or raw.get('errcode', 0) != 0:
detail = raw.get('errmsg', raw.get('errcode')) if isinstance(raw, dict) else 'no acknowledgement'
raise RuntimeError(f'WeCom template card update failed: {detail}')
return {**target, 'updated': True, 'raw': raw}
def _template_card_event(event: WecomBotEvent) -> dict[str, typing.Any]:
@@ -227,6 +279,7 @@ def interaction_event_from_native(
if not callback_key.startswith('lbi:'):
return None
parsed = parse_callback_key(callback_key)
task_id, _, _ = extract_template_card_action(_template_card_event(event))
if parsed.pop('select_submit', False):
parsed['field_ref'] = 0
parsed['option_ref'] = _selected_option_ref(_template_card_event(event))
@@ -242,6 +295,8 @@ def interaction_event_from_native(
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
'callback_req_id': str(event.get('req_id') or ''),
'callback_task_id': task_id,
},
timestamp=time.time(),
source_platform_object=event,
@@ -191,6 +191,7 @@ spec:
- get_group_member_list
- call_platform_api
- interaction.request
- interaction.acknowledge
platform_specific_apis:
- action: is_websocket_mode
+67 -3
View File
@@ -1304,6 +1304,58 @@ class RuntimeBot:
try:
with trace_mod.scope(route_ref=self._route_ref(event_binding, target_type, target_uuid)):
if (
target_type == 'agent'
and isinstance(event, platform_events.MessageReceivedEvent)
and event.adapter_name == 'wecombot-omni'
and event.message_chain
and all(
isinstance(
component,
(
platform_message.Source,
platform_message.Plain,
platform_message.At,
platform_message.AtAll,
),
)
for component in event.message_chain
)
):
answer = ''.join(
component.text
for component in event.message_chain
if isinstance(component, platform_message.Plain)
)
record = await self.ap.agent_run_orchestrator.interaction_manager.store.consume_text_reply(
workspace_id=self.execution_context.workspace_uuid,
bot_id=self.bot_entity.uuid,
conversation_id=envelope.conversation_id or '',
actor_id=envelope.actor.actor_id if envelope.actor else '',
binding_id=binding.binding_id,
runner_id=binding.runner_id,
processor_id=target_uuid,
text=answer,
message_id=str(event.message_id or self._extract_message_id(event.message_chain)),
message_time=event.timestamp,
)
if record is not None:
if not record.get('duplicate_text_reply'):
await self._resume_agent_interaction(
record,
event,
adapter,
envelope.actor.actor_id if envelope.actor else None,
execution_id,
)
return await self._record_event_route_trace(
event_type=event_type,
status='delivered',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
text=f'Event {event_type} handled text interaction reply',
)
from .processing_indicator import processing_indicator
reply_type, reply_id = (None, None)
@@ -1535,7 +1587,15 @@ class RuntimeBot:
# Only a validated callback resolving an original processor is eligible.
# Admit before acknowledgement and target lookup to preserve failures.
trace_mod.admit()
await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission(record, adapter)
callback_context = None
if getattr(event, 'adapter_name', None) == 'wecombot-omni':
callback_context = {
'req_id': data.get('callback_req_id'),
'task_id': data.get('callback_task_id'),
}
await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission(
record, adapter, callback_context
)
if record['processor_type'] == 'agent':
await self._resume_agent_interaction(record, event, adapter, actor_id, execution_id)
@@ -1615,7 +1675,7 @@ class RuntimeBot:
async def _resume_agent_interaction(
self,
record: dict[str, typing.Any],
event: platform_events.PlatformSpecificEvent,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
actor_id: str | None,
execution_id: str | None = None,
@@ -1639,9 +1699,13 @@ class RuntimeBot:
binding.binding_id = record['binding_id']
submission = record['submission']
event_data = getattr(event, 'data', None)
values = (submission or {}).get('values') or {}
text_answer = next(iter(values.values()), None) if len(values) == 1 else None
display_text = str(
(submission or {}).get('action_id')
or (event.data if isinstance(event.data, dict) else {}).get('display_text')
or (event_data if isinstance(event_data, dict) else {}).get('display_text')
or text_answer
or 'submitted'
)
input_data: dict[str, typing.Any] = {
@@ -190,6 +190,63 @@ async def test_structured_delivery_uses_frozen_target_and_persists_request(store
assert record['delivery_result']['message_id'] == 'message-1'
@pytest.mark.asyncio
async def test_text_reply_consumes_latest_scoped_plain_form_once(store):
async def create(interaction_id, *, field_type='textarea', rich=False):
await store.create_request(
interaction_id=interaction_id,
run_id=f'run-{interaction_id}',
binding_id='binding-1',
runner_id='runner-1',
processor_type='agent',
processor_id='agent-1',
request={
'fields': [{'id': 'question', 'type': field_type}],
'actions': [],
},
delivery_target={'target_type': 'person', 'target_id': 'user-1'},
workspace_id='workspace-1',
bot_id='bot-1',
conversation_id='person_user-1',
actor_id='user-1',
expires_at=time.time() + 600,
)
await store.record_delivery_success(f'run-{interaction_id}', interaction_id, {'ok': True, 'rich': rich})
async def reply(**overrides):
scope = dict(
workspace_id='workspace-1',
bot_id='bot-1',
conversation_id='person_user-1',
actor_id='user-1',
binding_id='binding-1',
runner_id='runner-1',
processor_id='agent-1',
text=' answer ',
message_id='message-1',
)
scope.update(overrides)
return await store.consume_text_reply(**scope)
await create('old')
await create('new')
assert await reply(actor_id='user-2') is None
assert await reply(conversation_id='person_user-2') is None
assert await reply(workspace_id='workspace-2') is None
assert await reply(text=' ') is None
submitted = await reply()
assert submitted['interaction_id'] == 'new'
assert submitted['submission']['values'] == {'question': 'answer'}
assert (await reply())['duplicate_text_reply'] is True
assert await reply(message_id='message-2') is None
assert (await store.get_request('run-old', 'old'))['status'] == 'pending'
await create('rich', rich=True)
assert await reply(message_id='message-3') is None
await create('select', field_type='select')
assert await reply(message_id='message-4') is None
@pytest.mark.asyncio
async def test_agent_tool_card_uses_host_token_and_persists_resume_target(store, event):
adapter = FakeAdapter(supports_interactions=True)
@@ -1271,7 +1271,10 @@ class TestQueryEntrySessionQueryId:
@pytest.mark.asyncio
@pytest.mark.parametrize('event_type', ['message.received', 'interaction.submitted'])
async def test_direct_agent_streams_output_to_the_originating_platform(clean_agent_state, event_type):
@pytest.mark.parametrize('completed_message', [False, True])
async def test_direct_agent_streams_output_to_the_originating_platform(
clean_agent_state, event_type, completed_message
):
from langbot.pkg.agent.runner.host_models import (
AgentBinding,
AgentEventEnvelope,
@@ -1283,25 +1286,27 @@ async def test_direct_agent_streams_output_to_the_originating_platform(clean_age
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
connector = FakePluginConnector(
results=[
{
'type': 'message.delta',
'data': {
'chunk': {
'role': 'assistant',
'content': 'hello',
'all_content': 'hello',
}
},
runner_results = [
{
'type': 'message.delta',
'data': {
'chunk': {
'role': 'assistant',
'content': 'hello',
'all_content': 'hello',
}
},
},
]
if completed_message:
runner_results.append(
{
'type': 'message.completed',
'data': {'message': {'role': 'assistant', 'content': 'hello world'}},
},
{'type': 'run.completed', 'data': {'finish_reason': 'stop'}},
]
)
}
)
runner_results.append({'type': 'run.completed', 'data': {'finish_reason': 'stop'}})
connector = FakePluginConnector(results=runner_results)
ap = FakeApplication(connector, clean_agent_state)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(make_descriptor()))
adapter = types.SimpleNamespace(
@@ -1358,11 +1363,13 @@ async def test_direct_agent_streams_output_to_the_originating_platform(clean_age
)
]
assert [item.content for item in results] == ['hello', 'hello world']
assert [item.content for item in results] == (['hello', 'hello world'] if completed_message else ['hello'])
if event_type == 'message.received':
adapter.create_message_card.assert_awaited_once()
chunks = adapter.reply_message_chunk.await_args_list
assert [call.kwargs['bot_message'].all_content for call in chunks] == ['hello', 'hello world']
assert [call.kwargs['bot_message'].all_content for call in chunks] == (
['hello', 'hello world'] if completed_message else ['hello', 'hello']
)
assert [call.kwargs['is_final'] for call in chunks] == [False, True]
adapter.reply_message.assert_not_awaited()
adapter.send_message.assert_not_awaited()
@@ -1371,7 +1378,7 @@ async def test_direct_agent_streams_output_to_the_originating_platform(clean_age
adapter.reply_message_chunk.assert_not_awaited()
adapter.reply_message.assert_not_awaited()
adapter.send_message.assert_awaited_once()
assert adapter.send_message.await_args.args[2][0].text == 'hello world'
assert adapter.send_message.await_args.args[2][0].text == ('hello world' if completed_message else 'hello')
@pytest.mark.asyncio
@@ -142,6 +142,97 @@ async def test_target_streaming_adapter_updates_card_without_source_message():
await session.close()
async def test_interaction_callback_buffers_one_final_message_after_card_update():
event = SimpleNamespace(
event_type='interaction.submitted',
delivery=SimpleNamespace(
surface='platform',
platform_capabilities={},
reply_target={'target_type': 'person', 'target_id': 'user-1'},
),
)
source = events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='wecombot-omni',
action='interaction.submitted',
data={'callback_req_id': 'callback-1'},
)
adapter = SimpleNamespace(
is_stream_output_supported=AsyncMock(return_value=True),
send_message=AsyncMock(),
)
session = ReplyStreamSession(event, adapter, source)
key = uuid4()
await session.apply(request(key, text='partial'))
result = await session.apply(request(key, 'finish', 'complete'))
assert result['delivery'] == 'buffered'
adapter.send_message.assert_awaited_once()
assert adapter.send_message.await_args.args[2][0].text == 'complete'
await session.close()
async def test_interaction_reply_is_sent_after_card_as_new_message():
event = SimpleNamespace(
event_type='interaction.submitted',
delivery=SimpleNamespace(
surface='platform',
platform_capabilities={},
reply_target={'target_type': 'person', 'target_id': 'user-1', 'message_id': 'original-1'},
),
)
adapter = SimpleNamespace(
is_stream_output_supported=AsyncMock(return_value=True),
send_message=AsyncMock(),
)
session = ReplyStreamSession(event, adapter, source=None)
key = uuid4()
await session.apply(request(key, text='partial'))
result = await session.apply(request(key, 'finish', 'complete'))
assert result['delivery'] == 'buffered'
adapter.send_message.assert_awaited_once()
assert adapter.send_message.await_args.args[2][0].text == 'complete'
await session.close()
@pytest.mark.parametrize('stream_fails', [False, True])
async def test_interaction_uses_new_stream_with_buffered_fallback(stream_fails):
event = SimpleNamespace(
event_type='interaction.submitted',
delivery=SimpleNamespace(
surface='platform',
platform_capabilities={},
reply_target={'target_type': 'person', 'target_id': 'user-1', 'message_id': 'original-1'},
),
)
adapter = SimpleNamespace(
is_stream_output_supported=AsyncMock(return_value=True),
begin_interaction_stream=AsyncMock(return_value=True),
reply_interaction_stream_chunk=AsyncMock(side_effect=RuntimeError('invalid req_id') if stream_fails else None),
send_message=AsyncMock(),
)
session = ReplyStreamSession(event, adapter)
key = uuid4()
await session.apply(request(key, text='part'))
result = await session.apply(request(key, 'finish', 'part done'))
adapter.begin_interaction_stream.assert_awaited_once_with('original-1', event.delivery.reply_target)
if stream_fails:
assert result['delivery'] == 'buffered'
adapter.send_message.assert_awaited_once()
assert adapter.send_message.await_args.args[2][0].text == 'part done'
else:
assert result['delivery'] == 'streaming'
assert [call.kwargs['content'] for call in adapter.reply_interaction_stream_chunk.await_args_list] == [
'part',
'part done',
]
assert adapter.reply_interaction_stream_chunk.await_args.kwargs['is_final'] is True
adapter.send_message.assert_not_awaited()
await session.close()
@pytest.mark.parametrize('native', [True, False])
@pytest.mark.parametrize('error', [RuntimeError, asyncio.CancelledError])
async def test_exception_finalizes_visible_card_without_sending_buffered_partial(native, error):
@@ -589,6 +589,21 @@ class TestInteractionResumeRouting:
actor_id='user-1',
)
@pytest.mark.asyncio
async def test_wecom_callback_passes_native_card_update_context(self):
record = self._record('pipeline')
bot = self._make_bot(record)
adapter = SimpleNamespace()
event = self._event()
event.adapter_name = 'wecombot-omni'
event.data.update({'callback_req_id': 'callback-req', 'callback_task_id': 'task-1'})
await bot._handle_interaction_submission(event, adapter)
bot.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission.assert_awaited_once_with(
record, adapter, {'req_id': 'callback-req', 'task_id': 'task-1'}
)
@pytest.mark.asyncio
async def test_callback_forwards_compact_platform_references(self):
bot = self._make_bot(self._record('pipeline'))
@@ -676,6 +691,27 @@ class TestInteractionResumeRouting:
assert binding.delivery_policy.enable_reply is True
adapter.send_message.assert_not_awaited()
from langbot_plugin.api.entities.builtin.platform import entities, events, message
text_record = self._record('agent')
text_record['submission'] = {
'interaction_id': 'form-1',
'action_id': None,
'values': {'name': 'My question'},
'submitted_at': 1235,
}
text_event = events.MessageReceivedEvent(
timestamp=1235,
message_id='message-2',
message_chain=message.MessageChain([message.Plain(text='My question')]),
sender=entities.User(id='user-1'),
chat_type=entities.ChatType.GROUP,
chat_id='chat-1',
)
await bot._resume_agent_interaction(text_record, text_event, adapter, 'user-1')
assert captured[1][0].input.text == 'My question'
assert captured[1][0].input.interaction.values == {'name': 'My question'}
def test_agent_product_to_binding_does_not_fallback_to_component_ref(self):
"""An empty config runner stays unconfigured even if component_ref is stale."""
from langbot.pkg.platform.botmgr import RuntimeBot
@@ -782,6 +818,53 @@ async def test_bound_event_processor_receives_one_complete_typed_event():
bot.ap.plugin_connector.emit_event.assert_not_called()
@pytest.mark.asyncio
async def test_wecom_plain_reply_resumes_pending_agent_interaction():
from datetime import datetime
from langbot_plugin.api.entities.builtin.platform import entities, events, message
bot = TestEventRouteTrace._make_bot(
[{'id': 'binding-1', 'event_pattern': 'message.received', 'target_type': 'agent', 'target_uuid': 'agent-1'}]
)
run = Mock()
consume = AsyncMock(return_value={'interaction_id': 'form-1'})
bot.ap = SimpleNamespace(
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': 'agent',
'supported_event_patterns': ['message.received'],
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
}
)
),
agent_run_orchestrator=SimpleNamespace(
run=run, interaction_manager=SimpleNamespace(store=SimpleNamespace(consume_text_reply=consume))
),
)
bot._resume_agent_interaction = AsyncMock()
event = events.MessageReceivedEvent(
adapter_name='wecombot-omni',
message_id='message-1',
message_chain=message.MessageChain(
[message.Source(id='message-1', time=datetime.now()), message.Plain(text='answer')]
),
sender=entities.User(id='user-1'),
chat_type=entities.ChatType.PRIVATE,
chat_id='user-1',
)
result = await bot._dispatch_eba_event_to_processor(event, SimpleNamespace(get_supported_apis=lambda: []))
assert result['status'] == 'delivered'
assert consume.await_args.kwargs['text'] == 'answer'
assert consume.await_args.kwargs['conversation_id'] == 'person_user-1'
bot._resume_agent_interaction.assert_awaited_once()
run.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['agent', 'event_processor'])
@pytest.mark.parametrize('output_kind', ['message', 'chunks', 'tool_rounds'])
@@ -1,7 +1,7 @@
from __future__ import annotations
import pathlib
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
import yaml
@@ -48,8 +48,12 @@ class DummyWecomBotWsClient:
self.send_message = AsyncMock(return_value={'ok': True})
self.reply_text = AsyncMock(return_value={'reply': True})
self.push_stream_chunk = AsyncMock(return_value=True)
self.finish_stream_for_interaction = AsyncMock(return_value=True)
self.begin_interaction_stream = Mock(return_value=True)
self.push_interaction_stream_chunk = AsyncMock(return_value=True)
self.set_message = AsyncMock(return_value={'set': True})
self.send_template_card = AsyncMock(return_value={'sent': True})
self.update_template_card = AsyncMock(return_value={'errcode': 0})
def on_message(self, msg_type: str):
def decorator(func):
@@ -109,6 +113,30 @@ def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter:
return WecomBotAdapter(config, DummyLogger())
@pytest.mark.asyncio
async def test_interaction_card_finishes_original_loading_stream():
adapter = make_adapter()
await adapter.call_platform_api(
'interaction.request',
{
'request': {'title': 'Continue', 'actions': [{'id': 'ok', 'label': 'OK'}]},
'reply_target': {'target_type': 'person', 'target_id': 'user-1', 'message_id': 'original-1'},
'callback_token': 'token-1',
},
)
adapter.bot.finish_stream_for_interaction.assert_awaited_once_with('original-1')
@pytest.mark.asyncio
async def test_interaction_reply_uses_new_wecom_stream():
adapter = make_adapter()
target = {'target_type': 'person', 'target_id': 'user-1', 'message_id': 'original-1'}
assert await adapter.begin_interaction_stream('original-1', target)
adapter.bot.begin_interaction_stream.assert_called_once_with('original-1', 'person', 'user-1')
await adapter.reply_interaction_stream_chunk(source_message_id='original-1', content='part', is_final=False)
adapter.bot.push_interaction_stream_chunk.assert_awaited_once_with('original-1', 'part', False)
def wecombot_event(**overrides) -> WecomBotEvent:
event_type = overrides.get('type', 'single')
payload = {
@@ -216,6 +244,7 @@ async def test_wecombot_select_card_submits_native_dropdown_value():
'eventtype': 'template_card_event',
'template_card_event': {
'card_type': 'multiple_interaction',
'task_id': result['task_id'],
'event_key': card['submit_button']['key'],
'selected_items': {
'selected_item': [
@@ -226,6 +255,8 @@ async def test_wecombot_select_card_submits_native_dropdown_value():
}
event = interaction_event_from_native(native)
assert event.data['callback_token'] == 'callback-token'
assert event.data['callback_req_id'] == 'req-1'
assert event.data['callback_task_id'] == result['task_id']
resolved = InteractionManager._resolve_submission_refs(
{'interaction_id': request['interaction_id'], 'request': request}, event.data
)
@@ -238,6 +269,70 @@ async def test_wecombot_select_card_submits_native_dropdown_value():
assert listener.await_args.args[0].data['option_ref'] == 1
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['button', 'select'])
async def test_wecombot_acknowledges_submitted_card(kind):
adapter = make_adapter()
request = {'title': 'Choose', 'actions': [{'id': 'yes', 'label': 'Yes'}, {'id': 'no', 'label': 'No'}]}
submission = {'action_id': 'no', 'values': {}}
if kind == 'select':
request = {
'title': 'Choose',
'actions': [],
'fields': [
{
'id': 'choice',
'type': 'select',
'label': 'Choice',
'options': [{'value': 'a', 'label': 'A'}, {'value': 'b', 'label': 'B'}],
}
],
}
submission = {'action_id': None, 'values': {'choice': 'b'}}
delivered = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'token-1',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'request': request,
},
)
result = await adapter.call_platform_api(
'interaction.acknowledge',
{
'request': request,
'submission': submission,
'update_target': delivered,
'callback_context': {'req_id': 'callback-req', 'task_id': delivered['task_id']},
},
)
assert result['updated'] is True
req_id, card = adapter.bot.update_template_card.await_args.args
assert req_id == 'callback-req'
assert card['task_id'] == delivered['task_id']
if kind == 'select':
assert card['select_list'][0]['selected_id'] == 'opt_2'
assert card['select_list'][0]['disable'] is True
assert card['submit_button']['key'] == 'submitted'
else:
assert card['button_list'][1]['replace_text'].endswith('No')
assert all(button['key'] == 'submitted' for button in card['button_list'])
with pytest.raises(ValueError, match='does not match'):
await adapter.call_platform_api(
'interaction.acknowledge',
{
'request': request,
'submission': submission,
'update_target': delivered,
'callback_context': {'req_id': 'callback-req', 'task_id': 'another-card'},
},
)
assert adapter.bot.update_template_card.await_count == 1
def test_wecombot_select_rejects_more_than_ten_options():
with pytest.raises(ValueError, match='at most 10 options'):
build_interaction_card(
@@ -475,6 +570,29 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie
assert feedback_calls[0].inaccurate_reasons == ['1', '2']
@pytest.mark.asyncio
async def test_wecombot_expired_stream_falls_back_to_proactive_reply():
adapter = make_adapter()
adapter.bot.push_stream_chunk.return_value = False
source_event = await WecomBotEventConverter().target2yiri(wecombot_event())
message = platform_message.MessageChain([platform_message.Plain(text='late answer')])
await adapter.reply_message_chunk(source_event, None, message, is_final=True)
adapter.bot.send_message.assert_awaited_once_with('user-1', 'late answer')
adapter.bot.reply_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_wecombot_send_message_rejects_failed_ack():
adapter = make_adapter()
adapter.bot.send_message.return_value = {'errcode': 400, 'errmsg': 'invalid message'}
with pytest.raises(RuntimeError, match='invalid message'):
await adapter.send_message(
'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hello')])
)
@pytest.mark.asyncio
async def test_wecombot_webhook_mode_rejects_proactive_send():
adapter = make_adapter(enable_webhook=True)
@@ -1,14 +1,10 @@
import sys
import time
import types
from unittest.mock import Mock
from unittest.mock import AsyncMock, Mock
import pytest
logger_module = types.ModuleType('langbot.pkg.platform.logger')
logger_module.EventLogger = object
sys.modules.setdefault('langbot.pkg.platform.logger', logger_module)
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
WecomBotClient,
build_button_interaction_payload,
@@ -45,7 +41,7 @@ def test_webhook_dispatch_tasks_are_bounded():
@pytest.mark.asyncio
async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
async def test_ws_message_waits_for_real_content_before_creating_stream(monkeypatch):
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
order = []
@@ -61,9 +57,7 @@ async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
async def reply_stream(*args, **kwargs):
del args, kwargs
order.append('initial-frame')
return {}
raise AssertionError('An empty stream must not be created')
async def dispatch_event(event):
del event
@@ -76,11 +70,126 @@ async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
assert order == ['initial-frame', 'pipeline-dispatch']
assert order == ['pipeline-dispatch']
assert client._stream_sessions['msg-1']['stream_started'] is False
@pytest.mark.asyncio
async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
async def test_ws_interaction_releases_unused_stream_without_visible_message():
client = WecomBotWsClient('bot-id', 'secret', object())
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {'user_id': 'user-1'}
sent = []
async def reply_stream(req_id, stream_id, content, finish=False):
sent.append((req_id, stream_id, content, finish))
return {'errcode': 0}
client.reply_stream = reply_stream
assert await client.finish_stream_for_interaction('msg-1')
assert sent == []
assert 'msg-1' not in client._stream_ids
assert 'msg-1' not in client._stream_sessions
@pytest.mark.asyncio
async def test_ws_interaction_starts_a_new_stream_after_card():
client = WecomBotWsClient('bot-id', 'secret', types.SimpleNamespace(info=AsyncMock()))
client._stream_ids['msg-1'] = 'req-1|old-stream'
client._stream_sessions['msg-1'] = {
'req_id': 'req-1',
'user_id': 'user-1',
'chat_id': '',
'created_at': time.monotonic(),
'stream_started': False,
}
sent = []
async def reply_stream(req_id, stream_id, content, finish=False, feedback_id=''):
sent.append((req_id, stream_id, content, finish))
return {'errcode': 0}
client.reply_stream = reply_stream
assert await client.finish_stream_for_interaction('msg-1')
assert sent == []
assert client.begin_interaction_stream('msg-1', 'person', 'user-1')
assert not client.begin_interaction_stream('msg-1', 'person', 'user-1')
assert await client.push_interaction_stream_chunk('msg-1', 'part', False)
assert await client.push_interaction_stream_chunk('msg-1', 'part done', True)
assert [item[2:] for item in sent] == [('part', False), ('part done', True)]
assert sent[0][0] == sent[1][0] == 'req-1'
assert sent[0][1] == sent[1][1]
assert sent[0][1] != 'old-stream'
def test_ws_interaction_stream_checks_target_and_expiry():
client = WecomBotWsClient('bot-id', 'secret', object())
client._interaction_stream_origins['msg-1'] = {
'req_id': 'req-1',
'user_id': 'user-1',
'chat_id': 'group-1',
'created_at': time.monotonic(),
}
assert not client.begin_interaction_stream('msg-1', 'person', 'other-user')
assert not client.begin_interaction_stream('msg-1', 'group', 'other-group')
client._interaction_stream_origins['msg-1']['created_at'] -= 571
assert not client.begin_interaction_stream('msg-1', 'person', 'user-1')
assert 'msg-1' not in client._interaction_stream_origins
@pytest.mark.asyncio
async def test_ws_interaction_preserves_existing_stream_text_when_finishing():
client = WecomBotWsClient('bot-id', 'secret', object())
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {'stream_started': True}
client._stream_last_content['msg-1'] = 'Earlier answer'
sent = []
async def reply_stream(req_id, stream_id, content, finish=False):
sent.append((content, finish))
return {'errcode': 0}
client.reply_stream = reply_stream
assert await client.finish_stream_for_interaction('msg-1')
assert sent == [('Earlier answer', True)]
@pytest.mark.asyncio
async def test_ws_interaction_finish_failure_keeps_stream_state():
logger = types.SimpleNamespace(warning=AsyncMock())
client = WecomBotWsClient('bot-id', 'secret', logger)
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {'stream_started': True}
async def reject(*args, **kwargs):
raise ConnectionError('disconnected')
client.reply_stream = reject
assert not await client.finish_stream_for_interaction('msg-1')
assert client._stream_ids['msg-1'] == 'req-1|stream-1'
@pytest.mark.asyncio
async def test_ws_rejected_stream_refresh_does_not_complete_session():
logger = types.SimpleNamespace(error=Mock())
client = WecomBotWsClient('bot-id', 'secret', logger)
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {'user_id': 'user-1'}
async def reject(*args, **kwargs):
return {'errcode': 400, 'errmsg': 'invalid req_id'}
async def record_error(message):
assert 'invalid req_id' in message
client.reply_stream = reject
logger.error = record_error
assert not await client.push_stream_chunk('msg-1', 'answer', is_final=True)
assert 'msg-1' in client._stream_ids
@pytest.mark.asyncio
async def test_ws_stream_starts_with_first_real_content(monkeypatch):
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
dispatched = []
@@ -105,9 +214,11 @@ async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
del args, kwargs
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
async def reply_stream(*args, **kwargs):
del args, kwargs
raise ConnectionError('simulated reply failure')
sent = []
async def reply_stream(req_id, stream_id, content, finish=False, feedback_id=''):
sent.append((req_id, content, finish))
return {'errcode': 0}
async def dispatch_event(event):
dispatched.append(event)
@@ -118,10 +229,17 @@ async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
client._dispatch_event = dispatch_event
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
assert sent == []
assert await client.push_stream_chunk('msg-1', 'hello')
assert len(dispatched) == 1
assert len(logger.warnings) == 1
assert 'simulated reply failure' in logger.warnings[0]
assert sent == [('req-1', 'hello', False)]
assert client._stream_sessions['msg-1']['stream_started'] is True
assert logger.warnings == []
assert await client.push_stream_chunk('msg-1', 'hello', is_final=True)
assert 'msg-1' not in client._stream_ids
assert client.begin_interaction_stream('msg-1', 'person', 'user-1')
def test_extract_template_card_action_supports_nested_button_key():