mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-27 02:45:24 +08:00
chore(merge): sync master and preserve monitoring test coverage
This commit is contained in:
@@ -580,3 +580,4 @@ class TestChatHandlerAsyncBehavior:
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
assert len(query.resp_messages) == 1
|
||||
assert query.resp_messages[0].content == 'Reply from plugin'
|
||||
|
||||
@@ -303,6 +303,10 @@ class TestRunnerContextProtocolV1:
|
||||
|
||||
assert ctx.event is not None
|
||||
assert ctx.event.event_type == 'message.received'
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError, match='event'):
|
||||
RunnerContext.model_validate(ctx.model_dump(exclude={'event'}))
|
||||
|
||||
def test_sdk_context_has_no_history_message_fields(self):
|
||||
"""RunnerContext should not expose inline history message fields."""
|
||||
@@ -332,16 +336,6 @@ class TestRunnerContextProtocolV1:
|
||||
assert not hasattr(ctx, 'bootstrap')
|
||||
|
||||
|
||||
class TestHostManagedHistoryNotInProtocol:
|
||||
"""Test that Host-managed history payloads are not in Protocol v1."""
|
||||
|
||||
def test_messages_not_in_sdk_context_top_level(self):
|
||||
"""RunnerContext should not expose top-level history messages."""
|
||||
ctx_fields = RunnerContext.model_fields.keys()
|
||||
|
||||
assert 'messages' not in ctx_fields
|
||||
|
||||
|
||||
class TestSDKResultProtocolV1:
|
||||
"""Test SDK RunnerResult for Protocol v1."""
|
||||
|
||||
@@ -355,6 +349,8 @@ class TestSDKResultProtocolV1:
|
||||
)
|
||||
|
||||
assert result.run_id == 'run_1'
|
||||
with pytest.raises(TypeError, match='run_id'):
|
||||
RunnerResult.message_completed(message=Message(role='assistant', content='Hello'))
|
||||
|
||||
|
||||
# Fixtures
|
||||
|
||||
@@ -139,30 +139,31 @@ class TestTranscriptStore:
|
||||
"""Test TranscriptStore operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_transcript(self, mock_db_engine):
|
||||
async def test_append_transcript(self):
|
||||
"""Test appending a transcript item."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from uuid import UUID
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
# Mock _get_next_seq
|
||||
with patch.object(store, '_get_next_seq', return_value=1):
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
transcript_id = await store.append_transcript(
|
||||
transcript_id=None, # Auto-generate
|
||||
event_id='evt_1',
|
||||
conversation_id='conv_1',
|
||||
role='user',
|
||||
content='Hello',
|
||||
)
|
||||
|
||||
assert transcript_id is not None
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
store = TranscriptStore(engine)
|
||||
transcript_id = await store.append_transcript(
|
||||
transcript_id=None,
|
||||
event_id='evt_1',
|
||||
conversation_id='conv_1',
|
||||
role='user',
|
||||
content='Hello',
|
||||
)
|
||||
assert UUID(transcript_id).version == 4
|
||||
items, _, _, _ = await store.page_transcript('conv_1')
|
||||
assert [(item['transcript_id'], item['event_id'], item['role'], item['content']) for item in items] == [
|
||||
(transcript_id, 'evt_1', 'user', 'Hello')
|
||||
]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestEventLogStoreRealSQLite:
|
||||
@@ -258,6 +259,7 @@ class TestEventLogStoreRealSQLite:
|
||||
async def test_get_latest_cursor(self, db_engine):
|
||||
"""Test get_latest_cursor with real DB."""
|
||||
store = EventLogStore(db_engine)
|
||||
assert await store.get_latest_cursor('conv_cursor') is None
|
||||
|
||||
# Append events
|
||||
for i in range(3):
|
||||
@@ -270,8 +272,12 @@ class TestEventLogStoreRealSQLite:
|
||||
|
||||
# Get latest cursor
|
||||
cursor = await store.get_latest_cursor('conv_cursor')
|
||||
assert cursor is not None
|
||||
assert int(cursor) > 0
|
||||
assert cursor == '3'
|
||||
await store.append_event(
|
||||
event_id='other', event_type='message.received', source='platform', conversation_id='other'
|
||||
)
|
||||
assert await store.get_latest_cursor('conv_cursor') == '3'
|
||||
assert await store.get_latest_cursor('other') == '4'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_events_older_than(self, db_engine):
|
||||
|
||||
@@ -205,14 +205,43 @@ def official_plugin_root():
|
||||
@pytest.mark.parametrize(
|
||||
'runner,folder,method,error_name,local_identity',
|
||||
[
|
||||
('DifyAgent', 'dify-agent', '_get_user_tag', 'DifyConfigError', 'person_Session_007'),
|
||||
('N8nAgent', 'n8n-agent', '_get_user_tag', 'N8nConfigError', 'person_Session_007'),
|
||||
('CozeAgent', 'coze-agent', '_get_user_id', 'CozeConfigError', 'person_launcher-123'),
|
||||
('TboxAgent', 'tbox-agent', '_get_user_id', 'TboxConfigError', 'bot-uuid-123'),
|
||||
pytest.param(
|
||||
'DifyAgent',
|
||||
'dify-agent',
|
||||
'_get_user_tag',
|
||||
'DifyConfigError',
|
||||
'person_Session_007',
|
||||
marks=pytest.mark.contract('dify_identity'),
|
||||
),
|
||||
pytest.param(
|
||||
'N8nAgent',
|
||||
'n8n-agent',
|
||||
'_get_user_tag',
|
||||
'N8nConfigError',
|
||||
'person_Session_007',
|
||||
marks=pytest.mark.contract('n8n_identity'),
|
||||
),
|
||||
pytest.param(
|
||||
'CozeAgent',
|
||||
'coze-agent',
|
||||
'_get_user_id',
|
||||
'CozeConfigError',
|
||||
'person_launcher-123',
|
||||
marks=pytest.mark.contract('coze_identity'),
|
||||
),
|
||||
pytest.param(
|
||||
'TboxAgent',
|
||||
'tbox-agent',
|
||||
'_get_user_id',
|
||||
'TboxConfigError',
|
||||
'bot-uuid-123',
|
||||
marks=pytest.mark.contract('tbox_identity'),
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize('missing', [False, True], ids=['trusted-identity', 'missing-identity'])
|
||||
@pytest.mark.parametrize('profile', ['dedicated', 'shared'])
|
||||
@pytest.mark.plugin_identity
|
||||
async def test_real_plugin_identity_helper_with_host_payload(
|
||||
mock_query, monkeypatch, official_plugin_root, runner, folder, method, error_name, local_identity, missing, profile
|
||||
):
|
||||
|
||||
@@ -789,6 +789,13 @@ async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event(
|
||||
|
||||
assert [message.content for message in messages] == ['state saved']
|
||||
# State is persisted to the database via PersistentStateStore.
|
||||
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
|
||||
binding = AgentBindingResolver().resolve_one(event, [config])
|
||||
snapshot = await get_persistent_state_store(db_engine).build_snapshot_from_event(event, binding, descriptor)
|
||||
assert snapshot['conversation'].get('external.conversation_id') == 'external_conv_123'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -124,6 +124,7 @@ class TestRegistryDiscovery:
|
||||
async def test_discover_caches_results(self):
|
||||
"""Discovery should cache results."""
|
||||
ap = FakeApplication()
|
||||
ap.plugin_connector.list_runners = AsyncMock(wraps=ap.plugin_connector.list_runners)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
# First discovery
|
||||
@@ -134,6 +135,11 @@ class TestRegistryDiscovery:
|
||||
|
||||
assert registry._cache is not None
|
||||
assert len(runners1) == len(runners2)
|
||||
ap.plugin_connector.list_runners.assert_awaited_once()
|
||||
assert [runner.id for runner in runners2] == [
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
'plugin:alice/my-agent/custom',
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_handles_plugin_disabled(self):
|
||||
|
||||
@@ -156,6 +156,7 @@ class TestNormalizeRunCompleted:
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, provider_message.Message)
|
||||
assert (result.role, result.content) == ('assistant', 'Final response')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_run_completed_without_message(self):
|
||||
|
||||
@@ -175,6 +175,22 @@ async def _create_run(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ledger_snapshot(db_engine):
|
||||
await _create_run(
|
||||
db_engine, run_id='workspace_guard', workspace_id='workspace_guard', plugin_identity='guard/runner'
|
||||
)
|
||||
|
||||
async def snapshot():
|
||||
async with AsyncSession(db_engine) as session:
|
||||
return [
|
||||
(await session.execute(sqlalchemy.select(*model.__table__.c).order_by(model.id))).mappings().all()
|
||||
for model in (agent_run_model.AgentRun, agent_run_model.AgentRunEvent)
|
||||
]
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_get_returns_current_run(session_registry, db_engine):
|
||||
await _register_session(session_registry, available_apis={'run_get': True})
|
||||
@@ -264,6 +280,7 @@ async def test_run_list_filters_same_scope_different_runner_owner(session_regist
|
||||
async def test_non_admin_target_run_actions_reject_same_scope_different_runner_owner(
|
||||
session_registry,
|
||||
db_engine,
|
||||
ledger_snapshot,
|
||||
):
|
||||
await _register_session(
|
||||
session_registry,
|
||||
@@ -351,10 +368,12 @@ async def test_non_admin_target_run_actions_reject_same_scope_different_runner_o
|
||||
),
|
||||
]
|
||||
|
||||
before = await ledger_snapshot()
|
||||
for action, payload in calls:
|
||||
result = await handler.actions[action](payload)
|
||||
assert result.code != 0, action
|
||||
assert 'not accessible' in result.message.lower()
|
||||
assert await ledger_snapshot() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1092,11 +1111,12 @@ async def test_run_append_result_uses_cas_and_redacts_metadata(session_registry,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine):
|
||||
async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine, ledger_snapshot):
|
||||
await _register_session(session_registry, available_apis={'run_append_result': True})
|
||||
await _create_run(db_engine)
|
||||
handler = _handler(db_engine)
|
||||
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
|
||||
before = await ledger_snapshot()
|
||||
|
||||
result = await run_append_result(
|
||||
{
|
||||
@@ -1112,14 +1132,16 @@ async def test_run_append_result_rejects_side_effecting_result_type(session_regi
|
||||
|
||||
assert result.code != 0
|
||||
assert 'canonical runner result path' in result.message
|
||||
assert await ledger_snapshot() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine):
|
||||
async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine, ledger_snapshot):
|
||||
await _register_session(session_registry, available_apis={'run_append_result': True})
|
||||
await _create_run(db_engine)
|
||||
handler = _handler(db_engine)
|
||||
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
|
||||
before = await ledger_snapshot()
|
||||
|
||||
result = await run_append_result(
|
||||
{
|
||||
@@ -1135,14 +1157,16 @@ async def test_run_append_result_rejects_unknown_result_type(session_registry, d
|
||||
|
||||
assert result.code != 0
|
||||
assert 'unknown result type' in result.message
|
||||
assert await ledger_snapshot() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_append_result_validates_known_payloads(session_registry, db_engine):
|
||||
async def test_run_append_result_validates_known_payloads(session_registry, db_engine, ledger_snapshot):
|
||||
await _register_session(session_registry, available_apis={'run_append_result': True})
|
||||
await _create_run(db_engine)
|
||||
handler = _handler(db_engine)
|
||||
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
|
||||
before = await ledger_snapshot()
|
||||
|
||||
result = await run_append_result(
|
||||
{
|
||||
@@ -1158,10 +1182,13 @@ async def test_run_append_result_validates_known_payloads(session_registry, db_e
|
||||
|
||||
assert result.code != 0
|
||||
assert 'invalid action.requested payload' in result.message
|
||||
assert await ledger_snapshot() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_append_and_finalize_claimed_target_require_active_claim(session_registry, db_engine):
|
||||
async def test_run_append_and_finalize_claimed_target_require_active_claim(
|
||||
session_registry, db_engine, ledger_snapshot
|
||||
):
|
||||
await _register_session(
|
||||
session_registry,
|
||||
available_apis={'run_append_result': True, 'run_finalize': True},
|
||||
@@ -1185,6 +1212,7 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
|
||||
handler = _handler(db_engine)
|
||||
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
|
||||
run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value]
|
||||
before = await ledger_snapshot()
|
||||
|
||||
missing_claim = await run_append_result(
|
||||
{
|
||||
@@ -1198,6 +1226,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
|
||||
},
|
||||
}
|
||||
)
|
||||
assert missing_claim.code != 0
|
||||
assert 'active claim ownership' in missing_claim.message
|
||||
assert await ledger_snapshot() == before
|
||||
wrong_claim = await run_finalize(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
@@ -1208,6 +1239,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
|
||||
'status': 'completed',
|
||||
}
|
||||
)
|
||||
assert wrong_claim.code != 0
|
||||
assert 'claim ownership is not active' in wrong_claim.message
|
||||
assert await ledger_snapshot() == before
|
||||
appended = await run_append_result(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
@@ -1233,10 +1267,6 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
|
||||
}
|
||||
)
|
||||
|
||||
assert missing_claim.code != 0
|
||||
assert 'active claim ownership' in missing_claim.message
|
||||
assert wrong_claim.code != 0
|
||||
assert 'claim ownership is not active' in wrong_claim.message
|
||||
assert appended.code == 0
|
||||
assert finalized.code == 0
|
||||
assert finalized.data['status'] == 'completed'
|
||||
@@ -1471,7 +1501,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine):
|
||||
async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine, ledger_snapshot):
|
||||
await _register_session(
|
||||
session_registry,
|
||||
available_apis={'run_renew_claim': True, 'run_release_claim': True},
|
||||
@@ -1493,6 +1523,7 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
|
||||
handler = _handler(db_engine)
|
||||
run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value]
|
||||
run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value]
|
||||
before = await ledger_snapshot()
|
||||
|
||||
renewed = await run_renew_claim(
|
||||
{
|
||||
@@ -1503,6 +1534,9 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
|
||||
'claim_token': claim['claim_token'],
|
||||
}
|
||||
)
|
||||
assert renewed.code != 0
|
||||
assert 'not accessible' in renewed.message.lower()
|
||||
assert await ledger_snapshot() == before
|
||||
released = await run_release_claim(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
@@ -1513,14 +1547,13 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
|
||||
}
|
||||
)
|
||||
|
||||
assert renewed.code != 0
|
||||
assert 'not accessible' in renewed.message.lower()
|
||||
assert released.code != 0
|
||||
assert 'not accessible' in released.message.lower()
|
||||
assert await ledger_snapshot() == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine):
|
||||
async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine, ledger_snapshot):
|
||||
await _register_session(
|
||||
session_registry,
|
||||
available_apis={'run_claim': True, 'run_release_claim': True},
|
||||
@@ -1549,6 +1582,7 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_
|
||||
}
|
||||
)
|
||||
assert claimed.code == 0
|
||||
before = await ledger_snapshot()
|
||||
|
||||
released = await run_release_claim(
|
||||
{
|
||||
@@ -1563,3 +1597,4 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_
|
||||
|
||||
assert released.code != 0
|
||||
assert 'use run_finalize' in released.message
|
||||
assert await ledger_snapshot() == before
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
@@ -132,6 +133,24 @@ class TestStateScopeHelpers:
|
||||
assert keys['subject'].startswith('subject:v2:')
|
||||
assert keys['runner'].startswith('runner:v2:')
|
||||
assert len(set(keys.values())) == len(keys)
|
||||
for dimension, value in [
|
||||
('workspace_id', 'ws_other'),
|
||||
('bot_id', 'bot_other'),
|
||||
]:
|
||||
other = deepcopy(event)
|
||||
setattr(other, dimension, value)
|
||||
for scope in VALID_STATE_SCOPES:
|
||||
assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope]
|
||||
for scope, dimension, value in [
|
||||
('conversation', 'conversation_id', 'conv_other'),
|
||||
('conversation', 'thread_id', 'thread_other'),
|
||||
('actor', 'actor_id', 'user_other'),
|
||||
('subject', 'subject_id', 'msg_other'),
|
||||
]:
|
||||
other = deepcopy(event)
|
||||
owner = other.actor if scope == 'actor' else other.subject if scope == 'subject' else other
|
||||
setattr(owner, dimension, value)
|
||||
assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope]
|
||||
|
||||
def test_scope_key_missing_identity_returns_none(self):
|
||||
descriptor = make_descriptor()
|
||||
|
||||
Reference in New Issue
Block a user