feat(tenancy): implement workspace isolation

This commit is contained in:
Junyan Qin
2026-07-19 09:58:59 +08:00
parent 9eb292992d
commit c6f826fe2d
271 changed files with 31162 additions and 6106 deletions
@@ -0,0 +1,114 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
BOT_A = '10000000-0000-0000-0000-00000000000a'
BOT_B = '10000000-0000-0000-0000-00000000000b'
def _context(workspace_uuid: str, bot_uuid: str, generation: int = 4) -> ExecutionContext:
return ExecutionContext(
instance_uuid='instance',
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=bot_uuid,
)
def _runtime(application, workspace_uuid: str, bot_uuid: str) -> RuntimeBot:
entity = SimpleNamespace(
uuid=bot_uuid,
workspace_uuid=workspace_uuid,
name='Same Name',
enable=True,
pipeline_routing_rules=[],
use_pipeline_uuid=None,
)
return RuntimeBot(
ap=application,
bot_entity=entity,
adapter=SimpleNamespace(),
logger=SimpleNamespace(),
execution_context=_context(workspace_uuid, bot_uuid),
)
class _WorkspaceService:
async def get_execution_binding(self, workspace_uuid, expected_generation=None):
if workspace_uuid not in {WORKSPACE_A, WORKSPACE_B} or expected_generation != 4:
raise ValueError('stale')
return SimpleNamespace(
instance_uuid='instance',
workspace_uuid=workspace_uuid,
placement_generation=4,
)
@pytest.fixture
def manager():
application = SimpleNamespace(workspace_service=_WorkspaceService())
platform_manager = PlatformManager(application)
platform_manager.bots = [
_runtime(application, WORKSPACE_A, BOT_A),
_runtime(application, WORKSPACE_B, BOT_B),
]
return platform_manager
@pytest.mark.asyncio
async def test_runtime_lookup_cannot_guess_another_workspace_bot(manager):
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A), BOT_A) is manager.bots[0]
assert await manager.get_bot_by_uuid(_context(WORKSPACE_B, BOT_A), BOT_A) is None
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_B), BOT_B) is None
@pytest.mark.asyncio
async def test_public_route_key_resolves_bound_runtime_and_rejects_non_opaque_input(manager):
assert await manager.resolve_public_bot(BOT_A) is manager.bots[0]
assert await manager.resolve_public_bot('Same Name') is None
assert await manager.resolve_public_bot('not-a-uuid') is None
@pytest.mark.asyncio
async def test_stale_runtime_generation_is_not_returned(manager):
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A, generation=5), BOT_A) is None
@pytest.mark.asyncio
async def test_runtime_bot_revalidates_its_generation_before_handling_events(manager):
runtime_bot = manager.bots[0]
await runtime_bot.assert_execution_active()
runtime_bot.placement_generation = 5
with pytest.raises(ValueError, match='stale'):
await runtime_bot.assert_execution_active()
def test_runtime_bot_rejects_workspace_mismatch():
application = SimpleNamespace()
entity = SimpleNamespace(
uuid=BOT_A,
workspace_uuid=WORKSPACE_A,
name='Bot',
enable=True,
pipeline_routing_rules=[],
use_pipeline_uuid=None,
)
with pytest.raises(WorkspaceRequiredError):
RuntimeBot(
ap=application,
bot_entity=entity,
adapter=SimpleNamespace(),
logger=SimpleNamespace(),
execution_context=_context(WORKSPACE_B, BOT_A),
)
@@ -0,0 +1,66 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
def _session(key):
session = SimpleNamespace()
session._langbot_session_key = key
return session
def _adapter(app, execution_context) -> HttpBotAdapter:
adapter = HttpBotAdapter.model_construct(
config={'signature_required': False},
logger=SimpleNamespace(execution_context=execution_context),
bot_uuid='bot-a',
outbound_states={},
idempotency_cache={},
sync_waiters={},
)
object.__setattr__(adapter, 'ap', app)
return adapter
@pytest.mark.asyncio
async def test_http_bot_reset_removes_only_exact_execution_scope():
context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=3,
bot_uuid='bot-a',
)
target_key = ('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'shared-session')
retained_keys = [
('instance-b', 'workspace-a', 3, 'bot-a', 'person', 'shared-session'),
('instance-a', 'workspace-b', 3, 'bot-a', 'person', 'shared-session'),
('instance-a', 'workspace-a', 4, 'bot-a', 'person', 'shared-session'),
('instance-a', 'workspace-a', 3, 'bot-b', 'person', 'shared-session'),
('instance-a', 'workspace-a', 3, 'bot-a', 'group', 'shared-session'),
('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'other-session'),
]
sessions = [_session(target_key), *[_session(key) for key in retained_keys], SimpleNamespace()]
app = SimpleNamespace(sess_mgr=SimpleNamespace(session_list=sessions))
adapter = _adapter(app, context)
removed = await adapter._reset_session('person', 'shared-session')
assert removed is True
assert [getattr(session, '_langbot_session_key', None) for session in app.sess_mgr.session_list] == [
*retained_keys,
None,
]
@pytest.mark.asyncio
async def test_http_bot_reset_fails_closed_without_trusted_scope():
app = SimpleNamespace(sess_mgr=SimpleNamespace(session_list=[]))
adapter = _adapter(app, None)
with pytest.raises(RuntimeError, match='trusted execution scope'):
await adapter._reset_session('person', 'shared-session')
@@ -0,0 +1,84 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.sources.openclaw_weixin import OpenClawWeixinAdapter
def make_adapter(*, execution_context: ExecutionContext | None):
app = SimpleNamespace(
persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
workspace_service=SimpleNamespace(
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
)
),
)
logger = SimpleNamespace(
ap=app,
execution_context=execution_context,
warning=AsyncMock(),
)
adapter = OpenClawWeixinAdapter.model_construct(
config={'token': 'refreshed-token'},
logger=logger,
client=Mock(),
bot_account_id='',
listeners={},
name='openclaw-weixin',
)
adapter._bot_uuid = 'shared-bot-uuid'
return adapter, app, logger
@pytest.mark.asyncio
async def test_persist_config_scopes_duplicate_bot_uuid_to_workspace():
adapter, app, _ = make_adapter(
execution_context=ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
bot_uuid='shared-bot-uuid',
)
)
await adapter._persist_config()
app.workspace_service.get_execution_binding.assert_awaited_once_with(
'workspace-a',
expected_generation=1,
)
statement = app.persistence_mgr.execute_async.await_args.args[0]
params = statement.compile().params
assert 'workspace-a' in params.values()
assert 'shared-bot-uuid' in params.values()
assert {'workspace_uuid', 'uuid'} <= {comparison.left.name for comparison in statement._where_criteria}
@pytest.mark.asyncio
@pytest.mark.parametrize(
'execution_context',
[
None,
ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
bot_uuid='another-bot-uuid',
),
],
ids=['missing-context', 'mismatched-bot'],
)
async def test_persist_config_fails_closed_without_matching_execution_context(execution_context):
adapter, app, logger = make_adapter(execution_context=execution_context)
await adapter._persist_config()
app.persistence_mgr.execute_async.assert_not_awaited()
logger.warning.assert_awaited_once()
@@ -278,3 +278,19 @@ class TestResolvePipelineUuid:
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
assert uuid == 'default-uuid'
assert routed is False
def test_websocket_task_override_does_not_mutate_bot_default(self):
bot = self._make_bot('default-uuid', [])
adapter = Mock()
adapter.get_pipeline_uuid_override.return_value = 'connection-pipeline'
pipeline_uuid, routed = bot.resolve_event_pipeline_uuid(
adapter,
'person',
'launcher',
'hello',
)
assert pipeline_uuid == 'connection-pipeline'
assert routed is False
assert bot.bot_entity.use_pipeline_uuid == 'default-uuid'
@@ -4,89 +4,114 @@ The web debug client uploads Image / Voice / File components carrying a storage
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
LLM input and the Box sandbox inbox have usable bytes), then deletes the
consumed storage object and clears ``path``. Covers mimetype selection per
type and graceful error handling.
type and fail-closed error handling.
"""
from __future__ import annotations
import base64
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter
_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
pipeline_uuid='pipeline-a',
)
_UPLOAD_PREFIX = 'v1/instance-a/workspace-a/1/upload_image/'
def _make_connection():
return SimpleNamespace(execution_context=_CONTEXT)
def _make_adapter(load_return=b'hello', load_side_effect=None):
provider = Mock()
provider.load = AsyncMock(return_value=load_return, side_effect=load_side_effect)
provider.delete = AsyncMock()
storage_mgr = Mock()
storage_mgr.storage_provider = provider
storage_mgr.scoped_prefix.return_value = _UPLOAD_PREFIX
storage_mgr.is_scoped_object_key.return_value = True
storage_mgr.delete_scoped_object_key = AsyncMock()
ap = Mock()
ap.storage_mgr.storage_provider = provider
ap.storage_mgr = storage_mgr
logger = Mock()
logger.error = AsyncMock()
logger.warning = AsyncMock()
# WebSocketAdapter is a pydantic model; bypass full __init__/validation.
adapter = WebSocketAdapter.model_construct(ap=ap, logger=logger)
return adapter, provider
return adapter, storage_mgr, provider
@pytest.mark.asyncio
async def test_image_jpeg_mimetype_and_cleanup():
adapter, provider = _make_adapter(load_return=b'\xff\xd8\xff')
chain = [{'type': 'Image', 'path': 'storage://abc/photo.jpg'}]
adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
path = f'{_UPLOAD_PREFIX}photo.jpg'
chain = [{'type': 'Image', 'path': path}]
await adapter._process_image_components(chain)
await adapter._process_image_components(_make_connection(), chain)
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
assert chain[0]['path'] == '' # consumed
provider.delete.assert_awaited_once_with('storage://abc/photo.jpg')
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
_CONTEXT,
path,
expected_owner_type='upload_image',
)
@pytest.mark.asyncio
async def test_image_defaults_to_png():
adapter, _ = _make_adapter()
chain = [{'type': 'Image', 'path': 'storage://abc/blob'}]
await adapter._process_image_components(chain)
adapter, _, _ = _make_adapter()
chain = [{'type': 'Image', 'path': f'{_UPLOAD_PREFIX}blob'}]
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:image/png;base64,')
@pytest.mark.asyncio
async def test_voice_uses_guessed_or_wav_mimetype():
adapter, _ = _make_adapter()
chain = [{'type': 'Voice', 'path': 'storage://abc/clip.wav'}]
await adapter._process_image_components(chain)
adapter, _, _ = _make_adapter()
chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:audio/')
@pytest.mark.asyncio
async def test_file_uses_octet_stream_fallback():
adapter, _ = _make_adapter()
chain = [{'type': 'File', 'path': 'storage://abc/unknownblob'}]
await adapter._process_image_components(chain)
adapter, _, _ = _make_adapter()
chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}]
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
@pytest.mark.asyncio
async def test_skips_components_without_path_or_unknown_type():
adapter, provider = _make_adapter()
adapter, _, provider = _make_adapter()
chain = [
{'type': 'Image', 'path': ''}, # no path
{'type': 'Plain', 'path': 'storage://abc/x'}, # not a file component
{'type': 'At', 'target': '123'}, # no path key at all
]
await adapter._process_image_components(chain)
await adapter._process_image_components(_make_connection(), chain)
provider.load.assert_not_awaited()
assert 'base64' not in chain[0]
assert 'base64' not in chain[1]
@pytest.mark.asyncio
async def test_load_failure_is_logged_not_raised():
adapter, _ = _make_adapter(load_side_effect=RuntimeError('storage down'))
chain = [{'type': 'File', 'path': 'storage://abc/doc.pdf'}]
async def test_load_failure_is_logged_and_aborts_processing():
adapter, _, _ = _make_adapter(load_side_effect=RuntimeError('storage down'))
chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}doc.pdf'}]
# must not raise
await adapter._process_image_components(chain)
with pytest.raises(RuntimeError, match='storage down'):
await adapter._process_image_components(_make_connection(), chain)
assert 'base64' not in chain[0]
adapter.logger.error.assert_awaited_once()
@@ -9,7 +9,25 @@ import pytest
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
from langbot.pkg.platform.sources.websocket_manager import WebSocketConnectionManager, is_valid_session_id
from langbot.pkg.platform.sources.websocket_manager import (
WebSocketConnectionManager,
WebSocketScope,
is_valid_session_id,
)
SCOPE_A = WebSocketScope('instance-a', 'workspace-a', 1)
SCOPE_B = WebSocketScope('instance-a', 'workspace-b', 1)
def _adapter_logger(scope: WebSocketScope = SCOPE_A):
logger = AsyncMock()
logger.execution_context = Mock(
instance_uuid=scope.instance_uuid,
workspace_uuid=scope.workspace_uuid,
placement_generation=scope.placement_generation,
)
return logger
@pytest.mark.asyncio
@@ -17,18 +35,21 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
manager = WebSocketConnectionManager()
first = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id='session-a',
)
second = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id='session-b',
)
dashboard = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
)
@@ -36,6 +57,7 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
await manager.broadcast_to_pipeline(
'pipeline-1',
{'type': 'response'},
scope=SCOPE_A,
session_type='person',
session_id='session-a',
)
@@ -47,6 +69,7 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
await manager.broadcast_to_pipeline(
'pipeline-1',
{'type': 'dashboard-response'},
scope=SCOPE_A,
session_type='person',
session_id=None,
)
@@ -56,19 +79,49 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
assert second.send_queue.empty()
@pytest.mark.asyncio
async def test_pipeline_indexes_and_broadcasts_are_workspace_scoped():
manager = WebSocketConnectionManager()
workspace_a = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='shared-pipeline',
session_type='person',
)
workspace_b = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_B,
pipeline_uuid='shared-pipeline',
session_type='person',
)
await manager.broadcast_to_pipeline(
'shared-pipeline',
{'type': 'workspace-a'},
scope=SCOPE_A,
)
assert await workspace_a.send_queue.get() == {'type': 'workspace-a'}
assert workspace_b.send_queue.empty()
assert await manager.get_connection(workspace_b.connection_id, scope=SCOPE_A) is None
assert await manager.get_connection(workspace_b.connection_id, scope=SCOPE_B) is workspace_b
assert manager.get_stats(scope=SCOPE_A)['total_connections'] == 1
@pytest.mark.asyncio
async def test_embed_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager()
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
connection = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
received = []
@@ -92,13 +145,14 @@ async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
connection = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='group',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
received = []
@@ -118,6 +172,7 @@ async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
dashboard = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='group',
)
@@ -138,30 +193,46 @@ async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-2',
session_type='person',
session_id=session_id,
)
connection = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
message_source = Mock()
message_source.sender.id = f'websocket_pipeline-1:{session_id}'
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
assert await adapter._get_connection_from_target(f'websocketgroup_pipeline-1:{session_id}') is connection
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is connection
assert (
await manager.get_connection_by_session_id(
session_id,
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
)
is connection
)
await manager.remove_connection(connection.connection_id)
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is None
assert (
await manager.get_connection_by_session_id(
session_id,
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
)
is None
)
def test_session_ids_must_be_canonical_random_uuids():
@@ -171,7 +242,7 @@ def test_session_ids_must_be_canonical_random_uuids():
def test_history_read_does_not_allocate_unknown_session():
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
@@ -179,16 +250,70 @@ def test_history_read_does_not_allocate_unknown_session():
assert adapter.websocket_person_session.message_lists == {}
@pytest.mark.asyncio
async def test_attachment_key_must_belong_to_connection_upload_scope():
manager = WebSocketConnectionManager()
connection = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
)
storage_mgr = Mock()
storage_mgr.scoped_prefix.return_value = 'v1/current/upload_image/'
storage_mgr.is_scoped_object_key.return_value = True
storage_mgr.storage_provider.load = AsyncMock(return_value=b'image')
storage_mgr.delete_scoped_object_key = AsyncMock()
adapter = WebSocketAdapter.model_construct(
ap=Mock(storage_mgr=storage_mgr),
logger=_adapter_logger(),
)
message_chain = [{'type': 'Image', 'path': 'v1/current/upload_image/key.png'}]
await adapter._process_image_components(connection, message_chain)
assert message_chain[0]['base64'].startswith('data:image/png;base64,')
assert message_chain[0]['path'] == ''
storage_mgr.scoped_prefix.assert_called_once_with(
connection.execution_context,
owner_type='upload_image',
)
storage_mgr.is_scoped_object_key.assert_called_once_with(
'v1/current/upload_image/key.png',
expected_owner_type='upload_image',
)
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
connection.execution_context,
'v1/current/upload_image/key.png',
expected_owner_type='upload_image',
)
with pytest.raises(ValueError, match='does not belong'):
await adapter._process_image_components(
connection,
[{'type': 'File', 'path': 'v1/other/upload/key.txt'}],
)
def test_history_and_reset_are_scoped_to_browser_session():
matching_provider_session = Mock(
instance_uuid=SCOPE_A.instance_uuid,
workspace_uuid=SCOPE_A.workspace_uuid,
placement_generation=SCOPE_A.placement_generation,
launcher_type=Mock(value='person'),
launcher_id='websocket_pipeline-1:session-a',
)
matching_group_provider_session = Mock(
instance_uuid=SCOPE_A.instance_uuid,
workspace_uuid=SCOPE_A.workspace_uuid,
placement_generation=SCOPE_A.placement_generation,
launcher_type=Mock(value='group'),
launcher_id='websocketgroup_pipeline-1:session-a',
)
other_session = Mock(
instance_uuid=SCOPE_A.instance_uuid,
workspace_uuid=SCOPE_A.workspace_uuid,
placement_generation=SCOPE_A.placement_generation,
launcher_type=Mock(value='person'),
launcher_id='websocket_pipeline-1:session-b',
)
@@ -200,7 +325,7 @@ def test_history_and_reset_are_scoped_to_browser_session():
]
adapter = WebSocketAdapter.model_construct(
ap=ap,
logger=AsyncMock(),
logger=_adapter_logger(),
)
adapter.websocket_person_session = Mock()
adapter.websocket_group_session = Mock()