fix(skill): reconcile rebase with runner contracts

This commit is contained in:
huanghuoguoguo
2026-09-28 10:54:31 +08:00
parent 38a7c10d04
commit c56fa0ba2e
14 changed files with 152 additions and 35 deletions
@@ -1007,7 +1007,7 @@ class TestQueryEntrySessionQueryId:
@pytest.mark.asyncio
async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state):
"""Pure event-first flow has query_id=None in session."""
"""Pure event-first flow restores authorized Skill revisions without a Query."""
from langbot.pkg.agent.runner.host_models import (
AgentEventEnvelope,
AgentBinding,
@@ -1018,6 +1018,8 @@ class TestQueryEntrySessionQueryId:
)
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILL_NAMES_STATE_KEY, ACTIVATED_SKILLS_KEY
db_engine = clean_agent_state
descriptor = make_descriptor()
@@ -1030,6 +1032,10 @@ class TestQueryEntrySessionQueryId:
]
)
ap = FakeApplication(plugin_connector, db_engine)
revision = 'sha256:' + '1' * 64
ap.skill_repository = types.SimpleNamespace(
get_skill=AsyncMock(return_value={'name': 'demo', 'revision': revision})
)
async def build_resource_context(execution_query):
from langbot.pkg.provider.tools.loaders.mcp import (
@@ -1085,10 +1091,21 @@ class TestQueryEntrySessionQueryId:
'mcp-resource-agent-read-enabled': True,
},
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']),
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
)
success, error = await get_persistent_state_store(db_engine).apply_update_from_event(
event.model_copy(update={'workspace_id': TEST_CONTEXT.workspace_uuid}),
binding,
descriptor,
'conversation',
ACTIVATED_SKILL_NAMES_STATE_KEY,
[{'name': 'demo', 'revision': revision}],
None,
)
assert success is True and error is None
messages = [
message
async for message in orchestrator.run(
@@ -1114,6 +1131,7 @@ class TestQueryEntrySessionQueryId:
assert execution_query.message_event.type == event.event_type
assert '_host_box_scope' not in execution_query.variables
assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden']
assert execution_query.variables[ACTIVATED_SKILLS_KEY]['demo']['revision'] == revision
assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1'
assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True
assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text']
@@ -1344,8 +1362,9 @@ class TestQueryEntryAdapterHostCapabilities:
assert snapshot['conversation']['external.test_key'] == 'test_value'
@pytest.mark.asyncio
async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state):
"""Persisted activated skill names are restored into the next Query run."""
@pytest.mark.parametrize('legacy_state', [False, True])
async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state, legacy_state):
"""Pinned revisions restore; old name-only state requires reactivation."""
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
from langbot.pkg.provider.tools.loaders.skill import (
ACTIVATED_SKILL_NAMES_STATE_KEY,
@@ -1363,6 +1382,10 @@ class TestQueryEntryAdapterHostCapabilities:
]
)
ap = FakeApplication(plugin_connector, db_engine)
revision = 'sha256:' + '1' * 64
ap.skill_repository = types.SimpleNamespace(
get_skill=AsyncMock(return_value={'name': 'demo', 'revision': revision})
)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
@@ -1376,7 +1399,7 @@ class TestQueryEntryAdapterHostCapabilities:
descriptor,
'conversation',
ACTIVATED_SKILL_NAMES_STATE_KEY,
['demo'],
['demo'] if legacy_state else [{'name': 'demo', 'revision': revision}],
None,
)
assert success is True
@@ -1385,7 +1408,16 @@ class TestQueryEntryAdapterHostCapabilities:
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
assert query.variables[ACTIVATED_SKILLS_KEY]['demo']['name'] == 'demo'
if legacy_state:
assert ACTIVATED_SKILLS_KEY not in query.variables
assert ap.skill_repository.get_skill.await_count == 0
assert (
len([warning for warning in ap.logger.warnings if 'Legacy name-only activated Skill state' in warning])
== 1
)
else:
assert query.variables[ACTIVATED_SKILLS_KEY]['demo']['name'] == 'demo'
assert query.variables[ACTIVATED_SKILLS_KEY]['demo']['revision'] == revision
@pytest.mark.asyncio
async def test_event_log_and_transcript_written(self, clean_agent_state):
@@ -383,6 +383,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
handler=plugin_handler,
),
box_service=box_service,
skill_repository=SimpleNamespace(storage_root=Mock(return_value='/core/skills/tenants/test')),
)
service = MaintenanceService(ap)
service._path_size = Mock(return_value=10)
@@ -394,12 +395,17 @@ class TestMaintenanceServiceGetStorageAnalysis:
result = await service.get_storage_analysis(TEST_CONTEXT)
assert result['total_size_bytes'] == 115
assert result['total_size_bytes'] == 125
assert (
next(section for section in result['sections'] if section['key'] == 'skills')['path']
== '/core/skills/tenants/test'
)
assert result['processes'][1]['directories'][0]['key'] == 'artifacts'
assert result['processes'][2]['directories'][1]['key'] == 'mcp'
assert result['processes'][2]['managed_processes'] == 2
plugin_handler.get_storage_analysis.assert_awaited_once_with()
box_service.get_storage_analysis.assert_awaited_once_with(TEST_CONTEXT)
ap.skill_repository.storage_root.assert_called_once_with(TEST_CONTEXT)
async def test_get_storage_analysis_postgresql(self):
"""Handles PostgreSQL database type."""
-1
View File
@@ -585,7 +585,6 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
connector.reconnect.assert_awaited_once()
service._ensure_default_workspace.assert_called_once()
service._purge_attachment_dirs.assert_awaited_once()
app.skill_mgr.reload_skills.assert_not_awaited()
assert service.available is True
-1
View File
@@ -24,7 +24,6 @@ def service():
require_workspace_sandbox=AsyncMock(side_effect=lambda context: context),
_action_context=lambda context: context,
client=SimpleNamespace(get_sessions=AsyncMock(side_effect=lambda **kw: list(sessions.values()))),
build_skill_extra_mounts=lambda query: [],
)
return RunnerBoxService(box)
@@ -261,6 +261,33 @@ class TestSkillToolLoader:
handled by SkillService via the HTTP API / web UI instead.
"""
@pytest.mark.asyncio
async def test_frozen_skill_source_runs_activation_without_box(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
from langbot.pkg.provider.tools.toolmgr import ToolManager
skill = _make_skill_data(name='demo', instructions='Read this first')
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': skill})
ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill))
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(ap)
manager.skill_tool_loader = SkillToolLoader(ap)
query = _make_query(_execution_context=_CONTEXT)
source_ref = {'source': 'skill', 'source_id': None}
result = await manager.execute_func_call('activate', {'skill_name': 'demo'}, query, source_ref)
assert result['activated'] is True
assert result['revision'] == skill['revision']
assert result['capabilities']['execution_available'] is False
assert query.variables['_skill_execution_available'] is False
assert await manager.get_tool_by_source(_CONTEXT, 'activate', source_ref) is not None
assert await manager.get_tool_by_source(_CONTEXT, 'register_skill', source_ref) is None
ap.box_service.is_workspace_sandbox_available.return_value = True
assert await manager.get_tool_by_source(_CONTEXT, 'register_skill', source_ref) is not None
@pytest.mark.asyncio
async def test_activate_returns_instructions_and_registers_skill(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import (