mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 16:56:02 +00:00
refactor(agent-runner): make agent binding and auth snapshot explicit
This commit is contained in:
@@ -39,6 +39,10 @@ def make_session(
|
||||
query_id: int | None = 1,
|
||||
plugin_identity: str = 'test/test-runner',
|
||||
resources: dict | None = None,
|
||||
conversation_id: str | None = None,
|
||||
permissions: dict[str, list[str]] | None = None,
|
||||
state_policy: dict[str, typing.Any] | None = None,
|
||||
state_context: dict[str, typing.Any] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Create a minimal AgentRunSession dict for testing.
|
||||
|
||||
@@ -50,13 +54,19 @@ def make_session(
|
||||
resources: AgentResources dict (uses make_resources() default if None)
|
||||
|
||||
Returns:
|
||||
AgentRunSession dict with all required fields including pre-computed _authorized_ids
|
||||
AgentRunSession dict with run-scoped authorization snapshot
|
||||
"""
|
||||
import time
|
||||
now = int(time.time())
|
||||
res = resources or make_resources()
|
||||
res = resources if resources is not None else make_resources()
|
||||
perms = permissions if permissions is not None else {}
|
||||
policy = (
|
||||
state_policy
|
||||
if state_policy is not None
|
||||
else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
|
||||
)
|
||||
context = state_context if state_context is not None else {}
|
||||
|
||||
# Pre-compute authorized IDs for O(1) lookup (matching production behavior)
|
||||
authorized_ids: dict[str, set[str]] = {
|
||||
'model': {m.get('model_id') for m in res.get('models', [])},
|
||||
'tool': {t.get('tool_name') for t in res.get('tools', [])},
|
||||
@@ -69,10 +79,16 @@ def make_session(
|
||||
'runner_id': runner_id,
|
||||
'query_id': query_id,
|
||||
'plugin_identity': plugin_identity,
|
||||
'resources': res,
|
||||
'authorization': {
|
||||
'resources': res,
|
||||
'permissions': perms,
|
||||
'conversation_id': conversation_id,
|
||||
'state_policy': policy,
|
||||
'state_context': context,
|
||||
'authorized_ids': authorized_ids,
|
||||
},
|
||||
'status': {
|
||||
'started_at': now,
|
||||
'last_activity_at': now,
|
||||
},
|
||||
'_authorized_ids': authorized_ids,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from langbot.pkg.agent.runner.session_registry import (
|
||||
AgentRunSessionRegistry,
|
||||
get_session_registry,
|
||||
)
|
||||
from .conftest import make_session
|
||||
|
||||
|
||||
class TestArtifactStore:
|
||||
@@ -210,6 +211,13 @@ class TestArtifactAuthorization:
|
||||
class TestArtifactAccessValidation:
|
||||
"""Test _validate_artifact_access authorization rules."""
|
||||
|
||||
def _make_session(self, conversation_id: str | None):
|
||||
return make_session(
|
||||
run_id="run_001",
|
||||
conversation_id=conversation_id,
|
||||
permissions={"artifacts": ["metadata", "read"]},
|
||||
)
|
||||
|
||||
def _call_validate(self, session, metadata, operation="metadata"):
|
||||
"""Helper to call the validation function."""
|
||||
from langbot.pkg.plugin.handler import _validate_artifact_access
|
||||
@@ -217,11 +225,7 @@ class TestArtifactAccessValidation:
|
||||
|
||||
def test_global_artifact_denied_by_default(self):
|
||||
"""Artifacts without conversation_id are denied by default (no global access)."""
|
||||
session = {
|
||||
"run_id": "run_001",
|
||||
"conversation_id": "conv_001",
|
||||
"permissions": {"artifacts": ["metadata", "read"]},
|
||||
}
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_global",
|
||||
"conversation_id": None, # No conversation scope
|
||||
@@ -234,11 +238,7 @@ class TestArtifactAccessValidation:
|
||||
|
||||
def test_own_run_artifact_allowed(self):
|
||||
"""Artifacts created by same run are allowed (even cross-conversation)."""
|
||||
session = {
|
||||
"run_id": "run_001",
|
||||
"conversation_id": "conv_001",
|
||||
"permissions": {"artifacts": ["metadata", "read"]},
|
||||
}
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_other", # Different conversation
|
||||
@@ -251,11 +251,7 @@ class TestArtifactAccessValidation:
|
||||
|
||||
def test_same_conversation_allowed(self):
|
||||
"""Artifacts in same conversation are allowed."""
|
||||
session = {
|
||||
"run_id": "run_001",
|
||||
"conversation_id": "conv_001",
|
||||
"permissions": {"artifacts": ["metadata", "read"]},
|
||||
}
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001", # Same as session
|
||||
@@ -268,11 +264,7 @@ class TestArtifactAccessValidation:
|
||||
|
||||
def test_different_conversation_and_run_denied(self):
|
||||
"""Artifacts in different conversation and different run are denied."""
|
||||
session = {
|
||||
"run_id": "run_001",
|
||||
"conversation_id": "conv_001",
|
||||
"permissions": {"artifacts": ["metadata", "read"]},
|
||||
}
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_other", # Different conversation
|
||||
@@ -285,11 +277,7 @@ class TestArtifactAccessValidation:
|
||||
|
||||
def test_session_without_conversation_denied_for_conversation_artifact(self):
|
||||
"""Session without conversation_id cannot access conversation-scoped artifacts."""
|
||||
session = {
|
||||
"run_id": "run_001",
|
||||
"conversation_id": None, # No conversation
|
||||
"permissions": {"artifacts": ["metadata", "read"]},
|
||||
}
|
||||
session = self._make_session(None)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001", # Has conversation
|
||||
@@ -301,11 +289,7 @@ class TestArtifactAccessValidation:
|
||||
|
||||
def test_session_without_conversation_allowed_for_own_artifact(self):
|
||||
"""Session without conversation can access artifacts it created."""
|
||||
session = {
|
||||
"run_id": "run_001",
|
||||
"conversation_id": None, # No conversation
|
||||
"permissions": {"artifacts": ["metadata", "read"]},
|
||||
}
|
||||
session = self._make_session(None)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001", # Has conversation
|
||||
@@ -431,9 +415,10 @@ class TestSessionRegistryPermissions:
|
||||
|
||||
session = await session_registry.get("run_001")
|
||||
assert session is not None
|
||||
assert session["permissions"]["artifacts"] == ["metadata", "read"]
|
||||
assert session["permissions"]["history"] == ["page"]
|
||||
assert session["permissions"]["events"] == ["get"]
|
||||
permissions = session["authorization"]["permissions"]
|
||||
assert permissions["artifacts"] == ["metadata", "read"]
|
||||
assert permissions["history"] == ["page"]
|
||||
assert permissions["events"] == ["get"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_empty_permissions(self, session_registry):
|
||||
@@ -457,7 +442,7 @@ class TestSessionRegistryPermissions:
|
||||
|
||||
session = await session_registry.get("run_002")
|
||||
assert session is not None
|
||||
assert session["permissions"] == {}
|
||||
assert session["authorization"]["permissions"] == {}
|
||||
|
||||
|
||||
class TestArtifactStoreRealSQLite:
|
||||
|
||||
@@ -97,6 +97,7 @@ class MockMessageChunk:
|
||||
self.role = 'assistant'
|
||||
self.content = content
|
||||
self.resp_message_id = resp_message_id
|
||||
self.tool_calls = []
|
||||
self.is_final = False
|
||||
|
||||
def readable_str(self):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Tests cover:
|
||||
1. Query -> AgentEventEnvelope conversion
|
||||
2. Current config -> AgentBinding conversion
|
||||
2. Current config -> AgentConfig projection and single-binding resolution
|
||||
3. AgentRunContext not inlining full history by default
|
||||
4. LangBot Host not defining context-window controls
|
||||
5. Event-first run() entry point
|
||||
@@ -32,6 +32,10 @@ from langbot_plugin.api.entities.builtin.agent_runner.permissions import (
|
||||
|
||||
# Import LangBot host models
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from langbot.pkg.agent.runner.binding_resolver import (
|
||||
AgentBindingResolver,
|
||||
AgentBindingResolutionError,
|
||||
)
|
||||
|
||||
|
||||
class TestQueryToEventEnvelope:
|
||||
@@ -127,27 +131,40 @@ class TestQueryToEventEnvelope:
|
||||
assert second.event_id != first.event_id
|
||||
|
||||
|
||||
class TestQueryConfigToBinding:
|
||||
"""Test current config -> AgentBinding conversion."""
|
||||
class TestQueryConfigToAgentConfig:
|
||||
"""Test current config projection and single-Agent binding resolution."""
|
||||
|
||||
def test_config_to_binding_runner_id(self, mock_query):
|
||||
"""Test binding runner_id extraction."""
|
||||
binding = QueryEntryAdapter.config_to_binding(
|
||||
def test_config_to_agent_config_runner_id(self, mock_query):
|
||||
"""Test AgentConfig runner_id extraction."""
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query, "plugin:author/plugin/runner"
|
||||
)
|
||||
|
||||
assert binding.runner_id == "plugin:author/plugin/runner"
|
||||
assert agent_config.runner_id == "plugin:author/plugin/runner"
|
||||
|
||||
def test_config_to_binding_scope(self, mock_query):
|
||||
"""Test binding scope extraction."""
|
||||
binding = QueryEntryAdapter.config_to_binding(
|
||||
def test_resolver_projects_agent_scope(self, mock_query):
|
||||
"""Test binding scope projection through the resolver."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query, "plugin:test/plugin/runner"
|
||||
)
|
||||
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
||||
|
||||
assert binding.scope.scope_type == "agent"
|
||||
assert binding.scope.scope_id == mock_query.pipeline_uuid
|
||||
assert binding.agent_id == mock_query.pipeline_uuid
|
||||
|
||||
def test_resolver_rejects_multiple_matching_agents(self, mock_query):
|
||||
"""Event dispatch is single-Agent in v1."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
first = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query, "plugin:test/plugin/runner"
|
||||
)
|
||||
second = first.model_copy(update={"agent_id": "agent_2"})
|
||||
|
||||
with pytest.raises(AgentBindingResolutionError):
|
||||
AgentBindingResolver().resolve_one(event, [first, second])
|
||||
|
||||
class TestAgentRunContextProtocolV1:
|
||||
"""Test AgentRunContext Protocol v1 behavior."""
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ class TestHistoryPageAuthorization:
|
||||
|
||||
session = await session_registry.get("run_1")
|
||||
assert session is not None
|
||||
assert session["conversation_id"] == "conv_1"
|
||||
assert session["authorization"]["conversation_id"] == "conv_1"
|
||||
|
||||
# Cleanup
|
||||
await session_registry.unregister("run_1")
|
||||
|
||||
@@ -22,7 +22,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
|
||||
from langbot.pkg.plugin.handler import _build_tool_detail, _get_pipeline_knowledge_base_uuids
|
||||
|
||||
# Import shared test fixtures from conftest.py
|
||||
from .conftest import make_resources
|
||||
from .conftest import make_resources, make_session
|
||||
|
||||
|
||||
class MockModel:
|
||||
@@ -1152,15 +1152,7 @@ class TestResourceTypeValidation:
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources()
|
||||
|
||||
# Create session manually for this test
|
||||
session = {
|
||||
'run_id': 'test',
|
||||
'runner_id': 'test',
|
||||
'query_id': 1,
|
||||
'plugin_identity': 'test',
|
||||
'resources': resources,
|
||||
'status': {'started_at': 0, 'last_activity_at': 0},
|
||||
}
|
||||
session = make_session(resources=resources)
|
||||
|
||||
# Unknown resource type should return False
|
||||
assert registry.is_resource_allowed(session, 'unknown_type', 'any_id') is False
|
||||
@@ -1487,15 +1479,7 @@ class TestStorageResourcePermissionHelper:
|
||||
"""is_resource_allowed handles missing storage field gracefully."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
# Create session without storage field
|
||||
session = {
|
||||
'run_id': 'test',
|
||||
'runner_id': 'test',
|
||||
'query_id': 1,
|
||||
'plugin_identity': 'test',
|
||||
'resources': {}, # No storage field
|
||||
'status': {'started_at': 0, 'last_activity_at': 0},
|
||||
}
|
||||
session = make_session(resources={})
|
||||
|
||||
# Should return False for both storage types
|
||||
assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
|
||||
|
||||
@@ -13,6 +13,7 @@ from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.errors import RunnerExecutionError
|
||||
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -327,7 +328,7 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
|
||||
session_during_run = plugin_connector.sessions_during_run[0]
|
||||
assert session_during_run is not None
|
||||
assert session_during_run["plugin_identity"] == "langbot/local-agent"
|
||||
assert session_during_run["_authorized_ids"]["tool"] == {"langbot/test-tool/search"}
|
||||
assert session_during_run["authorization"]["authorized_ids"]["tool"] == {"langbot/test-tool/search"}
|
||||
assert await get_session_registry().get(context["run_id"]) is None
|
||||
|
||||
|
||||
@@ -758,7 +759,8 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
# Note: We need to rebuild the event and binding to query the store
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
binding = QueryEntryAdapter.config_to_binding(query, RUNNER_ID)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
|
||||
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
||||
|
||||
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
|
||||
assert snapshot["conversation"]["external.test_key"] == "test_value"
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||
|
||||
@@ -48,6 +49,15 @@ def make_query(
|
||||
use_funcs: list | None = None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
query_id=1,
|
||||
bot_uuid='bot_001',
|
||||
launcher_type='person',
|
||||
launcher_id='launcher_001',
|
||||
sender_id='sender_001',
|
||||
message_event=None,
|
||||
message_chain=None,
|
||||
user_message=None,
|
||||
session=None,
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'id': RUNNER_ID},
|
||||
@@ -62,9 +72,11 @@ def make_query(
|
||||
|
||||
|
||||
async def build_resources(app, query, descriptor):
|
||||
binding = QueryEntryAdapter.config_to_binding(query, descriptor.id)
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(query, descriptor.id)
|
||||
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
||||
return await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
event=Mock(),
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
|
||||
@@ -41,8 +41,43 @@ class TestSessionRegistryBasic:
|
||||
assert result['runner_id'] == 'plugin:test/my-runner/default'
|
||||
assert result['query_id'] == 1
|
||||
assert result['plugin_identity'] == 'test/my-runner'
|
||||
assert len(result['resources']['models']) == 1
|
||||
assert result['resources']['models'][0]['model_id'] == 'model_001'
|
||||
auth_resources = result['authorization']['resources']
|
||||
assert len(auth_resources['models']) == 1
|
||||
assert auth_resources['models'][0]['model_id'] == 'model_001'
|
||||
assert 'resources' not in result
|
||||
assert 'permissions' not in result
|
||||
assert '_authorized_ids' not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_freezes_authorization_snapshot(self):
|
||||
"""Register should freeze authorization data for the run."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
models=[{'model_id': 'model_001'}],
|
||||
storage={'plugin_storage': True, 'workspace_storage': False},
|
||||
)
|
||||
|
||||
await registry.register(
|
||||
run_id='run_snapshot',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/my-runner',
|
||||
resources=resources,
|
||||
permissions={'models': ['invoke']},
|
||||
conversation_id='conv_001',
|
||||
)
|
||||
|
||||
resources['models'].append({'model_id': 'model_late'})
|
||||
resources['storage']['workspace_storage'] = True
|
||||
|
||||
session = await registry.get('run_snapshot')
|
||||
assert session is not None
|
||||
authorization = session['authorization']
|
||||
assert authorization['conversation_id'] == 'conv_001'
|
||||
assert authorization['permissions'] == {'models': ['invoke']}
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_001') is True
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_late') is False
|
||||
assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_session(self):
|
||||
@@ -91,23 +126,15 @@ class TestSessionRegistryBasic:
|
||||
|
||||
# Create session with manually set old timestamp
|
||||
now = int(time.time())
|
||||
res = make_resources()
|
||||
old_session: AgentRunSession = {
|
||||
'run_id': run_id,
|
||||
'runner_id': 'plugin:test/my-runner/default',
|
||||
'query_id': 1,
|
||||
'plugin_identity': 'test/my-runner',
|
||||
'resources': res,
|
||||
'status': {
|
||||
'started_at': now - 100, # 100 seconds ago
|
||||
'last_activity_at': now - 100, # 100 seconds ago
|
||||
},
|
||||
'_authorized_ids': {
|
||||
'model': set(),
|
||||
'tool': set(),
|
||||
'knowledge_base': set(),
|
||||
'file': set(),
|
||||
},
|
||||
old_session: AgentRunSession = make_session(
|
||||
run_id=run_id,
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/my-runner',
|
||||
)
|
||||
old_session['status'] = {
|
||||
'started_at': now - 100,
|
||||
'last_activity_at': now - 100,
|
||||
}
|
||||
|
||||
async with registry._lock:
|
||||
@@ -153,40 +180,25 @@ class TestSessionRegistryBasic:
|
||||
|
||||
# Create sessions with manually set old timestamp
|
||||
now = int(time.time())
|
||||
res = make_resources()
|
||||
old_session: AgentRunSession = {
|
||||
'run_id': 'old_run',
|
||||
'runner_id': 'plugin:test/runner/default',
|
||||
'query_id': 1,
|
||||
'plugin_identity': 'test/runner',
|
||||
'resources': res,
|
||||
'status': {
|
||||
'started_at': now - 7200, # 2 hours ago
|
||||
'last_activity_at': now - 7200, # 2 hours ago
|
||||
},
|
||||
'_authorized_ids': {
|
||||
'model': set(),
|
||||
'tool': set(),
|
||||
'knowledge_base': set(),
|
||||
'file': set(),
|
||||
},
|
||||
old_session: AgentRunSession = make_session(
|
||||
run_id='old_run',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
)
|
||||
old_session['status'] = {
|
||||
'started_at': now - 7200,
|
||||
'last_activity_at': now - 7200,
|
||||
}
|
||||
new_session: AgentRunSession = {
|
||||
'run_id': 'new_run',
|
||||
'runner_id': 'plugin:test/runner/default',
|
||||
'query_id': 2,
|
||||
'plugin_identity': 'test/runner',
|
||||
'resources': res,
|
||||
'status': {
|
||||
'started_at': now,
|
||||
'last_activity_at': now,
|
||||
},
|
||||
'_authorized_ids': {
|
||||
'model': set(),
|
||||
'tool': set(),
|
||||
'knowledge_base': set(),
|
||||
'file': set(),
|
||||
},
|
||||
new_session: AgentRunSession = make_session(
|
||||
run_id='new_run',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=2,
|
||||
plugin_identity='test/runner',
|
||||
)
|
||||
new_session['status'] = {
|
||||
'started_at': now,
|
||||
'last_activity_at': now,
|
||||
}
|
||||
|
||||
async with registry._lock:
|
||||
|
||||
@@ -342,7 +342,7 @@ class TestStateAPIFullFlowWithRealDB:
|
||||
# Verify session has correct state_context
|
||||
session = await session_registry.get('run_full_flow')
|
||||
assert session is not None
|
||||
state_ctx = session.get('state_context')
|
||||
state_ctx = session['authorization']['state_context']
|
||||
assert state_ctx is not None, f"state_context is None. Session keys: {list(session.keys())}"
|
||||
assert 'scope_keys' in state_ctx, f"scope_keys not in state_context: {state_ctx}"
|
||||
assert 'conversation' in state_ctx['scope_keys'], f"conversation not in scope_keys: {state_ctx['scope_keys']}"
|
||||
@@ -412,31 +412,31 @@ class TestStateAPIFullFlowWithRealDB:
|
||||
await session_registry.unregister('run_full_flow')
|
||||
|
||||
|
||||
class TestStateHandlerReadsFromSessionTopLevel:
|
||||
"""Tests verifying handlers read state_policy/state_context from session top-level, not resources."""
|
||||
class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
"""Tests verifying handlers read state_policy/state_context from authorization snapshot."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_handler_reads_state_policy_from_session_top_level(self, session_registry, db_engine, persistent_store):
|
||||
"""Handler reads state_policy from session['state_policy'], not session['resources']['state_policy']."""
|
||||
async def test_state_handler_reads_state_policy_from_authorization(self, session_registry, db_engine, persistent_store):
|
||||
"""Handler reads state_policy from session['authorization'], not resources."""
|
||||
fake_app = FakeApplication(db_engine)
|
||||
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
|
||||
|
||||
# Register with explicit state_policy at top level
|
||||
# Register with explicit state_policy in the authorization snapshot
|
||||
await session_registry.register(
|
||||
run_id='run_policy_top_level',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=make_resources(),
|
||||
state_policy={'enable_state': False, 'state_scopes': []}, # Disabled at top level
|
||||
state_policy={'enable_state': False, 'state_scopes': []},
|
||||
state_context={'scope_keys': {}, 'binding_identity': 'binding_1'},
|
||||
)
|
||||
|
||||
# Verify resources does NOT contain state_policy
|
||||
session = await session_registry.get('run_policy_top_level')
|
||||
assert session is not None
|
||||
assert 'state_policy' not in session.get('resources', {}), \
|
||||
"resources should NOT contain state_policy"
|
||||
resources = session['authorization']['resources']
|
||||
assert 'state_policy' not in resources, "resources should NOT contain state_policy"
|
||||
|
||||
async def fake_disconnect():
|
||||
return True
|
||||
@@ -445,7 +445,7 @@ class TestStateHandlerReadsFromSessionTopLevel:
|
||||
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
# Should fail because enable_state=False in session['state_policy']
|
||||
# Should fail because enable_state=False in authorization.state_policy
|
||||
result = await state_get_handler({
|
||||
'run_id': 'run_policy_top_level',
|
||||
'scope': 'conversation',
|
||||
@@ -459,12 +459,12 @@ class TestStateHandlerReadsFromSessionTopLevel:
|
||||
await session_registry.unregister('run_policy_top_level')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_handler_reads_state_context_from_session_top_level(self, session_registry, db_engine, persistent_store):
|
||||
"""Handler reads state_context from session['state_context'], not session['resources']['state_context']."""
|
||||
async def test_state_handler_reads_state_context_from_authorization(self, session_registry, db_engine, persistent_store):
|
||||
"""Handler reads state_context from session['authorization'], not resources."""
|
||||
fake_app = FakeApplication(db_engine)
|
||||
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
|
||||
|
||||
# Register with explicit state_context at top level
|
||||
# Register with explicit state_context in the authorization snapshot
|
||||
await session_registry.register(
|
||||
run_id='run_context_top_level',
|
||||
runner_id='plugin:test/runner/default',
|
||||
@@ -478,8 +478,8 @@ class TestStateHandlerReadsFromSessionTopLevel:
|
||||
# Verify resources does NOT contain state_context
|
||||
session = await session_registry.get('run_context_top_level')
|
||||
assert session is not None
|
||||
assert 'state_context' not in session.get('resources', {}), \
|
||||
"resources should NOT contain state_context"
|
||||
resources = session['authorization']['resources']
|
||||
assert 'state_context' not in resources, "resources should NOT contain state_context"
|
||||
|
||||
async def fake_disconnect():
|
||||
return True
|
||||
@@ -488,7 +488,7 @@ class TestStateHandlerReadsFromSessionTopLevel:
|
||||
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
|
||||
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
|
||||
|
||||
# Should use scope_key from session['state_context']['scope_keys']['conversation']
|
||||
# Should use scope_key from authorization.state_context.scope_keys.conversation
|
||||
result = await state_set_handler({
|
||||
'run_id': 'run_context_top_level',
|
||||
'scope': 'conversation',
|
||||
@@ -508,7 +508,7 @@ class TestResourcesDoesNotContainStateMetadata:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resources_clean_after_register(self, session_registry):
|
||||
"""After register(), resources should not contain state_policy or state_context."""
|
||||
"""After register(), only authorization contains resources and state metadata."""
|
||||
resources = make_resources()
|
||||
|
||||
await session_registry.register(
|
||||
@@ -524,15 +524,15 @@ class TestResourcesDoesNotContainStateMetadata:
|
||||
session = await session_registry.get('run_resources_clean')
|
||||
assert session is not None
|
||||
|
||||
# Verify resources is clean
|
||||
session_resources = session.get('resources', {})
|
||||
# Verify resources is nested under authorization and is clean.
|
||||
assert 'resources' not in session
|
||||
session_resources = session['authorization']['resources']
|
||||
assert 'state_policy' not in session_resources, \
|
||||
"session['resources'] should NOT contain state_policy"
|
||||
"authorization['resources'] should NOT contain state_policy"
|
||||
assert 'state_context' not in session_resources, \
|
||||
"session['resources'] should NOT contain state_context"
|
||||
"authorization['resources'] should NOT contain state_context"
|
||||
|
||||
# Verify state metadata is at top level
|
||||
assert 'state_policy' in session
|
||||
assert 'state_context' in session
|
||||
assert 'state_policy' in session['authorization']
|
||||
assert 'state_context' in session['authorization']
|
||||
|
||||
await session_registry.unregister('run_resources_clean')
|
||||
|
||||
Reference in New Issue
Block a user