Compare commits

..

1 Commits

Author SHA1 Message Date
huanghuoguoguo decdddde60 feat(box): support explicit host backend 2026-09-02 21:07:33 +08:00
28 changed files with 73 additions and 1433 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.9" version = "4.10.8"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.6", "langbot-plugin==0.5.5",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
+2 -3
View File
@@ -697,10 +697,9 @@ class DingTalkClient:
if not await self.check_access_token(): if not await self.check_access_token():
await self.get_access_token() await self.get_access_token()
template_params = dict(card_param_map or {}) cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
if card_data_config is not None: if card_data_config is not None:
template_params['config'] = card_data_config cardData['config'] = json.dumps(card_data_config)
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
body: dict = { body: dict = {
'cardTemplateId': card_template_id, 'cardTemplateId': card_template_id,
@@ -218,7 +218,6 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids = quart.request.args.getlist('pipelineId') pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime') start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime') end_time_str = quart.request.args.get('endTime')
user_query = quart.request.args.get('userQuery')
is_active_str = quart.request.args.get('isActive') is_active_str = quart.request.args.get('isActive')
limit = int(quart.request.args.get('limit', 100)) limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0)) offset = int(quart.request.args.get('offset', 0))
@@ -238,7 +237,6 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids=pipeline_ids if pipeline_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
user_query=user_query,
is_active=is_active, is_active=is_active,
limit=limit, limit=limit,
offset=offset, offset=offset,
@@ -398,14 +396,7 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW) @self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str: async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session""" """Get detailed analysis for a specific session"""
start_time = parse_iso_datetime(quart.request.args.get('startTime')) analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
analysis = await self.ap.monitoring_service.get_session_analysis(
request_context,
session_id,
start_time=start_time,
end_time=end_time,
)
# Always return success with the analysis data # Always return success with the analysis data
# The frontend will handle the 'found: false' case # The frontend will handle the 'found: false' case
@@ -186,9 +186,6 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json json_data = await quart.request.json
code = json_data.get('code') code = json_data.get('code')
state = json_data.get('state') state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback'
)
launch_assertion = json_data.get('launch_assertion') launch_assertion = json_data.get('launch_assertion')
workspace_uuid = json_data.get('workspace_uuid') workspace_uuid = json_data.get('workspace_uuid')
@@ -202,11 +199,8 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing authorization code') return self.fail(1, 'Missing authorization code')
if not state: if not state:
return self.fail(1, 'Missing state parameter') return self.fail(1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.fail(1, 'Unsupported Space OAuth code contract')
try: try:
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login') consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens # Exchange code for tokens
launch_workspace_uuid = consumed_state.launch_workspace_uuid launch_workspace_uuid = consumed_state.launch_workspace_uuid
@@ -224,36 +218,24 @@ class UserRouterGroup(group.RouterGroup):
code, code,
workspace_uuids, workspace_uuids,
workspace_created_ats, workspace_created_ats,
redirect_uri=redirect_uri,
) )
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
if not access_token: if not access_token:
return self.fail(1, 'Failed to get access token from Space') return self.fail(1, 'Failed to get access token from Space')
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' # Authenticate and create/update local user
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
return self.fail(1, 'Space OAuth Workspace binding mismatch')
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
if cloud_mode:
if not target_workspace_uuid:
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
# Authenticate only after the signed, exact Workspace delta has
# established the Account and membership runtime shadow rows.
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user( jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in access_token, refresh_token, expires_in
) )
if target_workspace_uuid: if launch_workspace_uuid:
try: try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace( access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid, user_obj.uuid,
target_workspace_uuid, launch_workspace_uuid,
) )
except Exception: except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace') self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
@@ -385,17 +367,12 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json json_data = await quart.request.json
code = json_data.get('code') code = json_data.get('code')
state = json_data.get('state') state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
)
if not code: if not code:
return self.http_status(400, -1, 'Missing authorization code') return self.http_status(400, -1, 'Missing authorization code')
if not state: if not state:
return self.http_status(400, -1, 'Missing state parameter') return self.http_status(400, -1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
try: try:
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind') user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
@@ -408,10 +385,7 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space') return self.http_status(400, -1, 'Only local accounts can bind to Space')
try: try:
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True) updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
updated_user = await self.ap.user_service.bind_space_account(
user_obj.user, code, redirect_uri=redirect_uri
)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user) jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success( return self.success(
data={ data={
@@ -454,10 +428,6 @@ class UserRouterGroup(group.RouterGroup):
} }
) )
projection_service = self.ap.directory_projection_service
if projection_service is None:
raise SpaceLaunchError('Cloud directory projection is unavailable')
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is None: if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core') raise SpaceLaunchError('Launch Account is not projected into Core')
+4 -20
View File
@@ -1257,7 +1257,6 @@ class MonitoringService:
pipeline_ids: list[str] | None = None, pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None, start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None,
user_query: str | None = None,
is_active: bool | None = None, is_active: bool | None = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
@@ -1275,14 +1274,6 @@ class MonitoringService:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time: if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
if user_query and user_query.strip():
user_pattern = f'%{user_query.strip()}%'
conditions.append(
sqlalchemy.or_(
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
)
)
if is_active is not None: if is_active is not None:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active) conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
@@ -1374,8 +1365,6 @@ class MonitoringService:
self, self,
context: TenantContext, context: TenantContext,
session_id: str, session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict: ) -> dict:
"""Get bounded session details with full statistics computed in SQL.""" """Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context) workspace_uuid = require_workspace_uuid(context)
@@ -1489,17 +1478,12 @@ class MonitoringService:
) )
) )
tool_stats = tool_stats_result.one() tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
]
if start_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
tool_query = ( tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall) sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(*tool_conditions) .where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
.limit(detail_limit + 1) .limit(detail_limit + 1)
) )
+1 -4
View File
@@ -119,7 +119,7 @@ class SpaceService:
space_config = self._get_space_config() space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url'] authorize_url = space_config['oauth_authorize_url']
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'} params = {'redirect_uri': redirect_uri}
if state: if state:
params['state'] = state params['state'] = state
return f'{authorize_url}?{urlencode(params)}' return f'{authorize_url}?{urlencode(params)}'
@@ -129,8 +129,6 @@ class SpaceService:
code: str, code: str,
workspace_uuids: list[str] | None = None, workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None, workspace_created_ats: dict[str, int] | None = None,
*,
redirect_uri: str = '',
) -> typing.Dict: ) -> typing.Dict:
"""Exchange OAuth authorization code for tokens""" """Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants from langbot.pkg.utils import constants
@@ -143,7 +141,6 @@ class SpaceService:
f'{space_url}/api/v1/accounts/oauth/token', f'{space_url}/api/v1/accounts/oauth/token',
json={ json={
'code': code, 'code': code,
'redirect_uri': redirect_uri,
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to # Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding. # synthesize a legacy instance-derived Workspace binding.
+2 -3
View File
@@ -774,7 +774,7 @@ class UserService:
f'email:{normalized_email}', f'email:{normalized_email}',
) )
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User: async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account""" """Bind Space account to existing local account"""
local_account = await self.get_user_by_email(user_email) local_account = await self.get_user_by_email(user_email)
if local_account is None: if local_account is None:
@@ -794,13 +794,12 @@ class UserService:
code, code,
[binding.workspace_uuid], [binding.workspace_uuid],
{binding.workspace_uuid: created_ts}, {binding.workspace_uuid: created_ts},
redirect_uri=redirect_uri,
) )
else: else:
# Compatibility for early/bootstrap call sites that have not wired # Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy # WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted. # Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri) token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
+2 -100
View File
@@ -125,21 +125,10 @@ class DirectoryProjectionService:
# The database cursor remains the shared projection high-water mark, # The database cursor remains the shared projection high-water mark,
# while this cursor tracks what this process has actually observed. # while this cursor tracks what this process has actually observed.
self._consumer_cursor: int | None = None self._consumer_cursor: int | None = None
self._sync_lock = asyncio.Lock()
async def initialize(self) -> None: async def initialize(self) -> None:
"""Block Cloud startup until one full signed snapshot is committed.""" """Block Cloud startup until one full signed snapshot is committed."""
async with self._sync_lock:
await self._refresh_snapshot()
async def refresh_snapshot(self) -> None:
"""Refresh from one full signed snapshot within the sync single-flight."""
async with self._sync_lock:
await self._refresh_snapshot()
async def _refresh_snapshot(self) -> None:
last_superseded: _DirectorySnapshotSuperseded | None = None last_superseded: _DirectorySnapshotSuperseded | None = None
for _attempt in range(5): for _attempt in range(5):
snapshot = await self.provider.fetch_snapshot(self.instance_uuid) snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
@@ -170,84 +159,9 @@ class DirectoryProjectionService:
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2) delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
async def sync_once(self) -> None: async def sync_once(self) -> None:
async with self._sync_lock:
await self._sync_once()
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
"""Synchronously project an exact Workspace set without moving the event cursor."""
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
if not requested:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
if len(requested) > self.event_limit:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
async with self._sync_lock:
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
await self._apply_targeted_delta(delta, requested)
async def _apply_targeted_delta(
self,
delta: DirectoryDelta,
requested_workspace_uuids: tuple[str, ...],
) -> None:
if not isinstance(delta, DirectoryDelta):
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
workspace_count, membership_count = self._validate_batch_capacity(
delta.workspaces,
full_snapshot=False,
)
delta = DirectoryDelta.model_validate(delta.model_dump())
if delta.instance_uuid != self.instance_uuid:
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
requested = set(requested_workspace_uuids)
if set(delta.requested_workspace_uuids) != requested:
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
if {workspace.uuid for workspace in delta.workspaces} != requested:
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
if not callable(directory_uow):
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
async with directory_uow(self.instance_uuid) as uow:
session = uow.session
state = await session.scalar(
sqlalchemy.select(DirectoryProjectionState)
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
.with_for_update()
)
if state is None:
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
snapshot = DirectorySnapshot(
instance_uuid=self.instance_uuid,
cursor=state.cursor,
generated_at=delta.generated_at,
workspaces=delta.workspaces,
)
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
active_workspace_count = await self._enforce_active_workspace_capacity(session)
await session.flush()
await self._update_entitlement_workspace_activity(
snapshot.workspaces,
requested_workspace_uuids=requested,
)
self._publish_runtime_execution_projection(
snapshot.workspaces,
affected_workspace_uuids=requested,
)
self._request_model_catalog_sync()
self._record_batch_cardinality(
active_workspaces=active_workspace_count,
workspaces=workspace_count,
memberships=membership_count,
)
async def _sync_once(self) -> None:
cursor = self._consumer_cursor cursor = self._consumer_cursor
if cursor is None: if cursor is None:
await self._refresh_snapshot() await self.initialize()
return return
batch = await self.provider.fetch_events( batch = await self.provider.fetch_events(
self.instance_uuid, self.instance_uuid,
@@ -794,13 +708,7 @@ class DirectoryProjectionService:
for row in inbox_rows: for row in inbox_rows:
row.applied_at = now row.applied_at = now
async def _apply_accounts( async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
self,
session: Any,
snapshot: DirectorySnapshot,
*,
preserve_existing: bool = False,
) -> dict[str, User]:
selected: dict[str, DirectoryMember] = {} selected: dict[str, DirectoryMember] = {}
emails: dict[str, str] = {} emails: dict[str, str] = {}
for workspace in snapshot.workspaces: for workspace in snapshot.workspaces:
@@ -865,12 +773,6 @@ class DirectoryProjectionService:
continue continue
if account.source != AccountSource.CLOUD_PROJECTION.value: if account.source != AccountSource.CLOUD_PROJECTION.value:
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account') raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
if preserve_existing:
# A targeted Workspace fetch has no independently monotonic
# Account revision. It may create a missing runtime shadow, but
# ordered event/snapshot projection remains the only updater of
# existing Account identity and status fields.
continue
if account.projection_revision > snapshot.cursor: if account.projection_revision > snapshot.cursor:
raise DirectoryProjectionUnavailableError('Directory account revision rolled back') raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
projected_account = self._account_projection(member) projected_account = self._account_projection(member)
+4 -32
View File
@@ -160,29 +160,6 @@ def _lark_should_update_stream_element(
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
def _lark_final_layout_texts(
*,
resume_from: bool,
text_message: str,
pre_pause_cached: str | None,
resume_cached: str,
) -> tuple[str, str]:
"""Return (main_text, resume_placeholder_text) for the final card update.
Non-resume round: the full reply belongs in the main streaming element
only also rendering the resume placeholder duplicates the reply, since
both hold the same accumulated text. Resume round (Dify HITL): keep the
pre-pause text in the main element and the resumed text in the
placeholder, as they are distinct segments.
"""
if resume_from:
# An empty pre-pause cache is valid (Dify paused before emitting any
# text); only a missing entry (None) falls back to the full text.
main_text = text_message if pre_pause_cached is None else pre_pause_cached
return main_text, resume_cached
return text_message, ''
def _lark_display_input_value(field: dict, value: typing.Any) -> str: def _lark_display_input_value(field: dict, value: typing.Any) -> str:
field_type = _dify_field_type(field) field_type = _dify_field_type(field)
if field_type == 'file': if field_type == 'file':
@@ -2381,21 +2358,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
else: else:
# Normal finish: remove buttons/notice and finalize the card. # Normal finish: keep pre-pause + resume content visible,
main_text, resume_text = _lark_final_layout_texts( # remove buttons/notice, drop the resume placeholder.
resume_from=resume_from,
text_message=text_message,
pre_pause_cached=self.card_pre_pause_text.get(card_id),
resume_cached=resume_cached,
)
await self._update_card_layout( await self._update_card_layout(
card_id=card_id, card_id=card_id,
message_source=message_source, message_source=message_source,
text_message=main_text, text_message=pre_pause,
sequence=final_seq, sequence=final_seq,
form_data=None, form_data=None,
notice_text=selected_notice if resume_from else '', notice_text=selected_notice if resume_from else '',
resume_placeholder_text=resume_text, resume_placeholder_text=resume_cached,
) )
self._drop_card_state(card_id) self._drop_card_state(card_id)
self.card_id_dict.pop(message_id, None) self.card_id_dict.pop(message_id, None)
+2 -7
View File
@@ -1913,14 +1913,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return plugins return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None: async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
runtime_handler = self._runtime_handler() runtime_handler = self._runtime_handler()
try: binding = await self._target_binding(author, plugin_name)
binding = await self._target_binding(author, plugin_name)
except ValueError as exc:
if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace':
return None
raise
with runtime_handler.installation_scope(binding): with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_info(author, plugin_name) return await runtime_handler.get_plugin_info(author, plugin_name)
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
levels = ['provider_default', 'disabled', 'enabled'] levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao': elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high'] levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family in ('ollama', 'ollama_chat'): elif family == 'ollama':
levels = ['provider_default'] levels = ['provider_default']
levels.append('disabled') levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name: if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
@@ -1345,14 +1345,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
) -> tuple[list[list[float]], dict]: ) -> tuple[list[list[float]], dict]:
"""Invoke embedding and return vectors with usage info.""" """Invoke embedding and return vectors with usage info."""
# litellm's embedding routing has no "ollama_chat" branch (that provider model_name = self._build_litellm_model_name(model.model_entity.name)
# exists only for /api/chat completions) — embeddings still go through
# the plain "ollama" provider. Requesters configured for ollama_chat
# (to get native tool-calling on the chat path) must fall back to
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
# provider for this endpoint".
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
api_key = model.provider.token_mgr.get_token() api_key = model.provider.token_mgr.get_token()
args = { args = {
@@ -1548,12 +1541,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
event_hooks=httpclient.httpx_response_limit_hooks(), event_hooks=httpclient.httpx_response_limit_hooks(),
) as client: ) as client:
response = await client.get(models_url, headers=headers) response = await client.get(models_url, headers=headers)
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
# Some OpenAI-compatible servers (notably a bare Ollama host,
# e.g. http://host:11434) expose the model list under /v1/models
# rather than /models. Providers whose configured base_url
# already ends in /v1 keep their original (working) URL.
response = await client.get(f'{base_url}/v1/models', headers=headers)
response.raise_for_status() response.raise_for_status()
payload = await httpclient.parse_json_response(response) payload = await httpclient.parse_json_response(response)
@@ -7,7 +7,7 @@ metadata:
zh_Hans: Ollama zh_Hans: Ollama
icon: ollama.svg icon: ollama.svg
spec: spec:
litellm_provider: ollama_chat litellm_provider: ollama
config: config:
- name: base_url - name: base_url
label: label:
+3 -4
View File
@@ -642,10 +642,9 @@
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
if ( if (
prevContent && prevContent === content ||
(prevContent === content || prevContent.indexOf(content) >= 0 ||
prevContent.indexOf(content) >= 0 || content.indexOf(prevContent) >= 0
content.indexOf(prevContent) >= 0)
) )
return; return;
} }
+2 -24
View File
@@ -242,22 +242,6 @@ class TestMonitoringSessionsEndpoint:
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
response = await quart_test_client.get(
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
assert kwargs['bot_ids'] == ['bot-1']
assert kwargs['user_query'] == 'alice'
assert kwargs['limit'] == 20
assert kwargs['offset'] == 40
@pytest.mark.usefixtures('mock_circular_import_chain') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringErrorsEndpoint: class TestMonitoringErrorsEndpoint:
@@ -294,19 +278,13 @@ class TestMonitoringDetailsEndpoints:
"""Tests for detail endpoints.""" """Tests for detail endpoints."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app): async def test_get_session_analysis(self, quart_test_client):
"""GET /api/v1/monitoring/sessions/{id}/analysis.""" """GET /api/v1/monitoring/sessions/{id}/analysis."""
response = await quart_test_client.get( response = await quart_test_client.get(
'/api/v1/monitoring/sessions/sess-1/analysis' '/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
'?startTime=2026-08-31T16%3A00%3A00.000Z'
'&endTime=2026-09-01T15%3A59%3A59.999Z',
headers={'Authorization': 'Bearer test_token'},
) )
assert response.status_code == 200 assert response.status_code == 200
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_message_details(self, quart_test_client): async def test_get_message_details(self, quart_test_client):
+8 -198
View File
@@ -27,8 +27,7 @@ async def space_oauth_api():
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
) )
application = Mock() application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss') application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.directory_projection_service = None
application.persistence_mgr = None application.persistence_mgr = None
application.user_service.get_authenticated_account = AsyncMock(return_value=account) application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.issue_space_oauth_state = AsyncMock( application.user_service.issue_space_oauth_state = AsyncMock(
@@ -126,26 +125,6 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
) )
@pytest.mark.asyncio
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={
'redirect_uri': 'http://localhost/auth/space/callback',
'cloud_entry': '1',
},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
authorize_url = (await response.get_json())['data']['authorize_url']
assert authorize_url.startswith('https://space.example/authorize?state=')
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_public_login_rejects_caller_supplied_state(space_oauth_api): async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
@@ -270,14 +249,10 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api): async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'}) missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
response = await client.post( response = await client.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
json={ json={'code': 'oauth-code', 'state': 'opaque-login-state'},
'code': 'v4_oauth-code',
'state': 'opaque-login-state',
'redirect_uri': 'https://oss.example/auth/space/callback',
},
) )
assert (await missing.get_json())['code'] == 1 assert (await missing.get_json())['code'] == 1
@@ -285,146 +260,12 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
assert (await response.get_json())['data']['token'] == 'space-login-token' assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login') application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with( application.space_service.exchange_oauth_code.assert_awaited_once_with(
'v4_oauth-code', 'oauth-code',
[WORKSPACE_UUID], [WORKSPACE_UUID],
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())}, {WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
redirect_uri='https://oss.example/auth/space/callback',
) )
@pytest.mark.asyncio
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
application, client = space_oauth_api
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'code contract' in payload['msg']
application.space_service.exchange_oauth_code.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
calls: list[str] = []
application.directory_projection_service = SimpleNamespace(
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
)
application.space_service.exchange_oauth_code.return_value = {
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
'cloud_workspace_uuid': WORKSPACE_UUID,
}
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
async def authenticate(*_args):
calls.append('authenticate')
return 'space-login-token', authenticated_account
application.user_service.authenticate_space_user.side_effect = authenticate
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
assert calls == ['reconcile', 'authenticate']
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
@pytest.mark.asyncio
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Cloud Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
application.space_service.exchange_oauth_code.return_value = {
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
'cloud_workspace_uuid': 'workspace-from-another-flow',
}
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
application, client = space_oauth_api
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api): async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
@@ -435,7 +276,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
response = await client.post( response = await client.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'}, json={'code': 'oauth-code', 'state': 'opaque-login-state'},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -534,22 +375,18 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
rejected = await client.post( rejected = await client.post(
'/api/v1/user/bind-space', '/api/v1/user/bind-space',
json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'}, json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
) )
response = await client.post( response = await client.post(
'/api/v1/user/bind-space', '/api/v1/user/bind-space',
json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'}, json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
) )
assert rejected.status_code == 401 assert rejected.status_code == 401
assert response.status_code == 200 assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'rotated-account-token' assert (await response.get_json())['data']['token'] == 'rotated-account-token'
application.user_service.verify_jwt_token.assert_not_awaited() application.user_service.verify_jwt_token.assert_not_awaited()
application.user_service.bind_space_account.assert_awaited_once_with( application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
'owner@example.com',
'v4_oauth-code',
redirect_uri='http://localhost/auth/space/callback?mode=bind',
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -557,7 +394,6 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
application, client = space_oauth_api application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock() application.user_service.consume_space_oauth_state.reset_mock()
application.space_service.exchange_oauth_code.reset_mock() application.space_service.exchange_oauth_code.reset_mock()
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post( response = await client.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
@@ -578,29 +414,3 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
) )
application.user_service.consume_space_oauth_state.assert_not_awaited() application.user_service.consume_space_oauth_state.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited() application.space_service.exchange_oauth_code.assert_not_awaited()
@pytest.mark.asyncio
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
application, client = space_oauth_api
projected_account = SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='space',
status='active',
)
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={
'workspace_uuid': WORKSPACE_UUID,
'launch_assertion': 'signed-launch-token',
},
)
assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
@@ -138,39 +138,6 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
assert (await service.get_message_details(context_a, message_b))['found'] is False assert (await service.get_message_details(context_a, message_b))['found'] is False
async def test_session_search_matches_user_id_or_name_within_workspace(service):
context_a = _context(WORKSPACE_A)
context_b = _context(WORKSPACE_B)
fixtures = [
(context_a, 'session-id-match', 'customer-42', 'Alice'),
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
(context_a, 'session-no-match', 'customer-7', 'Bob'),
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
]
for context, session_id, user_id, user_name in fixtures:
await service.record_session_start(
context,
session_id=session_id,
bot_id='same-bot',
bot_name='Same Bot',
pipeline_id='same-pipeline',
pipeline_name='Same Pipeline',
user_id=user_id,
user_name=user_name,
)
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
assert id_total == 1
assert [session['session_id'] for session in by_id] == ['session-id-match']
assert name_total == 2
assert {session['session_id'] for session in by_name} == {
'session-id-match',
'session-name-match',
}
async def test_tool_call_inherits_context_from_connection_message_row(service): async def test_tool_call_inherits_context_from_connection_message_row(service):
context = _context(WORKSPACE_A) context = _context(WORKSPACE_A)
message_id = await _record_message(service, context, 'tool context') message_id = await _record_message(service, context, 'tool context')
@@ -95,9 +95,7 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
result = service.get_oauth_authorize_url('http://localhost/callback') result = service.get_oauth_authorize_url('http://localhost/callback')
# Verify # Verify
query = parse_qs(urlsplit(result).query) assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
assert query['redirect_uri'] == ['http://localhost/callback']
assert query['code_contract'] == ['redirect-v1']
assert 'https://space.langbot.app/auth/authorize' in result assert 'https://space.langbot.app/auth/authorize' in result
def test_get_oauth_authorize_url_with_state(self): def test_get_oauth_authorize_url_with_state(self):
@@ -580,14 +578,12 @@ class TestSpaceServiceExchangeOAuthCode:
'auth_code', 'auth_code',
['workspace-1'], ['workspace-1'],
{'workspace-1': 1_700_000_000}, {'workspace-1': 1_700_000_000},
redirect_uri='https://oss.example/auth/space/callback',
) )
# Verify # Verify
assert result['access_token'] == 'new_access_token' assert result['access_token'] == 'new_access_token'
assert mock_session_obj.post.call_args.kwargs['json'] == { assert mock_session_obj.post.call_args.kwargs['json'] == {
'code': 'auth_code', 'code': 'auth_code',
'redirect_uri': 'https://oss.example/auth/space/callback',
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
'workspace_uuids': ['workspace-1'], 'workspace_uuids': ['workspace-1'],
'workspace_created_ats': {'workspace-1': 1_700_000_000}, 'workspace_created_ats': {'workspace-1': 1_700_000_000},
@@ -850,7 +846,10 @@ class TestSpaceServiceGetModelSelection:
if response_shape == 'models-envelope': if response_shape == 'models-envelope':
data = {'models': models} data = {'models': models}
elif response_shape == 'availability-wrapper': elif response_shape == 'availability-wrapper':
data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)] data = [
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
for index, model in enumerate(models)
]
else: else:
data = models data = models
payload = {'code': 0, 'data': data} payload = {'code': 0, 'data': data}
@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime import datetime
import logging import logging
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import Mock
import pytest import pytest
import sqlalchemy import sqlalchemy
@@ -215,88 +214,6 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
request_sync.assert_called_once_with() request_sync.assert_called_once_with()
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
application, session_factory = projection_context
provider = _Provider(
[_snapshot(7, workspaces=[])],
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
await service.reconcile_workspaces((WORKSPACE_UUID,))
async with session_factory() as session:
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
workspace = await session.get(Workspace, WORKSPACE_UUID)
membership = await session.scalar(
sqlalchemy.select(WorkspaceMembership).where(
WorkspaceMembership.workspace_uuid == WORKSPACE_UUID,
WorkspaceMembership.account_uuid == ACCOUNT_UUID,
)
)
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
assert account is not None
assert workspace is not None and workspace.name == 'JIT Workspace'
assert membership is not None and membership.status == 'active'
assert state is not None and state.cursor == 7
assert provider.delta_calls == 1
assert provider.after_cursors == []
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
application, session_factory = projection_context
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
update={
'members': [
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
]
}
)
provider = _Provider(
[_snapshot(7)],
deltas=[_delta(workspaces=[targeted_workspace])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
await service.reconcile_workspaces((WORKSPACE_UUID,))
async with session_factory() as session:
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
workspace = await session.get(Workspace, WORKSPACE_UUID)
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
assert account is not None and account.user == 'Workspace Owner'
assert account.projection_revision == 7
assert workspace is not None and workspace.name == 'Renamed Workspace'
assert state is not None and state.cursor == 7
async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context):
application, _session_factory = projection_context
provider = _Provider(
[_snapshot(7, workspaces=[])],
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
service._reconcile_entitlement_snapshot_set = AsyncMock()
service._update_entitlement_workspace_activity = AsyncMock()
service._publish_runtime_execution_projection = Mock()
await service.reconcile_workspaces((WORKSPACE_UUID,))
service._reconcile_entitlement_snapshot_set.assert_not_awaited()
service._update_entitlement_workspace_activity.assert_awaited_once()
assert service._update_entitlement_workspace_activity.await_args.kwargs == {
'requested_workspace_uuids': {WORKSPACE_UUID},
}
service._publish_runtime_execution_projection.assert_called_once()
assert service._publish_runtime_execution_projection.call_args.kwargs == {
'affected_workspace_uuids': {WORKSPACE_UUID},
}
async def test_initial_snapshot_projects_core_owned_rows(projection_context): async def test_initial_snapshot_projects_core_owned_rows(projection_context):
application, session_factory = projection_context application, session_factory = projection_context
reconcile_execution_projection = Mock() reconcile_execution_projection = Mock()
@@ -818,72 +735,6 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
assert second_provider.after_cursors == [1, 2] assert second_provider.after_cursors == [1, 2]
async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context):
application, _session_factory = projection_context
class _ConcurrentProvider(_Provider):
def __init__(self) -> None:
super().__init__([_snapshot(1)])
self.first_fetch_started = asyncio.Event()
self.release_first_fetch = asyncio.Event()
self.active_fetches = 0
self.max_active_fetches = 0
async def fetch_events(
self,
instance_uuid: str,
after_cursor: int,
limit: int,
) -> DirectoryEventBatch:
assert instance_uuid == INSTANCE_UUID
assert limit == 100
self.after_cursors.append(after_cursor)
self.active_fetches += 1
self.max_active_fetches = max(self.max_active_fetches, self.active_fetches)
try:
if len(self.after_cursors) == 1:
self.first_fetch_started.set()
await self.release_first_fetch.wait()
cursor = after_cursor + 1
return DirectoryEventBatch(
instance_uuid=instance_uuid,
after_cursor=after_cursor,
cursor=cursor,
high_water_cursor=cursor,
events=(
DirectoryEvent(
cursor=cursor,
uuid=f'40000000-0000-4000-8000-{cursor:012d}',
aggregate_uuid=WORKSPACE_UUID,
event_type='entitlement.changed',
revision=cursor,
payload={
'workspace_uuid': WORKSPACE_UUID,
'entitlement_revision': cursor,
},
created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC),
),
),
)
finally:
self.active_fetches -= 1
provider = _ConcurrentProvider()
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
first = asyncio.create_task(service.sync_once())
await provider.first_fetch_started.wait()
second = asyncio.create_task(service.sync_once())
await asyncio.sleep(0)
provider.release_first_fetch.set()
await asyncio.gather(first, second)
assert provider.max_active_fetches == 1
assert provider.after_cursors == [1, 2]
assert service._consumer_cursor == 3
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context): async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
application, session_factory = projection_context application, session_factory = projection_context
event_two = DirectoryEvent( event_two = DirectoryEvent(
+1 -42
View File
@@ -1,11 +1,8 @@
"""Tests for DingTalk API payload helpers.""" """Tests for DingTalk API payload helpers."""
import json import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map from langbot.libs.dingtalk_api.api import _stringify_card_param_map
from langbot.pkg.utils import httpclient
def test_dingtalk_card_param_map_stringifies_select_component_arrays(): def test_dingtalk_card_param_map_stringifies_select_component_arrays():
@@ -43,41 +40,3 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures():
assert params['other'] == '["A"]' assert params['other'] == '["A"]'
assert params['empty'] == '' assert params['empty'] == ''
async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch):
response = type('Response', (), {'status_code': 200})()
post = AsyncMock(return_value=response)
@asynccontextmanager
async def client_context():
yield type('HttpClient', (), {'post': post})()
client = object.__new__(DingTalkClient)
client.access_token = 'access-token'
client.robot_code = 'robot-code'
client.key = 'client-id'
client.logger = None
client.check_access_token = AsyncMock(return_value=True)
client._http_client_context = client_context
monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}'))
original_params = {'content': 'hello'}
delivered = await client.create_and_deliver_card(
card_template_id='template-id',
out_track_id='track-id',
open_space_id='dtv1.card//IM_ROBOT.user-id',
is_group=False,
card_param_map=original_params,
card_data_config={'autoLayout': True},
)
request_body = post.await_args.kwargs['json']
assert delivered is True
assert request_body['cardData'] == {
'cardParamMap': {
'content': 'hello',
'config': '{"autoLayout": true}',
}
}
assert original_params == {'content': 'hello'}
+1 -120
View File
@@ -1,7 +1,7 @@
"""Tests for Lark adapter helper behavior.""" """Tests for Lark adapter helper behavior."""
import threading import threading
from unittest.mock import AsyncMock, MagicMock from unittest.mock import MagicMock
import pytest import pytest
@@ -12,7 +12,6 @@ from langbot.pkg.platform.sources.lark import (
_lark_completed_input_lines, _lark_completed_input_lines,
_lark_current_input_defs, _lark_current_input_defs,
_lark_extract_action_form_inputs, _lark_extract_action_form_inputs,
_lark_final_layout_texts,
_lark_should_update_stream_element, _lark_should_update_stream_element,
_lark_visible_form_content, _lark_visible_form_content,
) )
@@ -222,121 +221,3 @@ def test_lark_completed_input_lines_display_select_value_from_object():
) )
assert lines == ['✅ xialaB'] assert lines == ['✅ xialaB']
def test_lark_final_layout_texts_normal_round_drops_resume_placeholder():
"""Non-resume final chunk: the reply must land in the main element only.
Regression: rendering the resume placeholder too duplicated the reply,
because the accumulated streaming text equals the final text on a normal
round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card).
"""
main_text, resume_text = _lark_final_layout_texts(
resume_from=False,
text_message='It is Sep 1, 2026, 15:09:15.',
pre_pause_cached=None,
resume_cached='It is Sep 1, 2026, 15:09:15.',
)
assert main_text == 'It is Sep 1, 2026, 15:09:15.'
assert resume_text == ''
def test_lark_final_layout_texts_resume_round_keeps_both_segments():
"""Dify HITL resume final chunk: pre-pause text and resumed text differ,
both segments stay visible."""
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='resumed answer',
pre_pause_cached='partial answer before pause',
resume_cached='resumed answer',
)
assert main_text == 'partial answer before pause'
assert resume_text == 'resumed answer'
def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back():
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='answer',
pre_pause_cached=None,
resume_cached='answer',
)
assert main_text == 'answer'
assert resume_text == 'answer'
def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty():
"""Dify paused before emitting any text: the pre-pause cache is a valid
empty string and must NOT be treated as a cache miss.
Regression: `pre_pause_cached or text_message` fell back to the full
text, so the final card rendered ('resumed answer', 'resumed answer')
and duplicated the reply.
"""
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='resumed answer',
pre_pause_cached='',
resume_cached='resumed answer',
)
assert main_text == ''
assert resume_text == 'resumed answer'
def _build_resume_final_chunk_adapter(message_text: str):
"""Build a LarkAdapter whose card state mimics a Dify HITL round that
paused before emitting any text, then resumed and completed."""
adapter = LarkAdapter.model_construct(
api_client=MagicMock(),
message_converter=MagicMock(yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))),
)
adapter.config = {'app_type': 'self'}
LarkAdapter.get_app_access_token = lambda self: None
LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None
adapter.card_id_dict = {'msg-1': 'card-1'}
adapter.card_streaming_text = {'card-1': message_text}
adapter.card_pre_pause_text = {'card-1': ''}
adapter.card_resume_transitioned = {'card-1'}
adapter.card_sequence_dict = {}
adapter.card_last_accessed = {}
adapter.card_cleanup_at = 0.0
adapter.card_id_to_source_ids = {}
adapter.reply_message_card_ids = {}
adapter.card_form_content = {}
adapter.card_form_input_defs = {}
adapter.card_form_inputs = {}
adapter._update_card_layout = AsyncMock()
return adapter
@pytest.mark.asyncio
async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty():
"""End-to-end regression via reply_message_chunk: Dify paused before any
text, so the pre-pause cache is ''. The final card update must render the
resumed answer only once (empty main text + resume placeholder), not
twice as ('resumed answer', 'resumed answer')."""
adapter = _build_resume_final_chunk_adapter('resumed answer')
bot_message = MagicMock(
resp_message_id='msg-1',
msg_sequence=1,
spec=['resp_message_id', 'msg_sequence', '_resume_from_form'],
)
bot_message._resume_from_form = True
message_source = MagicMock(source_platform_object=None)
await adapter.reply_message_chunk(
message_source,
bot_message,
MagicMock(),
is_final=True,
)
adapter._update_card_layout.assert_awaited_once()
layout_kwargs = adapter._update_card_layout.await_args.kwargs
assert layout_kwargs['text_message'] == ''
assert layout_kwargs['resume_placeholder_text'] == 'resumed answer'
@@ -640,19 +640,6 @@ class TestGetPluginInfo:
connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin') connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin')
assert result == {'manifest': {'metadata': {'name': 'plugin'}}} assert result == {'manifest': {'metadata': {'name': 'plugin'}}}
@pytest.mark.asyncio
async def test_returns_none_when_plugin_is_not_installed(self):
connector = create_mock_connector()
configure_handler(connector, AsyncMock())
connector._target_binding = AsyncMock(
side_effect=ValueError('Plugin author/plugin is not installed in this Workspace')
)
result = await connector.get_plugin_info('author', 'plugin')
assert result is None
connector.handler.get_plugin_info.assert_not_awaited()
class TestSetPluginConfig: class TestSetPluginConfig:
"""Tests for set_plugin_config method.""" """Tests for set_plugin_config method."""
Generated
+5 -5
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.9" version = "4.10.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -2129,7 +2129,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.6" }, { name = "langbot-plugin", specifier = "==0.5.5" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2196,7 +2196,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.6" version = "0.5.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
@@ -2217,9 +2217,9 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" } sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" }, { url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
] ]
[[package]] [[package]]
+3 -15
View File
@@ -43,24 +43,17 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise( function getOrCreateSpaceOAuthLoginPromise(
authCode: string, authCode: string,
state: string, state: string,
redirectUri: string,
workspaceUuid?: string, workspaceUuid?: string,
launchAssertion?: string, launchAssertion?: string,
): Promise<SpaceOAuthLoginResult> { ): Promise<SpaceOAuthLoginResult> {
const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`; const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey); const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) { if (pendingRequest) {
return pendingRequest; return pendingRequest;
} }
const requestPromise = httpClient const requestPromise = httpClient
.exchangeSpaceOAuthCode( .exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
authCode,
state,
redirectUri,
workspaceUuid,
launchAssertion,
)
.finally(() => { .finally(() => {
pendingSpaceOAuthLogins.delete(requestKey); pendingSpaceOAuthLogins.delete(requestKey);
}); });
@@ -102,7 +95,6 @@ function SpaceOAuthCallbackContent() {
const response = await getOrCreateSpaceOAuthLoginPromise( const response = await getOrCreateSpaceOAuthLoginPromise(
authCode, authCode,
state, state,
`${window.location.origin}/auth/space/callback`,
workspaceUuid, workspaceUuid,
launchAssertion, launchAssertion,
); );
@@ -203,11 +195,7 @@ function SpaceOAuthCallbackContent() {
async (authCode: string, state: string) => { async (authCode: string, state: string) => {
setIsProcessing(true); setIsProcessing(true);
try { try {
const response = await httpClient.bindSpaceAccount( const response = await httpClient.bindSpaceAccount(authCode, state);
authCode,
state,
`${window.location.origin}/auth/space/callback?mode=bind`,
);
if (!isMountedRef.current) { if (!isMountedRef.current) {
return; return;
} }
@@ -17,7 +17,6 @@ import {
Copy, Copy,
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft,
ChevronRight, ChevronRight,
Workflow, Workflow,
ThumbsUp, ThumbsUp,
@@ -118,43 +117,16 @@ interface BotSessionMonitorProps {
botId: string; botId: string;
} }
const SESSION_PAGE_SIZE = 20;
const MESSAGE_PAGE_SIZE = 50;
const localDateBoundaryToISOString = (
dateValue: string,
endOfDay: boolean,
): string => {
const [year, month, day] = dateValue.split('-').map(Number);
return new Date(
year,
month - 1,
day,
endOfDay ? 23 : 0,
endOfDay ? 59 : 0,
endOfDay ? 59 : 0,
endOfDay ? 999 : 0,
).toISOString();
};
const BotSessionMonitor = forwardRef< const BotSessionMonitor = forwardRef<
BotSessionMonitorHandle, BotSessionMonitorHandle,
BotSessionMonitorProps BotSessionMonitorProps
>(function BotSessionMonitor({ botId }, ref) { >(function BotSessionMonitor({ botId }, ref) {
const { t } = useTranslation(); const { t } = useTranslation();
const [sessions, setSessions] = useState<SessionInfo[]>([]); const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [sessionTotal, setSessionTotal] = useState(0);
const [sessionPage, setSessionPage] = useState(0);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [userQuery, setUserQuery] = useState('');
const [appliedUserQuery, setAppliedUserQuery] = useState('');
const [selectedSessionId, setSelectedSessionId] = useState<string | null>( const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null, null,
); );
const [messages, setMessages] = useState<SessionMessage[]>([]); const [messages, setMessages] = useState<SessionMessage[]>([]);
const [messageTotal, setMessageTotal] = useState(0);
const [messagePage, setMessagePage] = useState(0);
const [loadingSessions, setLoadingSessions] = useState(false); const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false); const [copiedUserId, setCopiedUserId] = useState(false);
@@ -166,8 +138,6 @@ const BotSessionMonitor = forwardRef<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const sessionRequestIdRef = useRef(0);
const messageRequestIdRef = useRef(0);
const { admins, reload: reloadAdmins } = useBotAdmins(botId); const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null); const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
@@ -234,33 +204,16 @@ const BotSessionMonitor = forwardRef<
}; };
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
const requestId = ++sessionRequestIdRef.current;
setLoadingSessions(true); setLoadingSessions(true);
try { try {
const response = await httpClient.getBotSessions(botId, { const response = await httpClient.getBotSessions(botId);
limit: SESSION_PAGE_SIZE,
offset: sessionPage * SESSION_PAGE_SIZE,
startTime: startDate
? localDateBoundaryToISOString(startDate, false)
: undefined,
endTime: endDate
? localDateBoundaryToISOString(endDate, true)
: undefined,
userQuery: appliedUserQuery || undefined,
});
if (requestId !== sessionRequestIdRef.current) return;
setSessions(response.sessions ?? []); setSessions(response.sessions ?? []);
setSessionTotal(response.total ?? 0);
} catch (error) { } catch (error) {
if (requestId === sessionRequestIdRef.current) { console.error('Failed to load sessions:', error);
console.error('Failed to load sessions:', error);
}
} finally { } finally {
if (requestId === sessionRequestIdRef.current) { setLoadingSessions(false);
setLoadingSessions(false);
}
} }
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]); }, [botId]);
useImperativeHandle( useImperativeHandle(
ref, ref,
@@ -271,39 +224,25 @@ const BotSessionMonitor = forwardRef<
); );
const loadMessages = useCallback( const loadMessages = useCallback(
async (sessionId: string, page: number) => { async (sessionId: string) => {
const requestId = ++messageRequestIdRef.current;
setLoadingMessages(true); setLoadingMessages(true);
setExpandedToolCallIds({}); setExpandedToolCallIds({});
try { try {
const messagesRes = await httpClient.getSessionMessages( const messagesRes = await httpClient.getSessionMessages(sessionId);
sessionId,
MESSAGE_PAGE_SIZE,
page * MESSAGE_PAGE_SIZE,
);
if (requestId !== messageRequestIdRef.current) return;
const sorted = (messagesRes.messages ?? []).sort( const sorted = (messagesRes.messages ?? []).sort(
(a, b) => (a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
); );
setMessages(sorted); setMessages(sorted);
setMessageTotal(messagesRes.total ?? 0);
try { try {
const analysisParams = new URLSearchParams();
if (sorted.length > 0) {
analysisParams.set('startTime', sorted[0].timestamp);
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
}
const analysisRes = await httpClient.get<{ const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[]; tool_calls?: SessionToolCall[];
}>( }>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`, `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
); );
if (requestId !== messageRequestIdRef.current) return;
setToolCalls(analysisRes?.tool_calls ?? []); setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) { } catch (analysisError) {
if (requestId !== messageRequestIdRef.current) return;
console.error('Failed to load session tool calls:', analysisError); console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]); setToolCalls([]);
} }
@@ -320,7 +259,6 @@ const BotSessionMonitor = forwardRef<
}>( }>(
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`, `/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
); );
if (requestId !== messageRequestIdRef.current) return;
const map: Record<string, SessionFeedback> = {}; const map: Record<string, SessionFeedback> = {};
if (feedbackRes?.feedback) { if (feedbackRes?.feedback) {
@@ -335,13 +273,9 @@ const BotSessionMonitor = forwardRef<
setFeedbackMap({}); setFeedbackMap({});
} }
} catch (error) { } catch (error) {
if (requestId === messageRequestIdRef.current) { console.error('Failed to load session messages:', error);
console.error('Failed to load session messages:', error);
}
} finally { } finally {
if (requestId === messageRequestIdRef.current) { setLoadingMessages(false);
setLoadingMessages(false);
}
} }
}, },
[botId], [botId],
@@ -351,24 +285,16 @@ const BotSessionMonitor = forwardRef<
loadSessions(); loadSessions();
}, [loadSessions]); }, [loadSessions]);
useEffect(() => {
setSelectedSessionId(null);
setMessagePage(0);
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
useEffect(() => { useEffect(() => {
if (selectedSessionId) { if (selectedSessionId) {
loadMessages(selectedSessionId, messagePage); loadMessages(selectedSessionId);
} else { } else {
messageRequestIdRef.current += 1;
setLoadingMessages(false);
setMessages([]); setMessages([]);
setMessageTotal(0);
setToolCalls([]); setToolCalls([]);
setExpandedToolCallIds({}); setExpandedToolCallIds({});
setFeedbackMap({}); setFeedbackMap({});
} }
}, [selectedSessionId, messagePage, loadMessages]); }, [selectedSessionId, loadMessages]);
useEffect(() => { useEffect(() => {
if (messages.length === 0 && toolCalls.length === 0) return; if (messages.length === 0 && toolCalls.length === 0) return;
@@ -626,19 +552,6 @@ const BotSessionMonitor = forwardRef<
const selectedSession = sessions.find( const selectedSession = sessions.find(
(s) => s.session_id === selectedSessionId, (s) => s.session_id === selectedSessionId,
); );
const sessionPageCount = Math.max(
1,
Math.ceil(sessionTotal / SESSION_PAGE_SIZE),
);
const messagePageCount = Math.max(
1,
Math.ceil(messageTotal / MESSAGE_PAGE_SIZE),
);
const applyUserSearch = () => {
setSessionPage(0);
setAppliedUserQuery(userQuery.trim());
};
return ( return (
<> <>
@@ -662,65 +575,6 @@ const BotSessionMonitor = forwardRef<
)} )}
</span> </span>
</button> </button>
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('bots.sessionMonitor.totalSessions', {
defaultValue: '{{count}} sessions',
count: sessionTotal,
})}
</span>
</div>
<div className="p-1.5 border-b shrink-0 space-y-1.5">
<div className="flex gap-1">
<input
value={userQuery}
onChange={(event) => setUserQuery(event.target.value)}
onKeyDown={(event) =>
event.key === 'Enter' && applyUserSearch()
}
aria-label={t('bots.sessionMonitor.userSearch', {
defaultValue: 'User ID or name',
})}
placeholder={t('bots.sessionMonitor.userSearch', {
defaultValue: 'User ID or name',
})}
className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs"
/>
<button
type="button"
onClick={applyUserSearch}
className="h-7 rounded border px-2 text-[11px] hover:bg-accent"
>
{t('common.search', { defaultValue: 'Search' })}
</button>
</div>
<div className="grid grid-cols-2 gap-1">
<input
type="date"
value={startDate}
max={endDate || undefined}
onChange={(event) => {
setSessionPage(0);
setStartDate(event.target.value);
}}
aria-label={t('bots.sessionMonitor.startDate', {
defaultValue: 'Start date',
})}
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
/>
<input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(event) => {
setSessionPage(0);
setEndDate(event.target.value);
}}
aria-label={t('bots.sessionMonitor.endDate', {
defaultValue: 'End date',
})}
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
/>
</div>
</div> </div>
{/* Session List */} {/* Session List */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
@@ -747,10 +601,7 @@ const BotSessionMonitor = forwardRef<
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer', 'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
isSelected ? 'bg-accent' : 'hover:bg-accent/50', isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)} )}
onClick={() => { onClick={() => setSelectedSessionId(session.session_id)}
setSelectedSessionId(session.session_id);
setMessagePage(0);
}}
> >
<div className="flex items-center justify-between mb-0.5"> <div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2"> <span className="text-sm font-medium truncate mr-2">
@@ -786,29 +637,6 @@ const BotSessionMonitor = forwardRef<
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
<div className="h-8 border-t px-1.5 flex items-center justify-between shrink-0 text-[11px]">
<button
type="button"
aria-label={t('common.previous', { defaultValue: 'Previous' })}
disabled={sessionPage === 0 || loadingSessions}
onClick={() => setSessionPage((page) => Math.max(0, page - 1))}
className="p-1 rounded hover:bg-accent disabled:opacity-40"
>
<ChevronLeft className="size-3.5" />
</button>
<span className="tabular-nums text-muted-foreground">
{sessionPage + 1} / {sessionPageCount}
</span>
<button
type="button"
aria-label={t('common.next', { defaultValue: 'Next' })}
disabled={sessionPage + 1 >= sessionPageCount || loadingSessions}
onClick={() => setSessionPage((page) => page + 1)}
className="p-1 rounded hover:bg-accent disabled:opacity-40"
>
<ChevronRight className="size-3.5" />
</button>
</div>
</div> </div>
{/* Right Panel: Messages */} {/* Right Panel: Messages */}
@@ -1147,33 +975,6 @@ const BotSessionMonitor = forwardRef<
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
<button
type="button"
disabled={messagePage === 0 || loadingMessages}
onClick={() =>
setMessagePage((page) => Math.max(0, page - 1))
}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
<ChevronLeft className="size-3.5" />
{t('common.previous', { defaultValue: 'Previous' })}
</button>
<span className="tabular-nums text-muted-foreground">
{messagePage + 1} / {messagePageCount} · {messageTotal}
</span>
<button
type="button"
disabled={
messagePage + 1 >= messagePageCount || loadingMessages
}
onClick={() => setMessagePage((page) => page + 1)}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
{t('common.next', { defaultValue: 'Next' })}
<ChevronRight className="size-3.5" />
</button>
</div>
</> </>
)} )}
</div> </div>
+5 -22
View File
@@ -474,13 +474,8 @@ export class BackendClient extends BaseHttpClient {
public getBotSessions( public getBotSessions(
botId: string, botId: string,
options: { limit: number = 100,
limit: number; offset: number = 0,
offset: number;
startTime?: string;
endTime?: string;
userQuery?: string;
},
): Promise<{ ): Promise<{
sessions: Array<{ sessions: Array<{
session_id: string; session_id: string;
@@ -500,17 +495,8 @@ export class BackendClient extends BaseHttpClient {
}> { }> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
queryParams.append('botId', botId); queryParams.append('botId', botId);
queryParams.append('limit', options.limit.toString()); queryParams.append('limit', limit.toString());
queryParams.append('offset', options.offset.toString()); queryParams.append('offset', offset.toString());
if (options.startTime) {
queryParams.append('startTime', options.startTime);
}
if (options.endTime) {
queryParams.append('endTime', options.endTime);
}
if (options.userQuery) {
queryParams.append('userQuery', options.userQuery);
}
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`); return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
} }
@@ -1365,7 +1351,6 @@ export class BackendClient extends BaseHttpClient {
public async bindSpaceAccount( public async bindSpaceAccount(
code: string, code: string,
state: string, state: string,
redirectUri: string,
): Promise<{ ): Promise<{
token: string; token: string;
user: string; user: string;
@@ -1373,7 +1358,7 @@ export class BackendClient extends BaseHttpClient {
}> { }> {
const response = await this.instance.post( const response = await this.instance.post(
'/api/v1/user/bind-space', '/api/v1/user/bind-space',
{ code, state, redirect_uri: redirectUri }, { code, state },
{ skipWorkspace: true } as RequestConfig, { skipWorkspace: true } as RequestConfig,
); );
if (response.data.code !== 0) { if (response.data.code !== 0) {
@@ -1409,7 +1394,6 @@ export class BackendClient extends BaseHttpClient {
public async exchangeSpaceOAuthCode( public async exchangeSpaceOAuthCode(
code: string, code: string,
state: string, state: string,
redirectUri: string,
workspaceUuid?: string, workspaceUuid?: string,
launchAssertion?: string, launchAssertion?: string,
): Promise<{ ): Promise<{
@@ -1424,7 +1408,6 @@ export class BackendClient extends BaseHttpClient {
{ {
code, code,
state, state,
redirect_uri: redirectUri,
workspace_uuid: workspaceUuid, workspace_uuid: workspaceUuid,
launch_assertion: launchAssertion, launch_assertion: launchAssertion,
}, },
@@ -1,19 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(
new URL('../../src/app/login/page.tsx', import.meta.url),
'utf8',
);
test('normal Cloud login uses the standard Space OAuth callback path', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
});
test('invitation login uses the same OAuth callback before accepting the invitation', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/);
assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/);
});
@@ -1,201 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const widgetPath = path.resolve(
testDirectory,
'../../../src/langbot/templates/embed/widget.js',
);
const widgetSource = fs.readFileSync(widgetPath, 'utf8');
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.className = '';
this.dataset = {};
this.style = {};
this.listeners = {};
this._innerHTML = '';
}
appendChild(child) {
this.children.push(child);
return child;
}
setAttribute(name, value) {
this[name] = String(value);
}
addEventListener(event, listener) {
this.listeners[event] = listener;
}
click() {
this.listeners.click?.({});
}
attachShadow() {
this.shadowRoot = new FakeElement('shadow-root');
return this.shadowRoot;
}
get classList() {
return {
add: (...names) => {
const classes = `${this.className} ${names.join(' ')}`
.trim()
.split(/\s+/);
this.className = [...new Set(classes)].join(' ');
},
};
}
set textContent(value) {
this._innerHTML = String(value ?? '');
}
set innerHTML(value) {
this._innerHTML = String(value ?? '');
}
get innerHTML() {
return this._innerHTML;
}
querySelectorAll(selector) {
const matches = [];
for (const child of this.children) {
if (
selector.startsWith('.') &&
child.className.split(/\s+/).includes(selector.slice(1))
) {
matches.push(child);
}
matches.push(...child.querySelectorAll(selector));
}
return matches;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
}
class FakeDocument {
constructor() {
this.body = new FakeElement('body');
this.head = new FakeElement('head');
this.readyState = 'complete';
this.currentScript = { getAttribute: () => null };
}
createElement(tagName) {
return new FakeElement(tagName);
}
getElementById(id) {
const find = (element) => {
if (element.id === id) return element;
for (const child of element.children) {
const match = find(child);
if (match) return match;
}
return null;
};
return find(this.body) ?? find(this.head);
}
}
class FakeWebSocket {
static OPEN = 1;
static instances = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.OPEN;
FakeWebSocket.instances.push(this);
}
send() {}
}
function launchWidget() {
FakeWebSocket.instances = [];
const document = new FakeDocument();
const window = {
crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' },
sessionStorage: { getItem: () => null, setItem: () => {} },
};
const context = vm.createContext({
document,
window,
navigator: { clipboard: { writeText: () => Promise.resolve() } },
WebSocket: FakeWebSocket,
fetch: () => new Promise(() => {}),
requestAnimationFrame: () => 0,
setTimeout: () => 0,
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
});
vm.runInContext(widgetSource, context, { filename: widgetPath });
const root = document.getElementById('langbot-widget-root');
assert.ok(root, 'widget should initialize');
root.shadowRoot.querySelector('.lb-bubble').click();
const socket = FakeWebSocket.instances.at(-1);
assert.ok(socket, 'opening widget should connect its WebSocket');
return {
receive(message) {
socket.onmessage({
data: JSON.stringify({ type: 'response', data: message }),
});
},
assistantMessages() {
return root.shadowRoot.querySelectorAll('.lb-msg-assistant');
},
};
}
function assistant(id, content) {
return { id, role: 'assistant', content, is_final: true };
}
test('renders a non-empty reply after an empty assistant frame', () => {
const widget = launchWidget();
widget.receive(assistant('thought', ''));
widget.receive(assistant('answer', 'visible answer'));
const messages = widget.assistantMessages();
assert.equal(messages.length, 2);
assert.equal(
messages[1].querySelector('.lb-msg-bubble').innerHTML,
'visible answer',
);
});
test('still drops a duplicate non-empty assistant frame', () => {
const widget = launchWidget();
widget.receive(assistant('answer-1', 'same answer'));
widget.receive(assistant('answer-2', 'same answer'));
assert.equal(widget.assistantMessages().length, 1);
});
test('still keeps two distinct non-empty assistant frames', () => {
const widget = launchWidget();
widget.receive(assistant('answer-1', 'first answer'));
widget.receive(assistant('answer-2', 'second answer'));
assert.equal(widget.assistantMessages().length, 2);
});
@@ -1,139 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const repoRoot = path.resolve(root, '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8');
const includes = (source, token, message) =>
assert.ok(source.includes(token), message);
test('session list request supports a server-side page and operator filters', () => {
const client = read('src/app/infra/http/BackendClient.ts');
includes(client, 'startTime?: string', 'client accepts a start date');
includes(client, 'endTime?: string', 'client accepts an end date');
includes(client, 'userQuery?: string', 'client accepts a user query');
includes(
client,
"queryParams.append('offset', options.offset.toString())",
'client sends the requested session offset',
);
includes(
client,
"queryParams.append('userQuery', options.userQuery)",
'client sends the user query',
);
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'SESSION_PAGE_SIZE',
'sessionTotal',
'sessionPage',
'startDate',
'endDate',
'userQuery',
]) {
includes(monitor, token, `monitor includes ${token}`);
}
});
test('session detail requests and renders a bounded message page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'MESSAGE_PAGE_SIZE',
'messageTotal',
'messagePage',
'page * MESSAGE_PAGE_SIZE',
]) {
includes(monitor, token, `message pagination includes ${token}`);
}
});
test('backend filters sessions by user id or user name in the existing endpoint', () => {
const controller = readRepo(
'src/langbot/pkg/api/http/controller/groups/monitoring.py',
);
const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py');
includes(
controller,
"quart.request.args.get('userQuery')",
'route accepts userQuery',
);
includes(controller, 'user_query=user_query', 'route forwards userQuery');
includes(
service,
'user_query: str | None = None',
'service accepts userQuery',
);
includes(
service,
'MonitoringSession.user_id.ilike',
'service searches user ids',
);
includes(
service,
'MonitoringSession.user_name.ilike',
'service searches user names',
);
});
test('stale session and message page responses cannot overwrite the latest page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'sessionRequestIdRef',
'messageRequestIdRef',
'requestId !== sessionRequestIdRef.current',
'requestId !== messageRequestIdRef.current',
'messageRequestIdRef.current += 1',
]) {
includes(monitor, token, `stale response guard includes ${token}`);
}
});
test('changing the session page or filters clears the selected detail', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared');
includes(
monitor,
'[appliedUserQuery, botId, endDate, sessionPage, startDate]',
'page and filters invalidate the selected session',
);
});
test('date filters use the operator local calendar day', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(
monitor,
'localDateBoundaryToISOString(startDate, false)',
'local start-of-day conversion',
);
includes(
monitor,
'localDateBoundaryToISOString(endDate, true)',
'local end-of-day conversion',
);
});
test('session tool calls are bounded to the visible message page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(monitor, "analysisParams.set('startTime'", 'analysis page start');
includes(monitor, "analysisParams.set('endTime'", 'analysis page end');
});