feat: rebuild as multi-user web agent
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from agent_platform.config import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path) -> Settings:
|
||||
return Settings(
|
||||
model_api_base_url="https://model.example.test",
|
||||
model_api_key="model-secret",
|
||||
openwebui_forward_jwt_secret="identity-secret-with-at-least-32-bytes", # noqa: S106
|
||||
internal_provider_key="provider-secret-with-at-least-32-bytes",
|
||||
internal_gateway_key="gateway-secret-with-at-least-32-bytes",
|
||||
database_url=f"sqlite+aiosqlite:///{tmp_path / 'agent.sqlite3'}",
|
||||
redis_url="redis://unused",
|
||||
gateway_url="http://gateway.test",
|
||||
execution_provider="local-docker",
|
||||
workspace_image="k1412-agent-workspace:test",
|
||||
workspace_network_enabled=False,
|
||||
workspace_memory_limit="512m",
|
||||
workspace_cpu_limit=1.0,
|
||||
workspace_pids_limit=128,
|
||||
workspace_ssh_host="",
|
||||
model_timeout_seconds=30,
|
||||
tool_timeout_seconds=30,
|
||||
max_tool_output_chars=24_000,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identity_jwt(settings: Settings) -> str:
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": "user-123",
|
||||
"email": "user@example.test",
|
||||
"name": "Test User",
|
||||
"role": "user",
|
||||
"iss": "open-webui",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
},
|
||||
settings.openwebui_forward_jwt_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
@@ -1,88 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.account_runtime import AccountRuntime
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
|
||||
|
||||
class AccountRuntimeTests(unittest.TestCase):
|
||||
def test_account_runtime_discovers_profiles_and_persists_login(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-account.json').write_text(
|
||||
(
|
||||
'{"profiles":['
|
||||
'{"name":"local","provider":"openai","identity":"dev@example.com","authMode":"api_key"},'
|
||||
'{"name":"team","provider":"anthropic","identity":"team@example.com","org":"Harness"}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
with patch.dict('os.environ', {'OPENAI_API_KEY': 'local-token'}, clear=False):
|
||||
runtime = AccountRuntime.from_workspace(workspace)
|
||||
report = runtime.login('local')
|
||||
restored = AccountRuntime.from_workspace(workspace)
|
||||
|
||||
self.assertEqual(len(runtime.profiles), 2)
|
||||
self.assertTrue(report.logged_in)
|
||||
self.assertEqual(report.profile_name, 'local')
|
||||
self.assertIsNotNone(restored.active_session)
|
||||
self.assertEqual(restored.active_session.profile_name, 'local')
|
||||
self.assertIn('Credential env vars: OPENAI_API_KEY', restored.render_summary())
|
||||
|
||||
def test_account_runtime_logout_clears_active_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-account.json').write_text(
|
||||
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = AccountRuntime.from_workspace(workspace)
|
||||
runtime.login('local')
|
||||
report = runtime.logout()
|
||||
|
||||
self.assertFalse(report.logged_in)
|
||||
self.assertIn('Logged out dev@example.com', report.detail)
|
||||
|
||||
def test_account_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-account.json').write_text(
|
||||
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = AccountRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
account_runtime=runtime,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'account_list_profiles',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
login_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'account_login',
|
||||
{'target': 'local'},
|
||||
context,
|
||||
)
|
||||
status_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'account_status',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(list_result.ok)
|
||||
self.assertIn('dev@example.com', list_result.content)
|
||||
self.assertTrue(login_result.ok)
|
||||
self.assertIn('profile=local', login_result.content)
|
||||
self.assertTrue(status_result.ok)
|
||||
self.assertIn('Configured account profiles: 1', status_result.content)
|
||||
@@ -1,284 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_context import (
|
||||
build_context_snapshot,
|
||||
clear_context_caches,
|
||||
set_system_prompt_injection,
|
||||
)
|
||||
from src.ask_user_runtime import AskUserRuntime
|
||||
from src.plan_runtime import PlanRuntime
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
from src.task_runtime import TaskRuntime
|
||||
from src.team_runtime import TeamRuntime
|
||||
|
||||
|
||||
class AgentContextTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
set_system_prompt_injection(None)
|
||||
clear_context_caches()
|
||||
|
||||
def test_user_context_loads_project_claude_md_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo' / 'nested'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace.parent / 'CLAUDE.md').write_text('root instructions\n', encoding='utf-8')
|
||||
(workspace / 'CLAUDE.local.md').write_text('local instructions\n', encoding='utf-8')
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('currentDate', snapshot.user_context)
|
||||
self.assertIn('claudeMd', snapshot.user_context)
|
||||
self.assertIn('root instructions', snapshot.user_context['claudeMd'])
|
||||
self.assertIn('local instructions', snapshot.user_context['claudeMd'])
|
||||
|
||||
def test_system_context_includes_cache_breaker(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
set_system_prompt_injection('debug-token')
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=Path(tmp_dir)))
|
||||
|
||||
self.assertEqual(snapshot.system_context['cacheBreaker'], '[CACHE_BREAKER: debug-token]')
|
||||
|
||||
def test_user_context_loads_plugin_cache_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
plugin_cache = workspace / '.port_sessions' / 'plugin_cache.json'
|
||||
plugin_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
plugin_cache.write_text(
|
||||
'{"plugins":[{"name":"demo-plugin","version":"1.2.3","enabled":true}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('pluginCache', snapshot.user_context)
|
||||
self.assertIn('demo-plugin', snapshot.user_context['pluginCache'])
|
||||
self.assertIn('1.2.3', snapshot.user_context['pluginCache'])
|
||||
|
||||
def test_user_context_loads_hook_policy_manifest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / '.claw-policy.json').write_text(
|
||||
(
|
||||
'{"trusted": false, '
|
||||
'"managedSettings": {"reviewMode": "strict"}, '
|
||||
'"safeEnv": ["HOOK_SAFE_TOKEN"], '
|
||||
'"hooks": {"beforePrompt": ["Respect workspace policy."]}}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False):
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('hookPolicy', snapshot.user_context)
|
||||
self.assertIn('managedSettings', snapshot.user_context)
|
||||
self.assertIn('safeEnv', snapshot.user_context)
|
||||
self.assertIn('trustMode', snapshot.user_context)
|
||||
self.assertIn('reviewMode=strict', snapshot.user_context['managedSettings'])
|
||||
self.assertIn('HOOK_SAFE_TOKEN=demo-secret', snapshot.user_context['safeEnv'])
|
||||
self.assertIn('untrusted', snapshot.user_context['trustMode'])
|
||||
|
||||
def test_user_context_loads_mcp_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
(
|
||||
'{"servers":[{"name":"workspace","resources":['
|
||||
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
|
||||
']}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('mcpRuntime', snapshot.user_context)
|
||||
self.assertIn('Local MCP resources: 1', snapshot.user_context['mcpRuntime'])
|
||||
|
||||
def test_user_context_loads_search_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / '.claw-search.json').write_text(
|
||||
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('searchRuntime', snapshot.user_context)
|
||||
self.assertIn('Configured search providers: 1', snapshot.user_context['searchRuntime'])
|
||||
self.assertIn('local-search', snapshot.user_context['searchRuntime'])
|
||||
|
||||
def test_user_context_loads_remote_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / '.claw-remote.json').write_text(
|
||||
(
|
||||
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
|
||||
'"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('remoteRuntime', snapshot.user_context)
|
||||
self.assertIn('Configured remote profiles: 1', snapshot.user_context['remoteRuntime'])
|
||||
self.assertIn('staging', snapshot.user_context['remoteRuntime'])
|
||||
|
||||
def test_user_context_loads_account_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / '.claw-account.json').write_text(
|
||||
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('accountRuntime', snapshot.user_context)
|
||||
self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime'])
|
||||
self.assertIn('dev@example.com', snapshot.user_context['accountRuntime'])
|
||||
|
||||
def test_user_context_loads_ask_user_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / '.claw-ask-user.json').write_text(
|
||||
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('askUserRuntime', snapshot.user_context)
|
||||
self.assertIn('Queued answers: 1', snapshot.user_context['askUserRuntime'])
|
||||
|
||||
def test_user_context_loads_config_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
claude_dir = workspace / '.claude'
|
||||
claude_dir.mkdir()
|
||||
(claude_dir / 'settings.json').write_text(
|
||||
'{"review":{"mode":"strict"}}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('configRuntime', snapshot.user_context)
|
||||
self.assertIn('Config sources: 1', snapshot.user_context['configRuntime'])
|
||||
self.assertIn('Effective keys: 2', snapshot.user_context['configRuntime'])
|
||||
|
||||
def test_user_context_loads_lsp_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / 'sample.py').write_text('def helper(value):\n return value * 2\n', encoding='utf-8')
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('lspRuntime', snapshot.user_context)
|
||||
self.assertIn('Indexed candidate files: 1', snapshot.user_context['lspRuntime'])
|
||||
|
||||
def test_user_context_loads_task_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
scratchpad = workspace / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
runtime = TaskRuntime.from_storage_path(scratchpad / 'task_runtime.json')
|
||||
runtime.create_task(title='Review task runtime')
|
||||
|
||||
snapshot = build_context_snapshot(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
scratchpad_directory=scratchpad,
|
||||
)
|
||||
|
||||
self.assertIn('taskRuntime', snapshot.user_context)
|
||||
self.assertIn('Total tasks: 1', snapshot.user_context['taskRuntime'])
|
||||
|
||||
def test_user_context_loads_plan_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
scratchpad = workspace / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
plan_runtime = PlanRuntime.from_storage_path(scratchpad / 'plan_runtime.json')
|
||||
plan_runtime.update_plan(
|
||||
[{'step': 'Inspect the runtime', 'status': 'in_progress'}],
|
||||
explanation='Use a stored plan.',
|
||||
)
|
||||
|
||||
snapshot = build_context_snapshot(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
scratchpad_directory=scratchpad,
|
||||
)
|
||||
|
||||
self.assertIn('planRuntime', snapshot.user_context)
|
||||
self.assertIn('Total plan steps: 1', snapshot.user_context['planRuntime'])
|
||||
|
||||
def test_user_context_ignores_workspace_plan_task_when_session_scratchpad_is_set(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
TaskRuntime.from_workspace(workspace).create_task(title='Global task')
|
||||
PlanRuntime.from_workspace(workspace).update_plan(
|
||||
[{'step': 'Global plan', 'status': 'in_progress'}],
|
||||
explanation='This must not leak into a session.',
|
||||
)
|
||||
scratchpad = workspace / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
|
||||
snapshot = build_context_snapshot(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
scratchpad_directory=scratchpad,
|
||||
)
|
||||
|
||||
self.assertNotIn('taskRuntime', snapshot.user_context)
|
||||
self.assertNotIn('planRuntime', snapshot.user_context)
|
||||
|
||||
def test_user_context_loads_team_runtime_summary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir) / 'repo'
|
||||
workspace.mkdir(parents=True)
|
||||
runtime = TeamRuntime.from_workspace(workspace)
|
||||
runtime.create_team('reviewers', members=['alice', 'bob'])
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
self.assertIn('teamRuntime', snapshot.user_context)
|
||||
self.assertIn('Configured teams: 1', snapshot.user_context['teamRuntime'])
|
||||
|
||||
@unittest.skipIf(shutil.which('git') is None, 'git is required for git context tests')
|
||||
def test_git_status_snapshot_contains_branch_and_status(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
subprocess.run(['git', 'init', '-b', 'main'], cwd=workspace, check=True)
|
||||
subprocess.run(['git', 'config', 'user.name', 'Tester'], cwd=workspace, check=True)
|
||||
subprocess.run(['git', 'config', 'user.email', 'tester@example.com'], cwd=workspace, check=True)
|
||||
(workspace / 'tracked.txt').write_text('hello\n', encoding='utf-8')
|
||||
subprocess.run(['git', 'add', 'tracked.txt'], cwd=workspace, check=True)
|
||||
subprocess.run(['git', 'commit', '-m', 'initial'], cwd=workspace, check=True)
|
||||
(workspace / 'tracked.txt').write_text('changed\n', encoding='utf-8')
|
||||
|
||||
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||
|
||||
git_status = snapshot.system_context.get('gitStatus', '')
|
||||
self.assertIn('Current branch: main', git_status)
|
||||
self.assertIn('Status:', git_status)
|
||||
self.assertIn('tracked.txt', git_status)
|
||||
@@ -1,41 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.agent_context_usage import collect_context_usage, format_context_usage
|
||||
from src.agent_session import AgentSessionState
|
||||
|
||||
|
||||
class AgentContextUsageTests(unittest.TestCase):
|
||||
def test_collect_context_usage_formats_breakdown(self) -> None:
|
||||
session = AgentSessionState.create(
|
||||
['# Intro\nhello', '# System\nworld'],
|
||||
'inspect repo',
|
||||
user_context={'currentDate': "Today's date is 2026-04-01."},
|
||||
system_context={'gitStatus': 'Current branch: main'},
|
||||
)
|
||||
session.append_assistant(
|
||||
'Reading files',
|
||||
(
|
||||
{
|
||||
'id': 'call_1',
|
||||
'type': 'function',
|
||||
'function': {'name': 'read_file', 'arguments': '{"path":"a.py"}'},
|
||||
},
|
||||
),
|
||||
)
|
||||
session.append_tool('read_file', 'call_1', '{"ok": true, "content": "print(1)"}')
|
||||
|
||||
report = collect_context_usage(
|
||||
session=session,
|
||||
model='test-model',
|
||||
strategy='test session',
|
||||
)
|
||||
rendered = format_context_usage(report)
|
||||
|
||||
self.assertGreater(report.total_tokens, 0)
|
||||
self.assertIn('## Context Usage', rendered)
|
||||
self.assertIn('**Token counter:**', rendered)
|
||||
self.assertIn('### System Prompt Sections', rendered)
|
||||
self.assertIn('### Message Breakdown', rendered)
|
||||
self.assertIn('#### Top Tools', rendered)
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_platform.auth import UserIdentity
|
||||
from agent_platform.models import get_model_spec
|
||||
from agent_platform.runtime.loop import AgentLoop
|
||||
from agent_platform.runtime.tools import TOOL_METADATA
|
||||
from agent_platform.store import RuntimeStore
|
||||
|
||||
|
||||
def tool_call(call_id: str, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": json.dumps(arguments)},
|
||||
}
|
||||
|
||||
|
||||
class ScriptedProvider:
|
||||
def __init__(self, responses: list[dict[str, Any]]) -> None:
|
||||
self.responses = responses
|
||||
self.requests = []
|
||||
|
||||
async def complete(self, **kwargs):
|
||||
self.requests.append(kwargs)
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class ParallelRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.started = 0
|
||||
self.both_started = asyncio.Event()
|
||||
|
||||
def specs(self, *, read_only=False, allow_delegate=True):
|
||||
return [TOOL_METADATA["read_file"].openai_spec()]
|
||||
|
||||
async def execute(self, name, arguments, context):
|
||||
self.started += 1
|
||||
if self.started == 2:
|
||||
self.both_started.set()
|
||||
await asyncio.wait_for(self.both_started.wait(), timeout=1)
|
||||
return {"ok": True, "path": arguments["path"]}
|
||||
|
||||
|
||||
async def test_read_only_tool_calls_run_in_parallel(settings) -> None:
|
||||
first = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
tool_call("1", "read_file", {"path": "a"}),
|
||||
tool_call("2", "read_file", {"path": "b"}),
|
||||
],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
]
|
||||
}
|
||||
final = {"choices": [{"message": {"role": "assistant", "content": "done"}, "finish_reason": "stop"}]}
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
registry = ParallelRegistry()
|
||||
loop = AgentLoop(ScriptedProvider([first, final]), registry, store, max_tool_output_chars=10_000)
|
||||
events = []
|
||||
|
||||
async def callback(event_type, payload):
|
||||
events.append(event_type)
|
||||
|
||||
try:
|
||||
answer = await loop.run(
|
||||
spec=get_model_spec("work-light"),
|
||||
messages=[{"role": "user", "content": "inspect both"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
chat_id="c1",
|
||||
callback=callback,
|
||||
)
|
||||
assert answer == "done"
|
||||
assert registry.started == 2
|
||||
assert events.count("tool.completed") == 2
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
|
||||
class SerialRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
|
||||
def specs(self, *, read_only=False, allow_delegate=True):
|
||||
return [TOOL_METADATA["write_file"].openai_spec()]
|
||||
|
||||
async def execute(self, name, arguments, context):
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
await asyncio.sleep(0.02)
|
||||
self.active -= 1
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def test_mutating_tool_calls_are_serialized(settings) -> None:
|
||||
first = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
tool_call("1", "write_file", {"path": "a", "content": "a"}),
|
||||
tool_call("2", "write_file", {"path": "b", "content": "b"}),
|
||||
],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
]
|
||||
}
|
||||
final = {"choices": [{"message": {"role": "assistant", "content": "done"}, "finish_reason": "stop"}]}
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
registry = SerialRegistry()
|
||||
loop = AgentLoop(ScriptedProvider([first, final]), registry, store, max_tool_output_chars=10_000)
|
||||
|
||||
async def callback(event_type, payload):
|
||||
return None
|
||||
|
||||
try:
|
||||
await loop.run(
|
||||
spec=get_model_spec("work-light"),
|
||||
messages=[{"role": "user", "content": "write both"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
chat_id="c1",
|
||||
callback=callback,
|
||||
)
|
||||
assert registry.max_active == 1
|
||||
finally:
|
||||
await store.close()
|
||||
@@ -1,581 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.agent_manager import AgentManager, ManagedAgentGroup, ManagedAgentRecord
|
||||
|
||||
|
||||
class TestManagedAgentRecordDefaults(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
rec = ManagedAgentRecord(agent_id="a1", prompt="do stuff")
|
||||
self.assertEqual(rec.agent_id, "a1")
|
||||
self.assertEqual(rec.prompt, "do stuff")
|
||||
self.assertIsNone(rec.parent_agent_id)
|
||||
self.assertIsNone(rec.group_id)
|
||||
self.assertIsNone(rec.child_index)
|
||||
self.assertIsNone(rec.label)
|
||||
self.assertIsNone(rec.resumed_from_session_id)
|
||||
self.assertIsNone(rec.session_id)
|
||||
self.assertIsNone(rec.session_path)
|
||||
self.assertEqual(rec.status, "running")
|
||||
self.assertEqual(rec.turns, 0)
|
||||
self.assertEqual(rec.tool_calls, 0)
|
||||
self.assertIsNone(rec.stop_reason)
|
||||
|
||||
|
||||
class TestManagedAgentGroupDefaults(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
grp = ManagedAgentGroup(group_id="g1")
|
||||
self.assertEqual(grp.group_id, "g1")
|
||||
self.assertIsNone(grp.label)
|
||||
self.assertIsNone(grp.parent_agent_id)
|
||||
self.assertEqual(grp.child_agent_ids, ())
|
||||
self.assertEqual(grp.strategy, "serial")
|
||||
self.assertEqual(grp.status, "running")
|
||||
self.assertEqual(grp.completed_children, 0)
|
||||
self.assertEqual(grp.failed_children, 0)
|
||||
self.assertEqual(grp.batch_count, 0)
|
||||
self.assertEqual(grp.max_batch_size, 0)
|
||||
self.assertEqual(grp.dependency_skips, 0)
|
||||
|
||||
|
||||
class TestStartAgent(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_increments_counter_and_returns_unique_ids(self) -> None:
|
||||
id1 = self.mgr.start_agent(prompt="task1")
|
||||
id2 = self.mgr.start_agent(prompt="task2")
|
||||
id3 = self.mgr.start_agent(prompt="task3")
|
||||
self.assertEqual(id1, "agent_1")
|
||||
self.assertEqual(id2, "agent_2")
|
||||
self.assertEqual(id3, "agent_3")
|
||||
self.assertEqual(len(self.mgr.records), 3)
|
||||
|
||||
def test_record_stored_with_correct_fields(self) -> None:
|
||||
aid = self.mgr.start_agent(prompt="hello", label="my-label")
|
||||
rec = self.mgr.records[aid]
|
||||
self.assertEqual(rec.agent_id, aid)
|
||||
self.assertEqual(rec.prompt, "hello")
|
||||
self.assertEqual(rec.label, "my-label")
|
||||
self.assertEqual(rec.status, "running")
|
||||
|
||||
def test_with_parent_agent_id_tracks_lineage(self) -> None:
|
||||
parent = self.mgr.start_agent(prompt="parent")
|
||||
child = self.mgr.start_agent(prompt="child", parent_agent_id=parent)
|
||||
rec = self.mgr.records[child]
|
||||
self.assertEqual(rec.parent_agent_id, parent)
|
||||
|
||||
def test_with_group_id_registers_child(self) -> None:
|
||||
gid = self.mgr.start_group(label="grp")
|
||||
aid = self.mgr.start_agent(prompt="task", group_id=gid, child_index=0)
|
||||
grp = self.mgr.groups[gid]
|
||||
self.assertIn(aid, grp.child_agent_ids)
|
||||
self.assertEqual(self.mgr.records[aid].group_id, gid)
|
||||
self.assertEqual(self.mgr.records[aid].child_index, 0)
|
||||
|
||||
def test_resumed_agents_tracked(self) -> None:
|
||||
aid = self.mgr.start_agent(
|
||||
prompt="resume", resumed_from_session_id="sess-old-123"
|
||||
)
|
||||
rec = self.mgr.records[aid]
|
||||
self.assertEqual(rec.resumed_from_session_id, "sess-old-123")
|
||||
|
||||
|
||||
class TestStartGroup(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_creates_group_with_strategy(self) -> None:
|
||||
gid = self.mgr.start_group(label="batch", strategy="parallel")
|
||||
self.assertEqual(gid, "group_1")
|
||||
grp = self.mgr.groups[gid]
|
||||
self.assertEqual(grp.label, "batch")
|
||||
self.assertEqual(grp.strategy, "parallel")
|
||||
self.assertEqual(grp.status, "running")
|
||||
|
||||
def test_increments_group_counter(self) -> None:
|
||||
g1 = self.mgr.start_group(label="a")
|
||||
g2 = self.mgr.start_group(label="b")
|
||||
self.assertEqual(g1, "group_1")
|
||||
self.assertEqual(g2, "group_2")
|
||||
|
||||
def test_parent_agent_id_stored(self) -> None:
|
||||
aid = self.mgr.start_agent(prompt="parent")
|
||||
gid = self.mgr.start_group(label="child-group", parent_agent_id=aid)
|
||||
self.assertEqual(self.mgr.groups[gid].parent_agent_id, aid)
|
||||
|
||||
def test_default_strategy_is_serial(self) -> None:
|
||||
gid = self.mgr.start_group()
|
||||
self.assertEqual(self.mgr.groups[gid].strategy, "serial")
|
||||
|
||||
|
||||
class TestRegisterGroupChild(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_adds_agent_to_group(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
aid = self.mgr.start_agent(prompt="t")
|
||||
self.mgr.register_group_child(gid, aid, child_index=0)
|
||||
grp = self.mgr.groups[gid]
|
||||
self.assertIn(aid, grp.child_agent_ids)
|
||||
self.assertEqual(self.mgr.records[aid].group_id, gid)
|
||||
self.assertEqual(self.mgr.records[aid].child_index, 0)
|
||||
|
||||
def test_duplicate_does_not_add_twice(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
aid = self.mgr.start_agent(prompt="t", group_id=gid, child_index=0)
|
||||
# Already registered via start_agent; register again
|
||||
self.mgr.register_group_child(gid, aid, child_index=0)
|
||||
grp = self.mgr.groups[gid]
|
||||
self.assertEqual(grp.child_agent_ids.count(aid), 1)
|
||||
|
||||
def test_unknown_group_is_noop(self) -> None:
|
||||
aid = self.mgr.start_agent(prompt="t")
|
||||
# Should not raise
|
||||
self.mgr.register_group_child("nonexistent", aid, child_index=0)
|
||||
|
||||
def test_unknown_agent_does_not_crash(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
# Agent does not exist; group gets the ID but record update is skipped
|
||||
self.mgr.register_group_child(gid, "fake_agent", child_index=0)
|
||||
self.assertIn("fake_agent", self.mgr.groups[gid].child_agent_ids)
|
||||
|
||||
def test_updates_child_index_on_record(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
aid = self.mgr.start_agent(prompt="t")
|
||||
self.mgr.register_group_child(gid, aid, child_index=5)
|
||||
self.assertEqual(self.mgr.records[aid].child_index, 5)
|
||||
self.assertEqual(self.mgr.records[aid].group_id, gid)
|
||||
|
||||
|
||||
class TestFinishAgent(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_marks_completed_with_stats(self) -> None:
|
||||
aid = self.mgr.start_agent(prompt="work")
|
||||
self.mgr.finish_agent(
|
||||
aid,
|
||||
session_id="sess-1",
|
||||
session_path="/path/sess",
|
||||
turns=5,
|
||||
tool_calls=12,
|
||||
stop_reason="end_turn",
|
||||
)
|
||||
rec = self.mgr.records[aid]
|
||||
self.assertEqual(rec.status, "completed")
|
||||
self.assertEqual(rec.session_id, "sess-1")
|
||||
self.assertEqual(rec.session_path, "/path/sess")
|
||||
self.assertEqual(rec.turns, 5)
|
||||
self.assertEqual(rec.tool_calls, 12)
|
||||
self.assertEqual(rec.stop_reason, "end_turn")
|
||||
|
||||
def test_preserves_original_fields(self) -> None:
|
||||
aid = self.mgr.start_agent(
|
||||
prompt="p", parent_agent_id="parent_x", label="lbl"
|
||||
)
|
||||
self.mgr.finish_agent(
|
||||
aid,
|
||||
session_id="s",
|
||||
session_path="/p",
|
||||
turns=1,
|
||||
tool_calls=2,
|
||||
stop_reason=None,
|
||||
)
|
||||
rec = self.mgr.records[aid]
|
||||
self.assertEqual(rec.prompt, "p")
|
||||
self.assertEqual(rec.parent_agent_id, "parent_x")
|
||||
self.assertEqual(rec.label, "lbl")
|
||||
|
||||
def test_unknown_agent_is_noop(self) -> None:
|
||||
# Should not raise
|
||||
self.mgr.finish_agent(
|
||||
"unknown_id",
|
||||
session_id=None,
|
||||
session_path=None,
|
||||
turns=0,
|
||||
tool_calls=0,
|
||||
stop_reason=None,
|
||||
)
|
||||
self.assertEqual(len(self.mgr.records), 0)
|
||||
|
||||
|
||||
class TestFinishGroup(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_updates_group_status_and_stats(self) -> None:
|
||||
gid = self.mgr.start_group(label="g", strategy="parallel")
|
||||
self.mgr.finish_group(
|
||||
gid,
|
||||
status="completed",
|
||||
completed_children=3,
|
||||
failed_children=1,
|
||||
batch_count=2,
|
||||
max_batch_size=4,
|
||||
dependency_skips=0,
|
||||
)
|
||||
grp = self.mgr.groups[gid]
|
||||
self.assertEqual(grp.status, "completed")
|
||||
self.assertEqual(grp.completed_children, 3)
|
||||
self.assertEqual(grp.failed_children, 1)
|
||||
self.assertEqual(grp.batch_count, 2)
|
||||
self.assertEqual(grp.max_batch_size, 4)
|
||||
self.assertEqual(grp.dependency_skips, 0)
|
||||
# Preserved fields
|
||||
self.assertEqual(grp.label, "g")
|
||||
self.assertEqual(grp.strategy, "parallel")
|
||||
|
||||
def test_unknown_group_is_noop(self) -> None:
|
||||
self.mgr.finish_group(
|
||||
"ghost",
|
||||
status="completed",
|
||||
completed_children=0,
|
||||
failed_children=0,
|
||||
)
|
||||
self.assertEqual(len(self.mgr.groups), 0)
|
||||
|
||||
def test_preserves_child_agent_ids(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
aid = self.mgr.start_agent(prompt="t", group_id=gid, child_index=0)
|
||||
self.mgr.finish_group(
|
||||
gid, status="completed", completed_children=1, failed_children=0
|
||||
)
|
||||
self.assertIn(aid, self.mgr.groups[gid].child_agent_ids)
|
||||
|
||||
|
||||
class TestChildrenOf(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_returns_only_children_of_specified_parent(self) -> None:
|
||||
p1 = self.mgr.start_agent(prompt="parent1")
|
||||
p2 = self.mgr.start_agent(prompt="parent2")
|
||||
c1 = self.mgr.start_agent(prompt="c1", parent_agent_id=p1)
|
||||
c2 = self.mgr.start_agent(prompt="c2", parent_agent_id=p1)
|
||||
c3 = self.mgr.start_agent(prompt="c3", parent_agent_id=p2)
|
||||
|
||||
children_p1 = self.mgr.children_of(p1)
|
||||
children_p2 = self.mgr.children_of(p2)
|
||||
|
||||
self.assertEqual(len(children_p1), 2)
|
||||
ids_p1 = {r.agent_id for r in children_p1}
|
||||
self.assertEqual(ids_p1, {c1, c2})
|
||||
|
||||
self.assertEqual(len(children_p2), 1)
|
||||
self.assertEqual(children_p2[0].agent_id, c3)
|
||||
|
||||
def test_returns_empty_for_no_children(self) -> None:
|
||||
aid = self.mgr.start_agent(prompt="solo")
|
||||
self.assertEqual(self.mgr.children_of(aid), ())
|
||||
|
||||
def test_returns_empty_for_unknown_parent(self) -> None:
|
||||
self.assertEqual(self.mgr.children_of("nonexistent"), ())
|
||||
|
||||
|
||||
class TestGroupChildren(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_returns_sorted_members(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
a2 = self.mgr.start_agent(prompt="b", group_id=gid, child_index=2)
|
||||
a0 = self.mgr.start_agent(prompt="a", group_id=gid, child_index=0)
|
||||
a1 = self.mgr.start_agent(prompt="c", group_id=gid, child_index=1)
|
||||
|
||||
children = self.mgr.group_children(gid)
|
||||
self.assertEqual(len(children), 3)
|
||||
self.assertEqual(children[0].agent_id, a0)
|
||||
self.assertEqual(children[1].agent_id, a1)
|
||||
self.assertEqual(children[2].agent_id, a2)
|
||||
|
||||
def test_none_child_index_sorted_last(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
a_none = self.mgr.start_agent(prompt="x", group_id=gid)
|
||||
a0 = self.mgr.start_agent(prompt="y", group_id=gid, child_index=0)
|
||||
|
||||
children = self.mgr.group_children(gid)
|
||||
self.assertEqual(children[0].agent_id, a0)
|
||||
self.assertEqual(children[1].agent_id, a_none)
|
||||
|
||||
def test_empty_for_unknown_group(self) -> None:
|
||||
self.assertEqual(self.mgr.group_children("nope"), ())
|
||||
|
||||
|
||||
class TestGroupSummary(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_aggregates_statistics(self) -> None:
|
||||
gid = self.mgr.start_group(label="batch", strategy="parallel")
|
||||
a1 = self.mgr.start_agent(
|
||||
prompt="t1", group_id=gid, child_index=0,
|
||||
resumed_from_session_id="old-sess",
|
||||
)
|
||||
a2 = self.mgr.start_agent(prompt="t2", group_id=gid, child_index=1)
|
||||
self.mgr.finish_agent(
|
||||
a1, session_id="s1", session_path="/p1",
|
||||
turns=3, tool_calls=5, stop_reason="end_turn",
|
||||
)
|
||||
self.mgr.finish_agent(
|
||||
a2, session_id="s2", session_path="/p2",
|
||||
turns=2, tool_calls=4, stop_reason="max_turns",
|
||||
)
|
||||
self.mgr.finish_group(
|
||||
gid, status="completed",
|
||||
completed_children=2, failed_children=0,
|
||||
batch_count=1, max_batch_size=2,
|
||||
)
|
||||
|
||||
summary = self.mgr.group_summary(gid)
|
||||
assert summary is not None
|
||||
self.assertEqual(summary["group_id"], gid)
|
||||
self.assertEqual(summary["label"], "batch")
|
||||
self.assertEqual(summary["strategy"], "parallel")
|
||||
self.assertEqual(summary["status"], "completed")
|
||||
self.assertEqual(summary["child_count"], 2)
|
||||
self.assertEqual(summary["completed_children"], 2)
|
||||
self.assertEqual(summary["failed_children"], 0)
|
||||
self.assertEqual(summary["resumed_children"], 1)
|
||||
self.assertEqual(summary["batch_count"], 1)
|
||||
self.assertEqual(summary["max_batch_size"], 2)
|
||||
self.assertEqual(summary["dependency_skips"], 0)
|
||||
self.assertEqual(
|
||||
summary["stop_reason_counts"],
|
||||
{"end_turn": 1, "max_turns": 1},
|
||||
)
|
||||
|
||||
def test_running_agents_counted_as_na(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
self.mgr.start_agent(prompt="t", group_id=gid, child_index=0)
|
||||
summary = self.mgr.group_summary(gid)
|
||||
assert summary is not None
|
||||
self.assertEqual(summary["stop_reason_counts"], {"n/a": 1})
|
||||
|
||||
def test_returns_none_for_unknown_group(self) -> None:
|
||||
self.assertIsNone(self.mgr.group_summary("unknown"))
|
||||
|
||||
|
||||
class TestCompletedRecords(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_filters_only_completed(self) -> None:
|
||||
a1 = self.mgr.start_agent(prompt="t1")
|
||||
a2 = self.mgr.start_agent(prompt="t2")
|
||||
a3 = self.mgr.start_agent(prompt="t3")
|
||||
self.mgr.finish_agent(
|
||||
a1, session_id="s", session_path="/p",
|
||||
turns=1, tool_calls=1, stop_reason="done",
|
||||
)
|
||||
self.mgr.finish_agent(
|
||||
a3, session_id="s2", session_path="/p2",
|
||||
turns=2, tool_calls=3, stop_reason="done",
|
||||
)
|
||||
|
||||
completed = self.mgr.completed_records()
|
||||
self.assertEqual(len(completed), 2)
|
||||
ids = {r.agent_id for r in completed}
|
||||
self.assertEqual(ids, {a1, a3})
|
||||
|
||||
def test_empty_when_none_completed(self) -> None:
|
||||
self.mgr.start_agent(prompt="running")
|
||||
self.assertEqual(self.mgr.completed_records(), ())
|
||||
|
||||
|
||||
class TestSummaryLines(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.mgr = AgentManager()
|
||||
|
||||
def test_empty_manager(self) -> None:
|
||||
lines = self.mgr.summary_lines()
|
||||
self.assertIn("- Managed agents: 0", lines)
|
||||
self.assertIn("- Completed agents: 0", lines)
|
||||
self.assertIn("- Child agents: 0", lines)
|
||||
self.assertIn("- Resumed agents: 0", lines)
|
||||
self.assertIn("- Agent groups: 0", lines)
|
||||
self.assertIn("- Completed groups: 0", lines)
|
||||
|
||||
def test_basic_output_format(self) -> None:
|
||||
a1 = self.mgr.start_agent(prompt="task", label="worker-1")
|
||||
self.mgr.finish_agent(
|
||||
a1, session_id="s1", session_path="/p",
|
||||
turns=4, tool_calls=10, stop_reason="end_turn",
|
||||
)
|
||||
lines = self.mgr.summary_lines()
|
||||
self.assertIn("- Managed agents: 1", lines)
|
||||
self.assertIn("- Completed agents: 1", lines)
|
||||
# Agent detail line
|
||||
detail = [l for l in lines if "worker-1" in l]
|
||||
self.assertEqual(len(detail), 1)
|
||||
self.assertIn("status=completed", detail[0])
|
||||
self.assertIn("turns=4", detail[0])
|
||||
self.assertIn("tool_calls=10", detail[0])
|
||||
self.assertIn("stop=end_turn", detail[0])
|
||||
|
||||
def test_group_info_in_agent_line(self) -> None:
|
||||
gid = self.mgr.start_group(label="g")
|
||||
self.mgr.start_agent(prompt="t", group_id=gid, child_index=0, label="child-0")
|
||||
lines = self.mgr.summary_lines()
|
||||
detail = [l for l in lines if "child-0" in l]
|
||||
self.assertEqual(len(detail), 1)
|
||||
self.assertIn(f"group={gid}", detail[0])
|
||||
self.assertIn("child_index=0", detail[0])
|
||||
|
||||
def test_resumed_from_in_agent_line(self) -> None:
|
||||
self.mgr.start_agent(
|
||||
prompt="t", label="res",
|
||||
resumed_from_session_id="old-sess-id",
|
||||
)
|
||||
lines = self.mgr.summary_lines()
|
||||
detail = [l for l in lines if "res" in l]
|
||||
self.assertTrue(any("resumed_from=old-sess-id" in l for l in detail))
|
||||
|
||||
def test_agent_without_label_uses_id(self) -> None:
|
||||
aid = self.mgr.start_agent(prompt="no label")
|
||||
lines = self.mgr.summary_lines()
|
||||
detail = [l for l in lines if aid in l]
|
||||
self.assertEqual(len(detail), 1)
|
||||
|
||||
def test_truncation_at_8_agents(self) -> None:
|
||||
for i in range(10):
|
||||
self.mgr.start_agent(prompt=f"task-{i}")
|
||||
lines = self.mgr.summary_lines()
|
||||
self.assertIn("- Managed agents: 10", lines)
|
||||
plus_line = [l for l in lines if "plus" in l and "managed agents" in l]
|
||||
self.assertEqual(len(plus_line), 1)
|
||||
self.assertIn("2 more managed agents", plus_line[0])
|
||||
|
||||
def test_truncation_at_6_groups(self) -> None:
|
||||
for i in range(8):
|
||||
self.mgr.start_group(label=f"grp-{i}")
|
||||
lines = self.mgr.summary_lines()
|
||||
plus_line = [l for l in lines if "plus" in l and "agent groups" in l]
|
||||
self.assertEqual(len(plus_line), 1)
|
||||
self.assertIn("2 more agent groups", plus_line[0])
|
||||
|
||||
def test_group_summary_line_format(self) -> None:
|
||||
gid = self.mgr.start_group(label="my-batch", strategy="parallel")
|
||||
a1 = self.mgr.start_agent(prompt="t1", group_id=gid, child_index=0)
|
||||
self.mgr.finish_agent(
|
||||
a1, session_id="s", session_path="/p",
|
||||
turns=1, tool_calls=2, stop_reason="end_turn",
|
||||
)
|
||||
self.mgr.finish_group(
|
||||
gid, status="completed",
|
||||
completed_children=1, failed_children=0,
|
||||
batch_count=1, max_batch_size=1,
|
||||
)
|
||||
lines = self.mgr.summary_lines()
|
||||
grp_line = [l for l in lines if "my-batch" in l and "group_status" in l]
|
||||
self.assertEqual(len(grp_line), 1)
|
||||
self.assertIn("group_status=completed", grp_line[0])
|
||||
self.assertIn("children=1", grp_line[0])
|
||||
self.assertIn("completed=1", grp_line[0])
|
||||
self.assertIn("failed=0", grp_line[0])
|
||||
self.assertIn("strategy=parallel", grp_line[0])
|
||||
self.assertIn("stop_reasons=end_turn:1", grp_line[0])
|
||||
|
||||
def test_child_and_resumed_counts(self) -> None:
|
||||
p = self.mgr.start_agent(prompt="parent")
|
||||
self.mgr.start_agent(prompt="c1", parent_agent_id=p)
|
||||
self.mgr.start_agent(
|
||||
prompt="c2", parent_agent_id=p,
|
||||
resumed_from_session_id="old",
|
||||
)
|
||||
lines = self.mgr.summary_lines()
|
||||
self.assertIn("- Child agents: 2", lines)
|
||||
self.assertIn("- Resumed agents: 1", lines)
|
||||
|
||||
|
||||
class TestMultipleAgentsAndGroupsInteraction(unittest.TestCase):
|
||||
"""End-to-end scenario with multiple groups and cross-references."""
|
||||
|
||||
def test_full_lifecycle(self) -> None:
|
||||
mgr = AgentManager()
|
||||
|
||||
# Parent agent spawns two groups
|
||||
parent = mgr.start_agent(prompt="orchestrate", label="orchestrator")
|
||||
g1 = mgr.start_group(label="build", parent_agent_id=parent, strategy="serial")
|
||||
g2 = mgr.start_group(label="test", parent_agent_id=parent, strategy="parallel")
|
||||
|
||||
# Group 1 children
|
||||
b1 = mgr.start_agent(prompt="build-fe", group_id=g1, child_index=0, parent_agent_id=parent)
|
||||
b2 = mgr.start_agent(prompt="build-be", group_id=g1, child_index=1, parent_agent_id=parent)
|
||||
|
||||
# Group 2 children, one resumed
|
||||
t1 = mgr.start_agent(
|
||||
prompt="test-unit", group_id=g2, child_index=0,
|
||||
parent_agent_id=parent, resumed_from_session_id="old-session",
|
||||
)
|
||||
t2 = mgr.start_agent(prompt="test-e2e", group_id=g2, child_index=1, parent_agent_id=parent)
|
||||
|
||||
# Finish agents
|
||||
for aid, turns, tc, sr in [
|
||||
(b1, 3, 8, "end_turn"),
|
||||
(b2, 4, 10, "end_turn"),
|
||||
(t1, 2, 5, "end_turn"),
|
||||
(t2, 6, 15, "max_turns"),
|
||||
]:
|
||||
mgr.finish_agent(aid, session_id=f"s-{aid}", session_path=f"/p/{aid}", turns=turns, tool_calls=tc, stop_reason=sr)
|
||||
|
||||
mgr.finish_group(g1, status="completed", completed_children=2, failed_children=0, batch_count=2, max_batch_size=1)
|
||||
mgr.finish_group(g2, status="completed", completed_children=1, failed_children=1, batch_count=1, max_batch_size=2, dependency_skips=1)
|
||||
|
||||
# Verify children_of
|
||||
children = mgr.children_of(parent)
|
||||
self.assertEqual(len(children), 4)
|
||||
|
||||
# Verify group_children ordering
|
||||
g1_children = mgr.group_children(g1)
|
||||
self.assertEqual(g1_children[0].agent_id, b1)
|
||||
self.assertEqual(g1_children[1].agent_id, b2)
|
||||
|
||||
g2_children = mgr.group_children(g2)
|
||||
self.assertEqual(g2_children[0].agent_id, t1)
|
||||
self.assertEqual(g2_children[1].agent_id, t2)
|
||||
|
||||
# Verify completed records (parent is still running)
|
||||
completed = mgr.completed_records()
|
||||
self.assertEqual(len(completed), 4)
|
||||
|
||||
# Verify group summaries
|
||||
s1 = mgr.group_summary(g1)
|
||||
assert s1 is not None
|
||||
self.assertEqual(s1["child_count"], 2)
|
||||
self.assertEqual(s1["resumed_children"], 0)
|
||||
self.assertEqual(s1["dependency_skips"], 0)
|
||||
|
||||
s2 = mgr.group_summary(g2)
|
||||
assert s2 is not None
|
||||
self.assertEqual(s2["child_count"], 2)
|
||||
self.assertEqual(s2["resumed_children"], 1)
|
||||
self.assertEqual(s2["dependency_skips"], 1)
|
||||
self.assertEqual(s2["stop_reason_counts"], {"end_turn": 1, "max_turns": 1})
|
||||
|
||||
# Verify summary_lines produces output
|
||||
lines = mgr.summary_lines()
|
||||
self.assertIn("- Managed agents: 5", lines)
|
||||
self.assertIn("- Completed agents: 4", lines)
|
||||
self.assertIn("- Child agents: 4", lines)
|
||||
self.assertIn("- Resumed agents: 1", lines)
|
||||
self.assertIn("- Agent groups: 2", lines)
|
||||
self.assertIn("- Completed groups: 2", lines)
|
||||
|
||||
|
||||
class TestFrozenDataclasses(unittest.TestCase):
|
||||
def test_record_is_frozen(self) -> None:
|
||||
rec = ManagedAgentRecord(agent_id="a", prompt="p")
|
||||
with self.assertRaises(AttributeError):
|
||||
rec.status = "completed" # type: ignore[misc]
|
||||
|
||||
def test_group_is_frozen(self) -> None:
|
||||
grp = ManagedAgentGroup(group_id="g")
|
||||
with self.assertRaises(AttributeError):
|
||||
grp.status = "completed" # type: ignore[misc]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,475 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_plugin_cache import (
|
||||
MAX_PLUGIN_LINES,
|
||||
MAX_PLUGIN_PREVIEW_CHARS,
|
||||
PluginCacheEntry,
|
||||
_coerce_entry,
|
||||
_extract_entries,
|
||||
discover_plugin_cache,
|
||||
load_plugin_cache_summary,
|
||||
)
|
||||
|
||||
|
||||
class TestDiscoverPluginCacheNone(unittest.TestCase):
|
||||
"""discover_plugin_cache returns None when no cache files exist."""
|
||||
|
||||
def test_returns_none_for_empty_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_port_sessions_dir_is_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".port_sessions").mkdir()
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestDiscoverPluginCacheListFormat(unittest.TestCase):
|
||||
"""discover_plugin_cache finds cache in .port_sessions/plugin_cache.json (list format)."""
|
||||
|
||||
def test_list_of_strings(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
cache_file = cache_dir / "plugin_cache.json"
|
||||
cache_file.write_text(json.dumps(["plugin-a", "plugin-b"]))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("plugin-a", result)
|
||||
self.assertIn("plugin-b", result)
|
||||
self.assertIn("Plugin entries discovered: 2", result)
|
||||
|
||||
def test_list_of_dicts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
cache_file = cache_dir / "plugin_cache.json"
|
||||
cache_file.write_text(
|
||||
json.dumps([{"name": "alpha", "version": "1.0"}, {"name": "beta"}])
|
||||
)
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("alpha", result)
|
||||
self.assertIn("version=1.0", result)
|
||||
self.assertIn("beta", result)
|
||||
|
||||
|
||||
class TestDiscoverPluginCacheDictPluginsKey(unittest.TestCase):
|
||||
"""discover_plugin_cache finds cache in .port_sessions/plugins.json (dict with 'plugins' key)."""
|
||||
|
||||
def test_dict_with_plugins_list(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
cache_file = cache_dir / "plugins.json"
|
||||
payload = {"plugins": [{"name": "foo"}, {"name": "bar"}]}
|
||||
cache_file.write_text(json.dumps(payload))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("foo", result)
|
||||
self.assertIn("bar", result)
|
||||
self.assertIn("Plugin entries discovered: 2", result)
|
||||
|
||||
|
||||
class TestDiscoverPluginCacheDictEntriesKey(unittest.TestCase):
|
||||
"""discover_plugin_cache handles dict with 'entries' key format."""
|
||||
|
||||
def test_dict_with_entries_list(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
cache_file = cache_dir / "plugin_cache.json"
|
||||
payload = {"entries": [{"name": "entry-a"}, {"name": "entry-b"}]}
|
||||
cache_file.write_text(json.dumps(payload))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("entry-a", result)
|
||||
self.assertIn("entry-b", result)
|
||||
|
||||
|
||||
class TestDiscoverPluginCacheDictKeyAsName(unittest.TestCase):
|
||||
"""discover_plugin_cache handles dict where values are dicts (key=name format)."""
|
||||
|
||||
def test_dict_values_are_dicts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
cache_file = cache_dir / "plugin_cache.json"
|
||||
payload = {
|
||||
"my-plugin": {"version": "2.0", "source": "/path/to/it"},
|
||||
"other-plugin": {"version": "3.1"},
|
||||
}
|
||||
cache_file.write_text(json.dumps(payload))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("my-plugin", result)
|
||||
self.assertIn("version=2.0", result)
|
||||
self.assertIn("source=/path/to/it", result)
|
||||
self.assertIn("other-plugin", result)
|
||||
|
||||
|
||||
class TestCoerceEntry(unittest.TestCase):
|
||||
"""_coerce_entry handles various input types."""
|
||||
|
||||
def test_string_entry(self) -> None:
|
||||
entry = _coerce_entry("simple-plugin")
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.name, "simple-plugin")
|
||||
self.assertTrue(entry.enabled)
|
||||
|
||||
def test_string_entry_strips_whitespace(self) -> None:
|
||||
entry = _coerce_entry(" padded-name ")
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.name, "padded-name")
|
||||
|
||||
def test_empty_string_returns_none(self) -> None:
|
||||
self.assertIsNone(_coerce_entry(""))
|
||||
self.assertIsNone(_coerce_entry(" "))
|
||||
|
||||
def test_dict_with_name_key(self) -> None:
|
||||
entry = _coerce_entry({"name": "named-plugin"})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.name, "named-plugin")
|
||||
|
||||
def test_dict_with_plugin_key(self) -> None:
|
||||
entry = _coerce_entry({"plugin": "plugin-key"})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.name, "plugin-key")
|
||||
|
||||
def test_dict_with_id_key(self) -> None:
|
||||
entry = _coerce_entry({"id": "id-key"})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.name, "id-key")
|
||||
|
||||
def test_name_takes_precedence_over_plugin_and_id(self) -> None:
|
||||
entry = _coerce_entry({"name": "winner", "plugin": "loser", "id": "also-loser"})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.name, "winner")
|
||||
|
||||
def test_dict_with_version_and_source(self) -> None:
|
||||
entry = _coerce_entry(
|
||||
{"name": "full", "version": "1.2.3", "source": "/src"}
|
||||
)
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.version, "1.2.3")
|
||||
self.assertEqual(entry.source, "/src")
|
||||
|
||||
def test_source_fallback_to_path(self) -> None:
|
||||
entry = _coerce_entry({"name": "p", "path": "/a/b"})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.source, "/a/b")
|
||||
|
||||
def test_source_fallback_to_module(self) -> None:
|
||||
entry = _coerce_entry({"name": "p", "module": "my.mod"})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.source, "my.mod")
|
||||
|
||||
def test_disabled_plugin(self) -> None:
|
||||
entry = _coerce_entry({"name": "off", "enabled": False})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertFalse(entry.enabled)
|
||||
|
||||
def test_enabled_none_defaults_to_true(self) -> None:
|
||||
entry = _coerce_entry({"name": "on"})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertTrue(entry.enabled)
|
||||
|
||||
def test_enabled_truthy_value(self) -> None:
|
||||
entry = _coerce_entry({"name": "on", "enabled": 1})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertTrue(entry.enabled)
|
||||
|
||||
def test_empty_dict_returns_none(self) -> None:
|
||||
self.assertIsNone(_coerce_entry({}))
|
||||
|
||||
def test_non_string_returns_none(self) -> None:
|
||||
self.assertIsNone(_coerce_entry(42))
|
||||
self.assertIsNone(_coerce_entry(None))
|
||||
self.assertIsNone(_coerce_entry(True))
|
||||
self.assertIsNone(_coerce_entry([]))
|
||||
|
||||
def test_dict_with_non_string_name_returns_none(self) -> None:
|
||||
self.assertIsNone(_coerce_entry({"name": 123}))
|
||||
self.assertIsNone(_coerce_entry({"name": ""}))
|
||||
|
||||
def test_empty_version_is_none(self) -> None:
|
||||
entry = _coerce_entry({"name": "p", "version": ""})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertIsNone(entry.version)
|
||||
|
||||
def test_non_string_version_is_none(self) -> None:
|
||||
entry = _coerce_entry({"name": "p", "version": 5})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertIsNone(entry.version)
|
||||
|
||||
def test_empty_source_is_none(self) -> None:
|
||||
entry = _coerce_entry({"name": "p", "source": ""})
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertIsNone(entry.source)
|
||||
|
||||
|
||||
class TestExtractEntries(unittest.TestCase):
|
||||
"""_extract_entries handles all payload shapes."""
|
||||
|
||||
def test_list_payload(self) -> None:
|
||||
entries = _extract_entries(["a", "b"])
|
||||
self.assertEqual(len(entries), 2)
|
||||
|
||||
def test_dict_plugins_key(self) -> None:
|
||||
entries = _extract_entries({"plugins": [{"name": "x"}]})
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0].name, "x")
|
||||
|
||||
def test_dict_entries_key(self) -> None:
|
||||
entries = _extract_entries({"entries": [{"name": "y"}]})
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0].name, "y")
|
||||
|
||||
def test_dict_key_as_name(self) -> None:
|
||||
entries = _extract_entries({"k1": {"version": "1"}, "k2": {"version": "2"}})
|
||||
names = {e.name for e in entries}
|
||||
self.assertEqual(names, {"k1", "k2"})
|
||||
|
||||
def test_plugins_key_takes_precedence_over_key_as_name(self) -> None:
|
||||
payload = {"plugins": [{"name": "from-plugins"}], "other": {"version": "1"}}
|
||||
entries = _extract_entries(payload)
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0].name, "from-plugins")
|
||||
|
||||
def test_non_dict_values_ignored_in_key_as_name(self) -> None:
|
||||
entries = _extract_entries({"good": {"version": "1"}, "bad": "string-val"})
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertEqual(entries[0].name, "good")
|
||||
|
||||
def test_empty_list_returns_empty(self) -> None:
|
||||
self.assertEqual(_extract_entries([]), [])
|
||||
|
||||
def test_invalid_payload_type(self) -> None:
|
||||
self.assertEqual(_extract_entries("not-valid"), [])
|
||||
self.assertEqual(_extract_entries(42), [])
|
||||
|
||||
|
||||
class TestLoadPluginCacheSummary(unittest.TestCase):
|
||||
"""load_plugin_cache_summary returns rendered summary string."""
|
||||
|
||||
def test_returns_none_when_no_cache(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = load_plugin_cache_summary(Path(tmp))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_summary_string(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "plugin_cache.json").write_text(
|
||||
json.dumps([{"name": "my-plugin", "version": "1.0"}])
|
||||
)
|
||||
|
||||
result = load_plugin_cache_summary(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("my-plugin", result)
|
||||
self.assertIn("Plugin cache loaded from:", result)
|
||||
|
||||
|
||||
class TestRenderedSummaryCounts(unittest.TestCase):
|
||||
"""Rendered summary shows correct enabled/disabled counts."""
|
||||
|
||||
def _make_cache(self, tmp: str, entries: list) -> str | None:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
(cache_dir / "plugin_cache.json").write_text(json.dumps(entries))
|
||||
return discover_plugin_cache(Path(tmp))
|
||||
|
||||
def test_all_enabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self._make_cache(tmp, [{"name": "a"}, {"name": "b"}, {"name": "c"}])
|
||||
self.assertIn("Enabled plugins: 3", result)
|
||||
self.assertNotIn("Disabled plugins:", result)
|
||||
|
||||
def test_some_disabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self._make_cache(
|
||||
tmp,
|
||||
[
|
||||
{"name": "a"},
|
||||
{"name": "b", "enabled": False},
|
||||
{"name": "c", "enabled": False},
|
||||
],
|
||||
)
|
||||
self.assertIn("Enabled plugins: 1", result)
|
||||
self.assertIn("Disabled plugins: 2", result)
|
||||
|
||||
def test_disabled_shown_in_line(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self._make_cache(
|
||||
tmp, [{"name": "off-plugin", "enabled": False}]
|
||||
)
|
||||
self.assertIn("disabled", result)
|
||||
self.assertIn("off-plugin", result)
|
||||
|
||||
|
||||
class TestPreviewTruncation(unittest.TestCase):
|
||||
"""Preview truncation works (MAX_PLUGIN_PREVIEW_CHARS=4000)."""
|
||||
|
||||
def test_long_output_is_truncated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
# Create entries with very long names so the rendered output exceeds the limit
|
||||
long_name = "x" * 500
|
||||
entries = [{"name": f"{long_name}-{i}"} for i in range(20)]
|
||||
(cache_dir / "plugin_cache.json").write_text(json.dumps(entries))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertLessEqual(len(result), MAX_PLUGIN_PREVIEW_CHARS)
|
||||
self.assertTrue(result.endswith("..."))
|
||||
|
||||
def test_short_output_not_truncated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
entries = [{"name": "small"}]
|
||||
(cache_dir / "plugin_cache.json").write_text(json.dumps(entries))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertFalse(result.endswith("..."))
|
||||
|
||||
|
||||
class TestMaxPluginLinesTruncation(unittest.TestCase):
|
||||
"""More than MAX_PLUGIN_LINES (12) shows truncation message."""
|
||||
|
||||
def test_more_than_max_lines_shows_truncation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
count = MAX_PLUGIN_LINES + 5
|
||||
entries = [{"name": f"plugin-{i}"} for i in range(count)]
|
||||
(cache_dir / "plugin_cache.json").write_text(json.dumps(entries))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn(f"... plus 5 more plugin entries", result)
|
||||
self.assertIn(f"Plugin entries discovered: {count}", result)
|
||||
|
||||
def test_exactly_max_lines_no_truncation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
entries = [{"name": f"plugin-{i}"} for i in range(MAX_PLUGIN_LINES)]
|
||||
(cache_dir / "plugin_cache.json").write_text(json.dumps(entries))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotIn("more plugin entries", result)
|
||||
|
||||
|
||||
class TestMalformedJsonSkipped(unittest.TestCase):
|
||||
"""Malformed JSON files are gracefully skipped."""
|
||||
|
||||
def test_invalid_json_skipped(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "plugin_cache.json").write_text("{not valid json!!!")
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_malformed_first_valid_second(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
# First candidate: malformed
|
||||
(cache_dir / "plugin_cache.json").write_text("not json")
|
||||
# Second candidate: valid
|
||||
(cache_dir / "plugins.json").write_text(
|
||||
json.dumps({"plugins": [{"name": "fallback"}]})
|
||||
)
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("fallback", result)
|
||||
|
||||
def test_valid_json_but_empty_entries(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache_dir = Path(tmp) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "plugin_cache.json").write_text(json.dumps([]))
|
||||
|
||||
result = discover_plugin_cache(Path(tmp))
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestAdditionalWorkingDirectories(unittest.TestCase):
|
||||
"""additional_working_directories are searched."""
|
||||
|
||||
def test_finds_cache_in_additional_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as main, tempfile.TemporaryDirectory() as extra:
|
||||
cache_dir = Path(extra) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "plugin_cache.json").write_text(
|
||||
json.dumps([{"name": "extra-plugin"}])
|
||||
)
|
||||
|
||||
result = discover_plugin_cache(Path(main), (extra,))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("extra-plugin", result)
|
||||
|
||||
def test_main_dir_preferred_over_additional(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as main, tempfile.TemporaryDirectory() as extra:
|
||||
for base, name in [(main, "main-plugin"), (extra, "extra-plugin")]:
|
||||
cache_dir = Path(base) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "plugin_cache.json").write_text(
|
||||
json.dumps([{"name": name}])
|
||||
)
|
||||
|
||||
result = discover_plugin_cache(Path(main), (extra,))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("main-plugin", result)
|
||||
|
||||
def test_load_plugin_cache_summary_with_additional_dirs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as main, tempfile.TemporaryDirectory() as extra:
|
||||
cache_dir = Path(extra) / ".port_sessions"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "plugin_cache.json").write_text(
|
||||
json.dumps([{"name": "via-summary"}])
|
||||
)
|
||||
|
||||
result = load_plugin_cache_summary(Path(main), (extra,))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("via-summary", result)
|
||||
|
||||
|
||||
class TestPluginCacheEntry(unittest.TestCase):
|
||||
"""PluginCacheEntry dataclass behavior."""
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
entry = PluginCacheEntry(name="test")
|
||||
self.assertEqual(entry.name, "test")
|
||||
self.assertTrue(entry.enabled)
|
||||
self.assertIsNone(entry.version)
|
||||
self.assertIsNone(entry.source)
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
entry = PluginCacheEntry(name="test")
|
||||
with self.assertRaises(AttributeError):
|
||||
entry.name = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,335 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_prompting import build_prompt_context, build_system_prompt_parts, render_system_prompt
|
||||
from src.plan_runtime import PlanRuntime
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_session import AgentSessionState
|
||||
from src.agent_tools import default_tool_registry
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig
|
||||
from src.task_runtime import TaskRuntime
|
||||
|
||||
|
||||
class AgentPromptingTests(unittest.TestCase):
|
||||
def test_prompt_builder_contains_expected_sections(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
runtime_config = AgentRuntimeConfig(
|
||||
cwd=Path(tmp_dir),
|
||||
permissions=AgentPermissions(
|
||||
allow_file_write=True,
|
||||
allow_shell_commands=False,
|
||||
),
|
||||
)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 系统规则', prompt)
|
||||
self.assertIn('# 处理任务', prompt)
|
||||
self.assertIn('# 工作空间边界', prompt)
|
||||
self.assertIn('当前 session 目录是本轮任务的默认工作区', prompt)
|
||||
self.assertIn('平台服务代码目录始终只读', prompt)
|
||||
self.assertIn('# 使用工具', prompt)
|
||||
self.assertIn('# Skills', prompt)
|
||||
self.assertIn('product-data', prompt)
|
||||
self.assertIn('__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__', prompt)
|
||||
self.assertIn('主工作目录:', prompt)
|
||||
|
||||
def test_prompt_builder_respects_enabled_skill_names(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
runtime_config = AgentRuntimeConfig(
|
||||
cwd=Path(tmp_dir),
|
||||
enabled_skill_names=('verify',),
|
||||
)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('verify', prompt)
|
||||
self.assertNotIn('product-data', prompt)
|
||||
self.assertNotIn('online-mining', prompt)
|
||||
|
||||
def test_session_state_exports_messages_in_order(self) -> None:
|
||||
state = AgentSessionState.create(['sys one', 'sys two'], 'hello')
|
||||
state.append_assistant('working', ())
|
||||
state.append_tool('read_file', 'call_1', '{"ok": true}')
|
||||
messages = state.to_openai_messages()
|
||||
self.assertEqual(messages[0]['role'], 'system')
|
||||
self.assertEqual(messages[1]['role'], 'user')
|
||||
self.assertEqual(messages[2]['role'], 'assistant')
|
||||
self.assertEqual(messages[3]['role'], 'tool')
|
||||
self.assertEqual(messages[3]['tool_call_id'], 'call_1')
|
||||
|
||||
def test_agent_can_render_prompt_without_contacting_model(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
prompt = agent.render_system_prompt()
|
||||
self.assertIn('Zhongkong Agent', prompt)
|
||||
self.assertIn('# 系统规则', prompt)
|
||||
self.assertIn('# 环境信息', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_plugins_when_cache_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
plugin_cache = workspace / '.port_sessions' / 'plugin_cache.json'
|
||||
plugin_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
plugin_cache.write_text(
|
||||
'{"plugins":[{"name":"example-plugin","enabled":true}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 插件', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_hook_policy_when_manifest_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-policy.json').write_text(
|
||||
'{"trusted": false, "hooks": {"beforePrompt": ["Follow workspace policy."]}}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# Hook 策略', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_mcp_when_manifest_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
(
|
||||
'{"servers":[{"name":"workspace","resources":['
|
||||
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
|
||||
']}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# MCP', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_search_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-search.json').write_text(
|
||||
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 搜索', prompt)
|
||||
self.assertIn('web_search', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_remote_when_manifest_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-remote.json').write_text(
|
||||
(
|
||||
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
|
||||
'"workspaceCwd":"/srv/app"}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 远程环境', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_account_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-account.json').write_text(
|
||||
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 账号', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_ask_user_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-ask-user.json').write_text(
|
||||
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 询问用户', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_config_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
claude_dir = workspace / '.claude'
|
||||
claude_dir.mkdir()
|
||||
(claude_dir / 'settings.json').write_text(
|
||||
'{"review":{"mode":"strict"}}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 配置', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_lsp_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'sample.py').write_text('def helper(value):\n return value * 2\n', encoding='utf-8')
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# LSP', prompt)
|
||||
self.assertIn('使用 LSP 工具', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_tasks_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
scratchpad = workspace / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
runtime = TaskRuntime.from_storage_path(scratchpad / 'task_runtime.json')
|
||||
runtime.create_task(title='Inspect runtime tasks')
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(
|
||||
runtime_config,
|
||||
model_config,
|
||||
scratchpad_directory=scratchpad,
|
||||
)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 任务', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_teams_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-teams.json').write_text(
|
||||
'{"teams":[{"name":"reviewers","members":["alice","bob"]}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 团队', prompt)
|
||||
|
||||
def test_prompt_builder_mentions_planning_when_runtime_is_loaded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
scratchpad = workspace / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
runtime = PlanRuntime.from_storage_path(scratchpad / 'plan_runtime.json')
|
||||
runtime.update_plan(
|
||||
[{'step': 'Inspect runtime planning', 'status': 'pending'}],
|
||||
explanation='Track the current plan.',
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
prompt_context = build_prompt_context(
|
||||
runtime_config,
|
||||
model_config,
|
||||
scratchpad_directory=scratchpad,
|
||||
)
|
||||
parts = build_system_prompt_parts(
|
||||
prompt_context=prompt_context,
|
||||
runtime_config=runtime_config,
|
||||
tools=default_tool_registry(),
|
||||
)
|
||||
|
||||
prompt = render_system_prompt(parts)
|
||||
self.assertIn('# 计划', prompt)
|
||||
@@ -1,126 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_registry import (
|
||||
create_agent_definition,
|
||||
delete_agent_definition,
|
||||
load_agent_registry,
|
||||
render_agent_detail,
|
||||
render_agents_report,
|
||||
update_agent_definition,
|
||||
)
|
||||
|
||||
|
||||
def _write_agent(path: Path, *, name: str, description: str, body: str, extra: str = '') -> None:
|
||||
payload = (
|
||||
'---\n'
|
||||
f'name: {name}\n'
|
||||
f'description: "{description}"\n'
|
||||
f'{extra}'
|
||||
'---\n\n'
|
||||
f'{body}\n'
|
||||
)
|
||||
path.write_text(payload, encoding='utf-8')
|
||||
|
||||
|
||||
class AgentRegistryTests(unittest.TestCase):
|
||||
def test_create_update_delete_agent_definition_roundtrip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
with patch.dict(os.environ, {'HOME': home_dir}):
|
||||
created = create_agent_definition(
|
||||
workspace,
|
||||
agent_type='reviewer',
|
||||
description='Review implementation changes.',
|
||||
system_prompt='Inspect diffs and summarize risks.',
|
||||
source='project',
|
||||
tools=('read_file', 'grep_search'),
|
||||
model='demo-model',
|
||||
initial_prompt='Start with changed files.',
|
||||
)
|
||||
self.assertEqual(created.action, 'created')
|
||||
self.assertTrue(Path(created.file_path).exists())
|
||||
|
||||
snapshot = load_agent_registry(workspace)
|
||||
detail = render_agent_detail(snapshot, 'reviewer')
|
||||
self.assertIn('demo-model', detail)
|
||||
self.assertIn('Start with changed files.', detail)
|
||||
|
||||
updated = update_agent_definition(
|
||||
workspace,
|
||||
agent_type='reviewer',
|
||||
description='Review code and tests carefully.',
|
||||
system_prompt='Focus on regressions and missing coverage.',
|
||||
source='project',
|
||||
)
|
||||
self.assertEqual(updated.action, 'updated')
|
||||
|
||||
snapshot = load_agent_registry(workspace)
|
||||
detail = render_agent_detail(snapshot, 'reviewer')
|
||||
self.assertIn('Review code and tests carefully.', detail)
|
||||
self.assertIn('Focus on regressions and missing coverage.', detail)
|
||||
|
||||
deleted = delete_agent_definition(
|
||||
workspace,
|
||||
agent_type='reviewer',
|
||||
source='project',
|
||||
)
|
||||
self.assertEqual(deleted.action, 'deleted')
|
||||
self.assertFalse(Path(deleted.file_path).exists())
|
||||
|
||||
def test_project_agent_overrides_built_in_agent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
agents_dir = workspace / '.claude' / 'agents'
|
||||
agents_dir.mkdir(parents=True)
|
||||
_write_agent(
|
||||
agents_dir / 'Explore.md',
|
||||
name='Explore',
|
||||
description='Project-specific explore agent.',
|
||||
body='Search this repository carefully before answering.',
|
||||
extra=(
|
||||
'tools: read_file, grep_search\n'
|
||||
'model: child-model\n'
|
||||
'initialPrompt: Begin with rg-style discovery.\n'
|
||||
),
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {'HOME': home_dir}):
|
||||
snapshot = load_agent_registry(workspace)
|
||||
|
||||
active = {agent.agent_type: agent for agent in snapshot.active_agents}
|
||||
self.assertIn('Explore', active)
|
||||
self.assertEqual(active['Explore'].source, 'projectSettings')
|
||||
self.assertEqual(active['Explore'].model, 'child-model')
|
||||
self.assertEqual(active['Explore'].tools, ('read_file', 'grep_search'))
|
||||
self.assertEqual(active['Explore'].initial_prompt, 'Begin with rg-style discovery.')
|
||||
|
||||
report = render_agents_report(snapshot, cwd=workspace)
|
||||
self.assertIn('Explore [projectSettings]', report)
|
||||
self.assertIn('Shadowed Agents', report)
|
||||
|
||||
detail = render_agent_detail(snapshot, 'Explore')
|
||||
self.assertIn('Project-specific explore agent.', detail)
|
||||
self.assertIn('Begin with rg-style discovery.', detail)
|
||||
self.assertIn('Search this repository carefully before answering.', detail)
|
||||
|
||||
def test_invalid_agent_file_is_reported(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
agents_dir = workspace / '.claude' / 'agents'
|
||||
agents_dir.mkdir(parents=True)
|
||||
(agents_dir / 'broken.md').write_text(
|
||||
'---\nname: broken\n---\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {'HOME': home_dir}):
|
||||
snapshot = load_agent_registry(workspace)
|
||||
|
||||
self.assertEqual(len(snapshot.failed_files), 1)
|
||||
self.assertIn('missing a description', snapshot.failed_files[0].error)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,534 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_slash_commands import looks_like_command, parse_slash_command
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
from src.plan_runtime import PlanRuntime
|
||||
from src.task_runtime import TaskRuntime
|
||||
|
||||
|
||||
class _FakeHTTPResponse:
|
||||
def __init__(self, payload: str) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.payload.encode('utf-8')
|
||||
|
||||
def __enter__(self) -> '_FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _write_fake_mcp_server(workspace: Path) -> Path:
|
||||
server_path = workspace / 'fake_mcp_server.py'
|
||||
server_path.write_text(
|
||||
(
|
||||
'import json, sys\n'
|
||||
'TOOLS = [{"name": "echo", "description": "Echo text", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}}]\n'
|
||||
'for raw in sys.stdin:\n'
|
||||
' raw = raw.strip()\n'
|
||||
' if not raw:\n'
|
||||
' continue\n'
|
||||
' message = json.loads(raw)\n'
|
||||
' method = message.get("method")\n'
|
||||
' if method == "initialize":\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"protocolVersion": "2025-11-25", "capabilities": {"resources": {}, "tools": {}}, "serverInfo": {"name": "fake-remote", "version": "1.0.0"}}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' if method == "notifications/initialized":\n'
|
||||
' continue\n'
|
||||
' if method == "tools/list":\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": TOOLS}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' if method == "tools/call":\n'
|
||||
' text = message.get("params", {}).get("arguments", {}).get("text", "")\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"content": [{"type": "text", "text": "echo:" + text}], "isError": False}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"resources": []}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
return server_path
|
||||
|
||||
|
||||
class AgentSlashCommandTests(unittest.TestCase):
|
||||
def test_parse_slash_command(self) -> None:
|
||||
parsed = parse_slash_command('/context extra args')
|
||||
assert parsed is not None
|
||||
self.assertEqual(parsed.command_name, 'context')
|
||||
self.assertEqual(parsed.args, 'extra args')
|
||||
self.assertFalse(parsed.is_mcp)
|
||||
|
||||
def test_looks_like_command(self) -> None:
|
||||
self.assertTrue(looks_like_command('context'))
|
||||
self.assertFalse(looks_like_command('foo/bar'))
|
||||
|
||||
def test_model_command_updates_agent_model(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/model local/test-model')
|
||||
self.assertIn('Set model to local/test-model', result.final_output)
|
||||
self.assertEqual(agent.model_config.model, 'local/test-model')
|
||||
|
||||
def test_unknown_command_returns_local_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/unknown-command')
|
||||
self.assertEqual(result.final_output, 'Unknown skill: unknown-command')
|
||||
|
||||
def test_context_command_renders_usage_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'CLAUDE.md').write_text('repo instructions\n', encoding='utf-8')
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('/context')
|
||||
self.assertIn('## Context Usage', result.final_output)
|
||||
self.assertIn('### Estimated usage by category', result.final_output)
|
||||
self.assertIn('### Memory Files', result.final_output)
|
||||
|
||||
def test_token_budget_command_renders_local_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('/token-budget')
|
||||
self.assertIn('# Token Budget', result.final_output)
|
||||
self.assertIn('Hard input limit', result.final_output)
|
||||
self.assertIn('Auto-compact buffer', result.final_output)
|
||||
|
||||
def test_agents_command_lists_and_shows_custom_agents(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
agents_dir = workspace / '.claude' / 'agents'
|
||||
agents_dir.mkdir(parents=True)
|
||||
(agents_dir / 'reviewer.md').write_text(
|
||||
(
|
||||
'---\n'
|
||||
'name: reviewer\n'
|
||||
'description: "Review implementation changes carefully."\n'
|
||||
'tools: read_file, grep_search\n'
|
||||
'model: test-child-model\n'
|
||||
'---\n\n'
|
||||
'Inspect code changes and summarize risks.\n'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
with patch.dict(os.environ, {'HOME': home_dir}):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
list_result = agent.run('/agents')
|
||||
detail_result = agent.run('/agents show reviewer')
|
||||
self.assertIn('# Agents', list_result.final_output)
|
||||
self.assertIn('reviewer [projectSettings]', list_result.final_output)
|
||||
self.assertIn('# Agent: reviewer', detail_result.final_output)
|
||||
self.assertIn('Inspect code changes and summarize risks.', detail_result.final_output)
|
||||
|
||||
def test_agents_command_can_create_update_and_delete_project_agent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
with patch.dict(os.environ, {'HOME': home_dir}):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
create_result = agent.run(
|
||||
'/agents create reviewer :: Review code changes carefully. :: Inspect code changes and summarize risks.'
|
||||
)
|
||||
self.assertIn('action=created', create_result.final_output)
|
||||
self.assertTrue((workspace / '.claude' / 'agents' / 'reviewer.md').exists())
|
||||
|
||||
update_result = agent.run(
|
||||
'/agents update reviewer Updated review description :: Focus on regressions and missing tests.'
|
||||
)
|
||||
self.assertIn('action=updated', update_result.final_output)
|
||||
|
||||
detail_result = agent.run('/agents reviewer')
|
||||
self.assertIn('Updated review description', detail_result.final_output)
|
||||
self.assertIn('Focus on regressions and missing tests.', detail_result.final_output)
|
||||
|
||||
delete_result = agent.run('/agents delete reviewer')
|
||||
self.assertIn('action=deleted', delete_result.final_output)
|
||||
self.assertFalse((workspace / '.claude' / 'agents' / 'reviewer.md').exists())
|
||||
|
||||
def test_mcp_and_resource_commands_render_local_reports(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
(
|
||||
'{"servers":[{"name":"workspace","resources":['
|
||||
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
|
||||
']}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
mcp_result = agent.run('/mcp')
|
||||
resources_result = agent.run('/resources')
|
||||
resource_result = agent.run('/resource mcp://workspace/notes')
|
||||
legacy_mcp_result = agent.run('/mcp (MCP)')
|
||||
self.assertIn('# MCP', mcp_result.final_output)
|
||||
self.assertIn('Local MCP resources: 1', mcp_result.final_output)
|
||||
self.assertIn('# MCP Resources', resources_result.final_output)
|
||||
self.assertIn('mcp://workspace/notes', resources_result.final_output)
|
||||
self.assertIn('# MCP Resource', resource_result.final_output)
|
||||
self.assertIn('mcp notes', resource_result.final_output)
|
||||
self.assertIn('# MCP', legacy_mcp_result.final_output)
|
||||
|
||||
def test_mcp_tools_command_renders_transport_backed_tools(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
server_path = _write_fake_mcp_server(workspace)
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
json.dumps(
|
||||
{
|
||||
'mcpServers': {
|
||||
'remote': {
|
||||
'command': sys.executable,
|
||||
'args': ['-u', str(server_path)],
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
tools_result = agent.run('/mcp tools')
|
||||
tool_result = agent.run('/mcp tool echo')
|
||||
self.assertIn('# MCP Tools', tools_result.final_output)
|
||||
self.assertIn('echo', tools_result.final_output)
|
||||
self.assertIn('# MCP Tool Result', tool_result.final_output)
|
||||
self.assertIn('echo:', tool_result.final_output)
|
||||
|
||||
def test_search_commands_render_and_update_local_search_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-search.json').write_text(
|
||||
(
|
||||
'{"providers":['
|
||||
'{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"},'
|
||||
'{"name":"backup-search","provider":"searxng","baseUrl":"http://127.0.0.2:8080"}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
with patch(
|
||||
'src.search_runtime.request.urlopen',
|
||||
return_value=_FakeHTTPResponse(
|
||||
'{"results":[{"title":"Alpha","url":"https://example.com/alpha","content":"Search snippet"}]}'
|
||||
),
|
||||
):
|
||||
search_result = agent.run('/search alpha query')
|
||||
providers_result = agent.run('/search providers')
|
||||
activate_result = agent.run('/search use backup-search')
|
||||
provider_result = agent.run('/search provider backup-search')
|
||||
self.assertIn('# Web Search', search_result.final_output)
|
||||
self.assertIn('Alpha', search_result.final_output)
|
||||
self.assertIn('# Search Providers', providers_result.final_output)
|
||||
self.assertIn('local-search', providers_result.final_output)
|
||||
self.assertIn('backup-search', providers_result.final_output)
|
||||
self.assertIn('provider=backup-search', activate_result.final_output)
|
||||
self.assertIn('# Search Provider', provider_result.final_output)
|
||||
self.assertIn('backup-search', provider_result.final_output)
|
||||
|
||||
def test_lsp_commands_render_local_reports(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'sample.py').write_text(
|
||||
'def helper(value):\n'
|
||||
' """Double a value."""\n'
|
||||
' return value * 2\n'
|
||||
'\n'
|
||||
'def run(item):\n'
|
||||
' return helper(item)\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
summary_result = agent.run('/lsp')
|
||||
symbols_result = agent.run('/lsp symbols sample.py')
|
||||
definition_result = agent.run('/lsp definition sample.py 6 12')
|
||||
diagnostics_result = agent.run('/lsp diagnostics sample.py')
|
||||
self.assertIn('# LSP', summary_result.final_output)
|
||||
self.assertIn('Indexed candidate files: 1', summary_result.final_output)
|
||||
self.assertIn('# LSP Document Symbols', symbols_result.final_output)
|
||||
self.assertIn('function helper', symbols_result.final_output)
|
||||
self.assertIn('# LSP Definition', definition_result.final_output)
|
||||
self.assertIn('function helper', definition_result.final_output)
|
||||
self.assertIn('# LSP Diagnostics', diagnostics_result.final_output)
|
||||
|
||||
def test_remote_commands_render_and_update_local_remote_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-remote.json').write_text(
|
||||
(
|
||||
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
|
||||
'"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
remotes_result = agent.run('/remotes')
|
||||
remote_result = agent.run('/remote')
|
||||
ssh_result = agent.run('/ssh staging')
|
||||
disconnect_result = agent.run('/disconnect')
|
||||
self.assertIn('# Remote Profiles', remotes_result.final_output)
|
||||
self.assertIn('staging', remotes_result.final_output)
|
||||
self.assertIn('# Remote', remote_result.final_output)
|
||||
self.assertIn('Configured remote profiles: 1', remote_result.final_output)
|
||||
self.assertIn('mode=ssh', ssh_result.final_output)
|
||||
self.assertIn('profile=staging', ssh_result.final_output)
|
||||
self.assertIn('connected=False', disconnect_result.final_output)
|
||||
|
||||
def test_workflow_and_trigger_commands_render_local_reports(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-workflows.json').write_text(
|
||||
(
|
||||
'{"workflows":['
|
||||
'{"name":"review","description":"Review changes.","steps":["Inspect diff","Summarize findings"]}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
(workspace / '.claw-triggers.json').write_text(
|
||||
(
|
||||
'{"triggers":['
|
||||
'{"trigger_id":"nightly","name":"Nightly","workflow":"review","schedule":"0 0 * * *"}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
workflows_result = agent.run('/workflows')
|
||||
workflow_result = agent.run('/workflow review')
|
||||
trigger_result = agent.run('/trigger nightly')
|
||||
trigger_run_result = agent.run('/trigger run nightly')
|
||||
self.assertIn('# Workflows', workflows_result.final_output)
|
||||
self.assertIn('review', workflows_result.final_output)
|
||||
self.assertIn('# Workflow', workflow_result.final_output)
|
||||
self.assertIn('Review changes', workflow_result.final_output)
|
||||
self.assertIn('# Remote Trigger', trigger_result.final_output)
|
||||
self.assertIn('trigger_id=nightly', trigger_result.final_output)
|
||||
self.assertIn('# Remote Trigger Run', trigger_run_result.final_output)
|
||||
|
||||
def test_account_commands_render_and_update_local_account_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-account.json').write_text(
|
||||
'{"profiles":[{"name":"local","provider":"openai","identity":"dev@example.com"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
account_result = agent.run('/account')
|
||||
profiles_result = agent.run('/account profiles')
|
||||
login_result = agent.run('/login local')
|
||||
logout_result = agent.run('/logout')
|
||||
self.assertIn('# Account', account_result.final_output)
|
||||
self.assertIn('Configured account profiles: 1', account_result.final_output)
|
||||
self.assertIn('# Account Profiles', profiles_result.final_output)
|
||||
self.assertIn('dev@example.com', profiles_result.final_output)
|
||||
self.assertIn('profile=local', login_result.final_output)
|
||||
self.assertIn('logged_in=False', logout_result.final_output)
|
||||
|
||||
def test_ask_commands_render_local_ask_user_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-ask-user.json').write_text(
|
||||
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
status_result = agent.run('/ask')
|
||||
history_result = agent.run('/ask history')
|
||||
self.assertIn('# Ask User', status_result.final_output)
|
||||
self.assertIn('Queued answers: 1', status_result.final_output)
|
||||
self.assertIn('# Ask User History', history_result.final_output)
|
||||
|
||||
def test_team_commands_render_local_team_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-teams.json').write_text(
|
||||
'{"teams":[{"name":"reviewers","members":["alice","bob"]}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
teams_result = agent.run('/teams')
|
||||
team_result = agent.run('/team reviewers')
|
||||
messages_result = agent.run('/messages')
|
||||
self.assertIn('# Teams', teams_result.final_output)
|
||||
self.assertIn('reviewers', teams_result.final_output)
|
||||
self.assertIn('# Team', team_result.final_output)
|
||||
self.assertIn('alice', team_result.final_output)
|
||||
self.assertIn('# Team Messages', messages_result.final_output)
|
||||
|
||||
def test_config_commands_render_local_reports(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
claude_dir = workspace / '.claude'
|
||||
claude_dir.mkdir()
|
||||
(claude_dir / 'settings.json').write_text(
|
||||
'{"review":{"mode":"strict"}}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
config_result = agent.run('/config')
|
||||
effective_result = agent.run('/config effective')
|
||||
value_result = agent.run('/config get review.mode')
|
||||
source_result = agent.run('/settings source project')
|
||||
self.assertIn('# Config', config_result.final_output)
|
||||
self.assertIn('Config sources: 1', config_result.final_output)
|
||||
self.assertIn('# Config Effective', effective_result.final_output)
|
||||
self.assertIn('"review"', effective_result.final_output)
|
||||
self.assertIn('# Config Value', value_result.final_output)
|
||||
self.assertIn('"strict"', value_result.final_output)
|
||||
self.assertIn('# Config Source', source_result.final_output)
|
||||
|
||||
def test_tasks_and_task_commands_render_local_reports(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = TaskRuntime.from_workspace(workspace)
|
||||
mutation = runtime.create_task(
|
||||
title='Review runtime tasks',
|
||||
status='in_progress',
|
||||
)
|
||||
task_id = mutation.task.task_id if mutation.task is not None else ''
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
tasks_result = agent.run('/tasks')
|
||||
task_result = agent.run(f'/task {task_id}')
|
||||
todo_result = agent.run('/todo in_progress')
|
||||
next_result = agent.run('/task-next')
|
||||
self.assertIn('# Tasks', tasks_result.final_output)
|
||||
self.assertIn(task_id, tasks_result.final_output)
|
||||
self.assertIn('# Task', task_result.final_output)
|
||||
self.assertIn('in_progress', task_result.final_output)
|
||||
self.assertIn('# Tasks', todo_result.final_output)
|
||||
self.assertIn('# Next Tasks', next_result.final_output)
|
||||
self.assertIn(task_id, next_result.final_output)
|
||||
|
||||
def test_plan_command_renders_local_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
plan_runtime = PlanRuntime.from_workspace(workspace)
|
||||
plan_runtime.update_plan(
|
||||
[{'step': 'Inspect the plan command', 'status': 'in_progress'}],
|
||||
explanation='Use the local plan runtime.',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
plan_result = agent.run('/plan')
|
||||
self.assertIn('# Plan', plan_result.final_output)
|
||||
self.assertIn('Inspect the plan command', plan_result.final_output)
|
||||
|
||||
def test_tools_and_status_commands_render_local_reports(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
tools_result = agent.run('/tools')
|
||||
status_result = agent.run('/status')
|
||||
self.assertIn('# Tools', tools_result.final_output)
|
||||
self.assertIn('`read_file`', tools_result.final_output)
|
||||
self.assertIn('# Status', status_result.final_output)
|
||||
self.assertIn('Token counter:', status_result.final_output)
|
||||
self.assertIn('Last run: none', status_result.final_output)
|
||||
|
||||
def test_hooks_and_trust_commands_render_local_reports(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-policy.json').write_text(
|
||||
(
|
||||
'{"trusted": false, '
|
||||
'"managedSettings": {"reviewMode": "strict"}, '
|
||||
'"safeEnv": ["HOOK_SAFE_TOKEN"]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
with patch.dict('os.environ', {'HOOK_SAFE_TOKEN': 'demo-secret'}, clear=False):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
hooks_result = agent.run('/hooks')
|
||||
trust_result = agent.run('/trust')
|
||||
self.assertIn('# Hook Policy', hooks_result.final_output)
|
||||
self.assertIn('Local hook/policy manifests', hooks_result.final_output)
|
||||
self.assertIn('# Trust', trust_result.final_output)
|
||||
self.assertIn('untrusted', trust_result.final_output)
|
||||
self.assertIn('reviewMode=strict', trust_result.final_output)
|
||||
self.assertIn('HOOK_SAFE_TOKEN=demo-secret', trust_result.final_output)
|
||||
|
||||
def test_clear_command_clears_saved_runtime_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
agent.last_session = agent.build_session('hello')
|
||||
agent.last_run_result = object() # type: ignore[assignment]
|
||||
result = agent.run('/clear')
|
||||
self.assertIn('Cleared ephemeral Python agent state', result.final_output)
|
||||
self.assertIsNone(agent.last_session)
|
||||
self.assertIsNone(agent.last_run_result)
|
||||
@@ -1,269 +0,0 @@
|
||||
"""Security tests for agent_tools.py: path traversal, destructive commands, and env var filtering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import (
|
||||
ToolExecutionError,
|
||||
ToolPermissionError,
|
||||
_build_subprocess_env,
|
||||
_ensure_shell_allowed,
|
||||
_is_sensitive_env_var,
|
||||
_resolve_path,
|
||||
build_tool_context,
|
||||
default_tool_registry,
|
||||
)
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||
from src.session_env_vars import (
|
||||
clear_session_env_vars,
|
||||
set_session_env_var,
|
||||
)
|
||||
|
||||
|
||||
def _make_context(
|
||||
tmp_dir: str,
|
||||
*,
|
||||
allow_shell: bool = False,
|
||||
allow_destructive: bool = False,
|
||||
) -> "ToolExecutionContext": # noqa: F821
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=Path(tmp_dir),
|
||||
permissions=AgentPermissions(
|
||||
allow_shell_commands=allow_shell,
|
||||
allow_destructive_shell_commands=allow_destructive,
|
||||
),
|
||||
)
|
||||
return build_tool_context(config, tool_registry=default_tool_registry())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_path – path traversal prevention
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestResolvePath(unittest.TestCase):
|
||||
def test_relative_path_within_workspace(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / "hello.txt").write_text("hi")
|
||||
ctx = _make_context(tmp)
|
||||
result = _resolve_path("hello.txt", ctx)
|
||||
self.assertEqual(result, (Path(tmp) / "hello.txt").resolve())
|
||||
|
||||
def test_absolute_path_within_workspace(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp) / "sub" / "file.txt"
|
||||
target.parent.mkdir()
|
||||
target.write_text("data")
|
||||
ctx = _make_context(tmp)
|
||||
result = _resolve_path(str(target), ctx)
|
||||
self.assertEqual(result, target.resolve())
|
||||
|
||||
def test_traversal_with_dotdot_blocked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ctx = _make_context(tmp)
|
||||
with self.assertRaises(ToolExecutionError):
|
||||
_resolve_path("../outside", ctx)
|
||||
|
||||
def test_traversal_etc_passwd_blocked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ctx = _make_context(tmp)
|
||||
with self.assertRaises(ToolExecutionError):
|
||||
_resolve_path("../../etc/passwd", ctx)
|
||||
|
||||
def test_allow_missing_true_permits_nonexistent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ctx = _make_context(tmp)
|
||||
result = _resolve_path("does_not_exist.txt", ctx, allow_missing=True)
|
||||
self.assertEqual(result, (Path(tmp) / "does_not_exist.txt").resolve())
|
||||
|
||||
def test_allow_missing_false_raises_for_nonexistent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ctx = _make_context(tmp)
|
||||
with self.assertRaises(OSError):
|
||||
_resolve_path("does_not_exist.txt", ctx, allow_missing=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ensure_shell_allowed – destructive command blocking
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestEnsureShellAllowed(unittest.TestCase):
|
||||
def _ctx(self, *, allow_shell: bool = True, allow_destructive: bool = False) -> "ToolExecutionContext": # noqa: F821
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
return _make_context(
|
||||
self._tmp.name,
|
||||
allow_shell=allow_shell,
|
||||
allow_destructive=allow_destructive,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "_tmp"):
|
||||
self._tmp.cleanup()
|
||||
|
||||
# -- safe commands pass --------------------------------------------------
|
||||
def test_safe_commands_allowed(self):
|
||||
ctx = self._ctx()
|
||||
for cmd in ("ls -la", "cat file.txt", "echo hello", "grep foo bar.txt"):
|
||||
_ensure_shell_allowed(cmd, ctx) # should not raise
|
||||
|
||||
# -- destructive commands blocked -----------------------------------------
|
||||
def test_rm_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("rm -rf /", ctx)
|
||||
|
||||
def test_mv_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("mv a b", ctx)
|
||||
|
||||
def test_dd_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("dd if=/dev/zero of=/dev/sda", ctx)
|
||||
|
||||
def test_shutdown_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("shutdown -h now", ctx)
|
||||
|
||||
def test_reboot_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("reboot ", ctx)
|
||||
|
||||
def test_mkfs_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("mkfs.ext4 /dev/sda1", ctx)
|
||||
|
||||
def test_chmod_recursive_777_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("chmod -R 777 /", ctx)
|
||||
|
||||
def test_chown_recursive_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("chown -R root:root /", ctx)
|
||||
|
||||
def test_git_reset_hard_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("git reset --hard", ctx)
|
||||
|
||||
def test_git_clean_fd_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("git clean -fd", ctx)
|
||||
|
||||
def test_truncation_operator_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed(": > important.log", ctx)
|
||||
|
||||
# -- chained commands with destructive sub-commands -----------------------
|
||||
def test_chained_and_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("echo hi && rm -rf /", ctx)
|
||||
|
||||
def test_chained_or_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("false || rm file", ctx)
|
||||
|
||||
def test_chained_semicolon_blocked(self):
|
||||
ctx = self._ctx()
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("echo ok; mv a b", ctx)
|
||||
|
||||
# -- shell commands entirely disabled ------------------------------------
|
||||
def test_shell_disabled_raises(self):
|
||||
ctx = self._ctx(allow_shell=False)
|
||||
with self.assertRaises(ToolPermissionError):
|
||||
_ensure_shell_allowed("ls", ctx)
|
||||
|
||||
# -- allow_destructive bypasses blocking ---------------------------------
|
||||
def test_destructive_allowed_bypasses(self):
|
||||
ctx = self._ctx(allow_destructive=True)
|
||||
# All destructive commands should pass without raising
|
||||
for cmd in (
|
||||
"rm -rf /",
|
||||
"mv a b",
|
||||
"dd if=/dev/zero of=/dev/sda",
|
||||
"shutdown -h now",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"chmod -R 777 /",
|
||||
"chown -R root:root /",
|
||||
"git reset --hard",
|
||||
"git clean -fd",
|
||||
": > file",
|
||||
):
|
||||
_ensure_shell_allowed(cmd, ctx) # should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_sensitive_env_var – secret-name detection
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestIsSensitiveEnvVar(unittest.TestCase):
|
||||
def test_common_sensitive_vars_detected(self):
|
||||
for name in (
|
||||
"MY_SECRET",
|
||||
"GITHUB_TOKEN",
|
||||
"DB_PASSWORD",
|
||||
"SSH_PRIVATE_KEY",
|
||||
"MY_API_KEY",
|
||||
"CREDENTIAL_STORE",
|
||||
"AUTH_HEADER",
|
||||
):
|
||||
self.assertTrue(
|
||||
_is_sensitive_env_var(name),
|
||||
f"{name} should be detected as sensitive",
|
||||
)
|
||||
|
||||
def test_non_sensitive_vars_allowed(self):
|
||||
for name in ("HOME", "PATH", "LANG", "TERM", "USER", "SHELL"):
|
||||
self.assertFalse(
|
||||
_is_sensitive_env_var(name),
|
||||
f"{name} should not be detected as sensitive",
|
||||
)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertTrue(_is_sensitive_env_var("my_secret"))
|
||||
self.assertTrue(_is_sensitive_env_var("Github_Token"))
|
||||
self.assertTrue(_is_sensitive_env_var("db_password"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_subprocess_env – session env var merging
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestBuildSubprocessEnv(unittest.TestCase):
|
||||
def setUp(self):
|
||||
clear_session_env_vars()
|
||||
|
||||
def tearDown(self):
|
||||
clear_session_env_vars()
|
||||
|
||||
def test_session_env_var_appears_in_subprocess_env(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ctx = _make_context(tmp)
|
||||
set_session_env_var("CLAW_SESSION_FOO", "from-session")
|
||||
env = _build_subprocess_env(ctx)
|
||||
self.assertEqual(env["CLAW_SESSION_FOO"], "from-session")
|
||||
|
||||
def test_extra_env_overrides_session_env(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = AgentRuntimeConfig(cwd=Path(tmp))
|
||||
ctx = build_tool_context(
|
||||
config,
|
||||
tool_registry=default_tool_registry(),
|
||||
extra_env={"CLAW_OVERRIDE_ME": "from-extra"},
|
||||
)
|
||||
set_session_env_var("CLAW_OVERRIDE_ME", "from-session")
|
||||
env = _build_subprocess_env(ctx)
|
||||
self.assertEqual(env["CLAW_OVERRIDE_ME"], "from-extra")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,80 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.ask_user_runtime import AskUserRuntime
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
|
||||
|
||||
class AskUserRuntimeTests(unittest.TestCase):
|
||||
def test_ask_user_runtime_consumes_queued_answers(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-ask-user.json').write_text(
|
||||
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = AskUserRuntime.from_workspace(workspace)
|
||||
response = runtime.answer(question='Approve deploy?')
|
||||
restored = AskUserRuntime.from_workspace(workspace)
|
||||
|
||||
self.assertEqual(response.answer, 'yes')
|
||||
self.assertEqual(response.source, 'queued')
|
||||
self.assertEqual(len(restored.queued_answers), 0)
|
||||
self.assertEqual(len(restored.history), 1)
|
||||
|
||||
def test_ask_user_tool_executes_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-ask-user.json').write_text(
|
||||
'{"answers":[{"question":"Choose mode","answer":"safe"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = AskUserRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
ask_user_runtime=runtime,
|
||||
)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'ask_user_question',
|
||||
{
|
||||
'question': 'Choose mode',
|
||||
'choices': ['safe', 'fast'],
|
||||
'allow_free_text': False,
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('# Ask User', result.content)
|
||||
self.assertIn('safe', result.content)
|
||||
self.assertEqual(result.metadata.get('action'), 'ask_user_question')
|
||||
|
||||
def test_ask_user_tool_requests_web_review_when_no_answer_is_queued(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = AskUserRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
ask_user_runtime=runtime,
|
||||
)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'ask_user_question',
|
||||
{
|
||||
'question': 'Choose mining strategy',
|
||||
'choices': ['fast sample', 'strict filter'],
|
||||
'allow_free_text': True,
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual(result.metadata.get('action'), 'ask_user_question')
|
||||
self.assertTrue(result.metadata.get('requires_user_review'))
|
||||
self.assertIn('Choose mining strategy', result.content)
|
||||
self.assertIn('fast sample', result.content)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from agent_platform.auth import decode_openwebui_identity, verify_service_bearer
|
||||
|
||||
|
||||
def test_signed_identity_is_verified(identity_jwt: str, settings) -> None:
|
||||
identity = decode_openwebui_identity(identity_jwt, settings.openwebui_forward_jwt_secret)
|
||||
assert identity.user_id == "user-123"
|
||||
assert identity.email == "user@example.test"
|
||||
|
||||
|
||||
def test_invalid_identity_is_rejected(settings) -> None:
|
||||
with pytest.raises(HTTPException) as error:
|
||||
decode_openwebui_identity("not-a-jwt", settings.openwebui_forward_jwt_secret)
|
||||
assert error.value.status_code == 401
|
||||
|
||||
|
||||
def test_service_bearer_uses_exact_match() -> None:
|
||||
verify_service_bearer("Bearer expected", "expected")
|
||||
with pytest.raises(HTTPException) as error:
|
||||
verify_service_bearer("Bearer expect", "expected")
|
||||
assert error.value.status_code == 401
|
||||
@@ -1,109 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.background_runtime import BackgroundSessionRuntime
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class BackgroundRuntimeTests(unittest.TestCase):
|
||||
def test_runtime_can_launch_and_kill_generic_process(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = BackgroundSessionRuntime(workspace / '.port_sessions' / 'background')
|
||||
record = runtime.launch(
|
||||
[sys.executable, '-c', 'import time; time.sleep(10)'],
|
||||
prompt='sleep',
|
||||
workspace_cwd=workspace,
|
||||
model='local/test-model',
|
||||
process_cwd=workspace,
|
||||
)
|
||||
running = runtime.load_record(record.background_id)
|
||||
killed = runtime.kill(record.background_id)
|
||||
for _ in range(30):
|
||||
if runtime.load_record(record.background_id).status != 'running':
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
self.assertEqual(running.status, 'running')
|
||||
self.assertEqual(killed.status, 'killed')
|
||||
self.assertEqual(killed.stop_reason, 'killed')
|
||||
|
||||
def test_agent_background_cli_exposes_ps_logs_and_attach(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
run_dir = Path(tmp_dir)
|
||||
workspace = run_dir / 'workspace'
|
||||
workspace.mkdir()
|
||||
env = os.environ.copy()
|
||||
existing_pythonpath = env.get('PYTHONPATH')
|
||||
env['PYTHONPATH'] = (
|
||||
f'{PROJECT_ROOT}:{existing_pythonpath}'
|
||||
if existing_pythonpath
|
||||
else str(PROJECT_ROOT)
|
||||
)
|
||||
launch = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
'-m',
|
||||
'src.main',
|
||||
'agent-bg',
|
||||
'/help',
|
||||
'--cwd',
|
||||
str(workspace),
|
||||
],
|
||||
cwd=run_dir,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
background_id = next(
|
||||
line.split('=', 1)[1]
|
||||
for line in launch.stdout.splitlines()
|
||||
if line.startswith('background_id=')
|
||||
)
|
||||
runtime = BackgroundSessionRuntime(run_dir / '.port_sessions' / 'background')
|
||||
record = runtime.load_record(background_id)
|
||||
for _ in range(60):
|
||||
if record.status in {'completed', 'failed', 'exited'}:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
record = runtime.load_record(background_id)
|
||||
ps = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'agent-ps'],
|
||||
cwd=run_dir,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
logs = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'agent-logs', background_id],
|
||||
cwd=run_dir,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
attach = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'agent-attach', background_id],
|
||||
cwd=run_dir,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertIn(background_id, launch.stdout)
|
||||
self.assertIn(background_id, ps.stdout)
|
||||
self.assertIn('# Background Logs', logs.stdout)
|
||||
self.assertIn('# Slash Commands', logs.stdout)
|
||||
self.assertIn('# Background Attach', attach.stdout)
|
||||
@@ -1,324 +0,0 @@
|
||||
"""Tests for the bash run_in_background path (local fallback only).
|
||||
|
||||
Covers:
|
||||
- BashBgStore: record_start / mark_completed / mark_auto_resumed race semantics.
|
||||
- _run_bash_background local fallback: spawns a detached process with file
|
||||
redirects, registers it via the bg_register callback, output/pid/exit_code
|
||||
files materialize.
|
||||
- _run_bash_status / _run_bash_kill: route through bg_status_query / bg_kill_request
|
||||
callbacks and format their result.
|
||||
|
||||
Remote (jupyter_runtime) path is intentionally not exercised here — that
|
||||
requires a live wsh account and is covered by the e2e checklist in the plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent_tool_core import ToolExecutionContext
|
||||
from src.agent_tools import (
|
||||
_run_bash_background,
|
||||
_run_bash_kill,
|
||||
_run_bash_status,
|
||||
)
|
||||
from src.agent_types import AgentPermissions
|
||||
from src.bash_bg_store import BashBgStore, BgTaskSpec, BgTaskStatus
|
||||
|
||||
|
||||
def _make_context(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
bg_register=None,
|
||||
bg_status_query=None,
|
||||
bg_kill_request=None,
|
||||
) -> ToolExecutionContext:
|
||||
return ToolExecutionContext(
|
||||
root=tmp_path,
|
||||
command_timeout_seconds=10.0,
|
||||
max_output_chars=4096,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
scratchpad_directory=tmp_path,
|
||||
bg_register=bg_register,
|
||||
bg_status_query=bg_status_query,
|
||||
bg_kill_request=bg_kill_request,
|
||||
account_id='acct-test',
|
||||
session_id='sess-test',
|
||||
run_id='run-test',
|
||||
)
|
||||
|
||||
|
||||
# ----- BashBgStore --------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_record_and_complete(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_abc',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=12345,
|
||||
task_dir=str(tmp_path / 'bg_abc'),
|
||||
output_path=str(tmp_path / 'bg_abc' / 'output'),
|
||||
pid_path=str(tmp_path / 'bg_abc' / 'pid'),
|
||||
exit_code_path=str(tmp_path / 'bg_abc' / 'exit_code'),
|
||||
command='echo hi',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=True,
|
||||
)
|
||||
store.record_start(spec)
|
||||
row = store.get('bg_abc')
|
||||
assert row is not None
|
||||
assert row['status'] == 'running'
|
||||
assert row['exit_code'] is None
|
||||
|
||||
store.mark_completed('bg_abc', exit_code=0)
|
||||
row = store.get('bg_abc')
|
||||
assert row['status'] == 'completed'
|
||||
assert row['exit_code'] == 0
|
||||
assert row['finished_at'] is not None
|
||||
|
||||
|
||||
def test_store_mark_auto_resumed_is_race_safe(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_race',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=1,
|
||||
task_dir='/tmp/x',
|
||||
output_path='/tmp/x/output',
|
||||
pid_path='/tmp/x/pid',
|
||||
exit_code_path='/tmp/x/exit_code',
|
||||
command='true',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=True,
|
||||
)
|
||||
store.record_start(spec)
|
||||
store.mark_completed('bg_race', exit_code=0)
|
||||
|
||||
# First caller wins; subsequent callers always see False.
|
||||
assert store.mark_auto_resumed('bg_race') is True
|
||||
assert store.mark_auto_resumed('bg_race') is False
|
||||
assert store.mark_auto_resumed('bg_race') is False
|
||||
|
||||
|
||||
def test_store_mark_killed_only_running(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_kill',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=2,
|
||||
task_dir='/tmp/y',
|
||||
output_path='/tmp/y/output',
|
||||
pid_path='/tmp/y/pid',
|
||||
exit_code_path='/tmp/y/exit_code',
|
||||
command='sleep 100',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=False,
|
||||
)
|
||||
store.record_start(spec)
|
||||
store.mark_killed('bg_kill')
|
||||
row = store.get('bg_kill')
|
||||
assert row['status'] == 'cancelled'
|
||||
|
||||
# Re-killing a non-running task is a no-op (status stays cancelled).
|
||||
store.mark_killed('bg_kill')
|
||||
row = store.get('bg_kill')
|
||||
assert row['status'] == 'cancelled'
|
||||
|
||||
|
||||
# ----- _run_bash_background local fallback --------------------------------
|
||||
|
||||
|
||||
def test_run_bash_background_local_spawns_detached(tmp_path: Path) -> None:
|
||||
captured: list[BgTaskSpec] = []
|
||||
|
||||
ctx = _make_context(tmp_path, bg_register=captured.append)
|
||||
content, metadata = _run_bash_background(
|
||||
{
|
||||
'command': 'echo hello-from-bg',
|
||||
'wait_for_completion': True,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
assert metadata['action'] == 'bash_bg'
|
||||
task_id = metadata['task_id']
|
||||
assert task_id.startswith('bg_')
|
||||
assert metadata['wait_for_completion'] is True
|
||||
assert 'pid=' in content
|
||||
|
||||
# bg_register received exactly one spec, with the agent's session/account.
|
||||
assert len(captured) == 1
|
||||
spec = captured[0]
|
||||
assert spec.task_id == task_id
|
||||
assert spec.account_key == 'acct-test'
|
||||
assert spec.session_id == 'sess-test'
|
||||
assert spec.run_id == 'run-test'
|
||||
assert spec.command == 'echo hello-from-bg'
|
||||
assert spec.wait_for_completion is True
|
||||
|
||||
# Wait for the detached subprocess to finish — bounded by exit_code file.
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(spec.exit_code_path)
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
assert exit_code_path.exists(), 'exit_code file did not appear in 5s'
|
||||
assert exit_code_path.read_text(encoding='utf-8').strip() == '0'
|
||||
|
||||
output_text = Path(spec.output_path).read_text(encoding='utf-8')
|
||||
assert 'hello-from-bg' in output_text
|
||||
|
||||
|
||||
def test_run_bash_background_failed_command_records_nonzero_exit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
captured: list[BgTaskSpec] = []
|
||||
ctx = _make_context(tmp_path, bg_register=captured.append)
|
||||
|
||||
_content, metadata = _run_bash_background(
|
||||
{'command': 'exit 7'},
|
||||
ctx,
|
||||
)
|
||||
spec = captured[0]
|
||||
assert metadata['action'] == 'bash_bg'
|
||||
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(spec.exit_code_path)
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
assert exit_code_path.exists()
|
||||
assert exit_code_path.read_text(encoding='utf-8').strip() == '7'
|
||||
|
||||
|
||||
def test_run_bash_background_requires_shell_permission(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolPermissionError
|
||||
|
||||
ctx = ToolExecutionContext(
|
||||
root=tmp_path,
|
||||
command_timeout_seconds=10.0,
|
||||
max_output_chars=4096,
|
||||
permissions=AgentPermissions(allow_shell_commands=False),
|
||||
scratchpad_directory=tmp_path,
|
||||
)
|
||||
with pytest.raises(ToolPermissionError):
|
||||
_run_bash_background({'command': 'echo nope'}, ctx)
|
||||
|
||||
|
||||
# ----- bash_status / bash_kill via callbacks ------------------------------
|
||||
|
||||
|
||||
def test_bash_status_routes_through_callback(tmp_path: Path) -> None:
|
||||
fake_status = BgTaskStatus(
|
||||
task_id='bg_xyz',
|
||||
status='completed',
|
||||
pid=999,
|
||||
started_at=time.time() - 5.0,
|
||||
finished_at=time.time(),
|
||||
exit_code=0,
|
||||
output_path='/tmp/x/output',
|
||||
output_preview='all good\n',
|
||||
auto_resumed=False,
|
||||
)
|
||||
|
||||
queries: list[str] = []
|
||||
|
||||
def query(task_id: str) -> BgTaskStatus | None:
|
||||
queries.append(task_id)
|
||||
return fake_status
|
||||
|
||||
ctx = _make_context(tmp_path, bg_status_query=query)
|
||||
content, metadata = _run_bash_status({'task_id': 'bg_xyz'}, ctx)
|
||||
|
||||
assert queries == ['bg_xyz']
|
||||
assert metadata['action'] == 'bash_status'
|
||||
assert metadata['status'] == 'completed'
|
||||
assert metadata['exit_code'] == 0
|
||||
assert 'all good' in content
|
||||
assert 'task_id=bg_xyz' in content
|
||||
|
||||
|
||||
def test_bash_status_unknown_task_raises(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolExecutionError
|
||||
|
||||
def query(_task_id: str):
|
||||
return None
|
||||
|
||||
ctx = _make_context(tmp_path, bg_status_query=query)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
_run_bash_status({'task_id': 'bg_missing'}, ctx)
|
||||
|
||||
|
||||
def test_bash_status_without_callback_raises(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolExecutionError
|
||||
|
||||
ctx = _make_context(tmp_path)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
_run_bash_status({'task_id': 'bg_anything'}, ctx)
|
||||
|
||||
|
||||
def test_bash_kill_routes_through_callback(tmp_path: Path) -> None:
|
||||
killed: list[str] = []
|
||||
|
||||
def kill(task_id: str) -> bool:
|
||||
killed.append(task_id)
|
||||
return True
|
||||
|
||||
ctx = _make_context(tmp_path, bg_kill_request=kill)
|
||||
content, metadata = _run_bash_kill({'task_id': 'bg_kk'}, ctx)
|
||||
assert killed == ['bg_kk']
|
||||
assert metadata['killed'] is True
|
||||
assert 'bg_kk' in content
|
||||
|
||||
|
||||
def test_bash_kill_failure_reports(tmp_path: Path) -> None:
|
||||
ctx = _make_context(tmp_path, bg_kill_request=lambda _t: False)
|
||||
content, metadata = _run_bash_kill({'task_id': 'bg_dead'}, ctx)
|
||||
assert metadata['killed'] is False
|
||||
assert '未能终止' in content
|
||||
|
||||
|
||||
# ----- end-to-end: spawn + register + sync via real BashBgStore -----------
|
||||
|
||||
|
||||
def test_local_bg_lifecycle_with_real_store(tmp_path: Path) -> None:
|
||||
"""Plug a real BashBgStore into bg_register and check the row appears."""
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
|
||||
def register(spec: BgTaskSpec) -> None:
|
||||
store.record_start(spec)
|
||||
|
||||
ctx = _make_context(tmp_path, bg_register=register)
|
||||
_content, metadata = _run_bash_background(
|
||||
{'command': 'echo lifecycle && exit 0'}, ctx,
|
||||
)
|
||||
task_id = metadata['task_id']
|
||||
row = store.get(task_id)
|
||||
assert row is not None
|
||||
assert row['status'] == 'running'
|
||||
|
||||
# Wait for child to write exit_code (more reliable than kill -0, since
|
||||
# the unreaped child becomes a zombie and kill -0 still reports it alive).
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(row['exit_code_path'])
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
if not exit_code_path.exists():
|
||||
pytest.fail('background process did not exit within 5 seconds')
|
||||
|
||||
# Simulate manager finalize step.
|
||||
exit_code = int(
|
||||
exit_code_path.read_text(encoding='utf-8').strip()
|
||||
)
|
||||
store.mark_completed(task_id, exit_code=exit_code)
|
||||
row = store.get(task_id)
|
||||
assert row['status'] == 'completed'
|
||||
assert row['exit_code'] == 0
|
||||
@@ -1,878 +0,0 @@
|
||||
"""
|
||||
Tests for bash_security module.
|
||||
|
||||
Tests are organized by validator function, matching the npm test structure.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.bash_security import (
|
||||
SecurityBehavior,
|
||||
SecurityResult,
|
||||
ValidationContext,
|
||||
bash_command_is_safe,
|
||||
check_shell_security,
|
||||
extract_quoted_content,
|
||||
get_destructive_command_warning,
|
||||
has_unescaped_char,
|
||||
interpret_command_result,
|
||||
is_command_read_only,
|
||||
split_command,
|
||||
strip_safe_redirections,
|
||||
validate_backslash_escaped_operators,
|
||||
validate_backslash_escaped_whitespace,
|
||||
validate_brace_expansion,
|
||||
validate_carriage_return,
|
||||
validate_comment_quote_desync,
|
||||
validate_control_characters,
|
||||
validate_dangerous_patterns,
|
||||
validate_dangerous_variables,
|
||||
validate_empty,
|
||||
validate_git_commit,
|
||||
validate_ifs_injection,
|
||||
validate_incomplete_commands,
|
||||
validate_jq_command,
|
||||
validate_mid_word_hash,
|
||||
validate_newlines,
|
||||
validate_obfuscated_flags,
|
||||
validate_proc_environ_access,
|
||||
validate_quoted_newline,
|
||||
validate_redirections,
|
||||
validate_shell_metacharacters,
|
||||
validate_unicode_whitespace,
|
||||
validate_zsh_dangerous_commands,
|
||||
)
|
||||
|
||||
|
||||
# ---- Helper to build a context ----
|
||||
|
||||
def _ctx(cmd: str) -> ValidationContext:
|
||||
"""Build a ValidationContext for the given command."""
|
||||
base = cmd.strip().split()[0] if cmd.strip() else ''
|
||||
with_dq, fully_unq, keep_qc = extract_quoted_content(cmd)
|
||||
return ValidationContext(
|
||||
original_command=cmd,
|
||||
base_command=base,
|
||||
unquoted_content=with_dq,
|
||||
fully_unquoted_content=strip_safe_redirections(fully_unq),
|
||||
fully_unquoted_pre_strip=fully_unq,
|
||||
unquoted_keep_quote_chars=keep_qc,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# extract_quoted_content
|
||||
# ===========================================================================
|
||||
|
||||
class TestExtractQuotedContent:
|
||||
def test_no_quotes(self):
|
||||
dq, full, kqc = extract_quoted_content('echo hello')
|
||||
assert dq == 'echo hello'
|
||||
assert full == 'echo hello'
|
||||
|
||||
def test_single_quotes_stripped(self):
|
||||
dq, full, kqc = extract_quoted_content("echo 'hello world'")
|
||||
assert 'hello world' not in full
|
||||
assert 'echo' in full
|
||||
|
||||
def test_double_quotes_in_dq_output(self):
|
||||
dq, full, kqc = extract_quoted_content('echo "hello world"')
|
||||
assert 'hello world' in dq # double-quoted content preserved in dq
|
||||
assert 'hello world' not in full # but stripped in fully_unquoted
|
||||
|
||||
def test_escape_handling(self):
|
||||
dq, full, kqc = extract_quoted_content('echo \\$HOME')
|
||||
assert '$HOME' in full
|
||||
|
||||
def test_keep_quote_chars(self):
|
||||
_, _, kqc = extract_quoted_content("echo 'x'#")
|
||||
assert "'" in kqc # quote chars preserved
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# strip_safe_redirections
|
||||
# ===========================================================================
|
||||
|
||||
class TestStripSafeRedirections:
|
||||
def test_dev_null_output(self):
|
||||
assert '>/dev/null' not in strip_safe_redirections('cmd > /dev/null')
|
||||
|
||||
def test_stderr_redirect(self):
|
||||
assert '2>&1' not in strip_safe_redirections('cmd 2>&1')
|
||||
|
||||
def test_dev_null_input(self):
|
||||
assert '</dev/null' not in strip_safe_redirections('cmd < /dev/null')
|
||||
|
||||
def test_preserves_other_redirections(self):
|
||||
result = strip_safe_redirections('cmd > output.txt')
|
||||
assert '> output.txt' in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# has_unescaped_char
|
||||
# ===========================================================================
|
||||
|
||||
class TestHasUnescapedChar:
|
||||
def test_unescaped_backtick(self):
|
||||
assert has_unescaped_char('echo `date`', '`') is True
|
||||
|
||||
def test_escaped_backtick(self):
|
||||
assert has_unescaped_char('echo \\`safe\\`', '`') is False
|
||||
|
||||
def test_double_backslash_then_backtick(self):
|
||||
# \\\` → \\ (literal backslash) + ` (unescaped)
|
||||
assert has_unescaped_char('test\\\\`date`', '`') is True
|
||||
|
||||
def test_no_match(self):
|
||||
assert has_unescaped_char('echo hello', '`') is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# split_command
|
||||
# ===========================================================================
|
||||
|
||||
class TestSplitCommand:
|
||||
def test_simple(self):
|
||||
assert split_command('echo hello') == ['echo hello']
|
||||
|
||||
def test_semicolon(self):
|
||||
assert split_command('echo a; echo b') == ['echo a', 'echo b']
|
||||
|
||||
def test_and_and(self):
|
||||
assert split_command('cmd1 && cmd2') == ['cmd1', 'cmd2']
|
||||
|
||||
def test_pipe(self):
|
||||
assert split_command('cat file | grep pattern') == ['cat file', 'grep pattern']
|
||||
|
||||
def test_or_or(self):
|
||||
assert split_command('cmd1 || cmd2') == ['cmd1', 'cmd2']
|
||||
|
||||
def test_quotes_preserved(self):
|
||||
result = split_command("echo 'a; b'")
|
||||
assert len(result) == 1 # semicolon inside quotes not split
|
||||
|
||||
def test_complex(self):
|
||||
result = split_command('cd /tmp && echo hi; ls | head')
|
||||
assert len(result) == 4
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_empty
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateEmpty:
|
||||
def test_empty(self):
|
||||
assert validate_empty(_ctx('')).behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_whitespace_only(self):
|
||||
assert validate_empty(_ctx(' ')).behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_non_empty(self):
|
||||
assert validate_empty(_ctx('ls')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_control_characters
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateControlCharacters:
|
||||
def test_null_byte(self):
|
||||
result = validate_control_characters(_ctx('echo\x00hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_bell(self):
|
||||
result = validate_control_characters(_ctx('echo\x07hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean_command(self):
|
||||
result = validate_control_characters(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_tab_allowed(self):
|
||||
result = validate_control_characters(_ctx('echo\thello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_newline_allowed(self):
|
||||
result = validate_control_characters(_ctx('echo\nhello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_incomplete_commands
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateIncompleteCommands:
|
||||
def test_starts_with_tab(self):
|
||||
result = validate_incomplete_commands(_ctx('\techo hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_starts_with_dash(self):
|
||||
result = validate_incomplete_commands(_ctx('-rf /'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_starts_with_operator(self):
|
||||
result = validate_incomplete_commands(_ctx('&& echo hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
result = validate_incomplete_commands(_ctx('; echo hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_normal_command(self):
|
||||
result = validate_incomplete_commands(_ctx('ls -la'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_git_commit
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateGitCommit:
|
||||
def test_not_git(self):
|
||||
result = validate_git_commit(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_simple_commit(self):
|
||||
result = validate_git_commit(_ctx("git commit -m 'initial commit'"))
|
||||
assert result.behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_double_quoted_commit(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "fix bug"'))
|
||||
assert result.behavior == SecurityBehavior.ALLOW
|
||||
|
||||
def test_commit_with_substitution(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "$(date)"'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_commit_with_backtick(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "`date`"'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_commit_with_chained_commands(self):
|
||||
result = validate_git_commit(_ctx("git commit -m 'msg'; rm -rf /"))
|
||||
# Should passthrough (not early-allow) due to ; in remainder
|
||||
assert result.behavior != SecurityBehavior.ALLOW
|
||||
|
||||
def test_commit_with_backslash(self):
|
||||
result = validate_git_commit(_ctx('git commit -m "test\\"msg"'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_jq_command
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateJqCommand:
|
||||
def test_not_jq(self):
|
||||
assert validate_jq_command(_ctx('echo hi')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_jq_system(self):
|
||||
result = validate_jq_command(_ctx('jq "system(\"rm -rf /\")"'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_jq_from_file(self):
|
||||
result = validate_jq_command(_ctx('jq -f evil.jq'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_jq_slurpfile(self):
|
||||
result = validate_jq_command(_ctx('jq --slurpfile x data.json'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_safe_jq(self):
|
||||
result = validate_jq_command(_ctx('jq ".name" data.json'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_obfuscated_flags
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateObfuscatedFlags:
|
||||
def test_ansi_c_quoting(self):
|
||||
result = validate_obfuscated_flags(_ctx("find . $'-exec' evil"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_locale_quoting(self):
|
||||
result = validate_obfuscated_flags(_ctx('find . $"-exec" evil'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_echo_safe(self):
|
||||
result = validate_obfuscated_flags(_ctx("echo $'hello'"))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_empty_quotes_before_dash(self):
|
||||
result = validate_obfuscated_flags(_ctx("find . '' -exec evil"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_quoted_flag(self):
|
||||
result = validate_obfuscated_flags(_ctx('find . "-exec" rm {} ;'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_normal_command(self):
|
||||
result = validate_obfuscated_flags(_ctx('ls -la'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_three_consecutive_quotes(self):
|
||||
result = validate_obfuscated_flags(_ctx("find . '''exec"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_shell_metacharacters
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateShellMetacharacters:
|
||||
def test_semicolon_in_quotes(self):
|
||||
result = validate_shell_metacharacters(_ctx('echo "a;b"'))
|
||||
# unquoted_content (with_double_quotes) has the ; inside
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH or result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_find_name_with_pipe(self):
|
||||
# Single-quoted pipe is stripped entirely from unquoted content → safe
|
||||
ctx = _ctx("find . -name '|evil'")
|
||||
result = validate_shell_metacharacters(ctx)
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_double_quoted_metachar(self):
|
||||
# Check that we catch metacharacters in unquoted positions
|
||||
# The npm version checks the double-quote-retained string for
|
||||
# quoted metacharacters, but we strip quote chars. So we test
|
||||
# the actual dangerous case: unquoted semicolon
|
||||
ctx = _ctx('find . -name evil; rm -rf /')
|
||||
# This won't be caught by this specific validator (it looks for
|
||||
# metacharacters INSIDE quoted args, not command separators)
|
||||
result = validate_shell_metacharacters(ctx)
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_clean_command(self):
|
||||
assert validate_shell_metacharacters(_ctx('ls -la')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_dangerous_variables
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateDangerousVariables:
|
||||
def test_variable_in_pipe(self):
|
||||
result = validate_dangerous_variables(_ctx('$CMD | grep x'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_variable_in_redirect(self):
|
||||
result = validate_dangerous_variables(_ctx('echo x > $FILE'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_safe_variable(self):
|
||||
result = validate_dangerous_variables(_ctx('echo $HOME'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_dangerous_patterns
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateDangerousPatterns:
|
||||
def test_backtick(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo `date`'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dollar_paren(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo $(date)'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dollar_brace(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo ${PATH}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_process_substitution(self):
|
||||
result = validate_dangerous_patterns(_ctx('diff <(cmd1) <(cmd2)'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_safe_echo(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_escaped_backtick(self):
|
||||
result = validate_dangerous_patterns(_ctx('echo \\`safe\\`'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_redirections
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateRedirections:
|
||||
def test_output_redirect(self):
|
||||
result = validate_redirections(_ctx('echo x > file.txt'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_input_redirect(self):
|
||||
result = validate_redirections(_ctx('cat < /etc/passwd'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dev_null_stripped(self):
|
||||
# >/dev/null is stripped by strip_safe_redirections
|
||||
result = validate_redirections(_ctx('cmd > /dev/null'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_no_redirect(self):
|
||||
result = validate_redirections(_ctx('echo hello'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_newlines
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateNewlines:
|
||||
def test_no_newlines(self):
|
||||
assert validate_newlines(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_newline_with_command(self):
|
||||
result = validate_newlines(_ctx('echo hello\nrm -rf /'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_backslash_continuation(self):
|
||||
result = validate_newlines(_ctx('cmd \\\n--flag'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_carriage_return
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateCarriageReturn:
|
||||
def test_no_cr(self):
|
||||
assert validate_carriage_return(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_cr_in_command(self):
|
||||
result = validate_carriage_return(_ctx('echo hello\rworld'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
assert result.is_misparsing is True
|
||||
|
||||
def test_cr_in_double_quotes_safe(self):
|
||||
result = validate_carriage_return(_ctx('echo "hello\rworld"'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_ifs_injection
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateIFSInjection:
|
||||
def test_ifs_variable(self):
|
||||
result = validate_ifs_injection(_ctx('echo$IFS/etc/passwd'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_ifs_expansion(self):
|
||||
result = validate_ifs_injection(_ctx('echo ${IFS:0:1}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_ifs_injection(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_proc_environ_access
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateProcEnvironAccess:
|
||||
def test_proc_environ(self):
|
||||
result = validate_proc_environ_access(_ctx('cat /proc/self/environ'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_proc_pid_environ(self):
|
||||
result = validate_proc_environ_access(_ctx('cat /proc/1/environ'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_proc_environ_access(_ctx('cat /etc/hosts')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_backslash_escaped_whitespace
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateBackslashEscapedWhitespace:
|
||||
def test_escaped_space(self):
|
||||
result = validate_backslash_escaped_whitespace(_ctx('echo\\ hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_escaped_tab(self):
|
||||
result = validate_backslash_escaped_whitespace(_ctx('echo\\\thello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_backslash_escaped_whitespace(
|
||||
_ctx('echo hello')
|
||||
).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_backslash_escaped_operators
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateBackslashEscapedOperators:
|
||||
def test_escaped_semicolon(self):
|
||||
result = validate_backslash_escaped_operators(_ctx('cat safe.txt \\; echo /etc/passwd'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_escaped_pipe(self):
|
||||
result = validate_backslash_escaped_operators(_ctx('cmd \\| evil'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_backslash_escaped_operators(
|
||||
_ctx('ls -la')
|
||||
).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_inside_quotes_safe(self):
|
||||
result = validate_backslash_escaped_operators(_ctx("echo '\\;'"))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_brace_expansion
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateBraceExpansion:
|
||||
def test_comma_expansion(self):
|
||||
result = validate_brace_expansion(_ctx('echo {a,b,c}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_sequence_expansion(self):
|
||||
result = validate_brace_expansion(_ctx('echo {1..5}'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_no_expansion(self):
|
||||
result = validate_brace_expansion(_ctx('echo {hello}'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_escaped_brace(self):
|
||||
result = validate_brace_expansion(_ctx('echo \\{a,b\\}'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_excess_closing_braces(self):
|
||||
result = validate_brace_expansion(_ctx("git diff {@'{'0},--output=/tmp/pwned}"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_unicode_whitespace
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateUnicodeWhitespace:
|
||||
def test_nbsp(self):
|
||||
result = validate_unicode_whitespace(_ctx('echo\u00a0hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_em_space(self):
|
||||
result = validate_unicode_whitespace(_ctx('echo\u2003hello'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean(self):
|
||||
assert validate_unicode_whitespace(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_mid_word_hash
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateMidWordHash:
|
||||
def test_mid_word_hash(self):
|
||||
result = validate_mid_word_hash(_ctx('echotest#comment'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_word_start_hash(self):
|
||||
result = validate_mid_word_hash(_ctx('echo # comment'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_dollar_brace_hash_safe(self):
|
||||
# ${#var} is bash string length, should be safe
|
||||
result = validate_mid_word_hash(_ctx('echo ${#var}'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_comment_quote_desync
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateCommentQuoteDesync:
|
||||
def test_quote_in_comment(self):
|
||||
result = validate_comment_quote_desync(_ctx("echo hello # it's a comment"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_clean_comment(self):
|
||||
result = validate_comment_quote_desync(_ctx('echo hello # clean comment'))
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_no_comment(self):
|
||||
assert validate_comment_quote_desync(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_quoted_newline
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateQuotedNewline:
|
||||
def test_quoted_newline_with_hash(self):
|
||||
result = validate_quoted_newline(_ctx("echo 'hello\n# dangerous line'"))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_no_newline(self):
|
||||
assert validate_quoted_newline(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_newline_without_hash(self):
|
||||
assert validate_quoted_newline(_ctx("echo 'hello\nworld'")).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# validate_zsh_dangerous_commands
|
||||
# ===========================================================================
|
||||
|
||||
class TestValidateZshDangerousCommands:
|
||||
def test_zmodload(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('zmodload zsh/system'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_zpty(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('zpty cmd echo'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_emulate(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('emulate -c evil'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_fc_e(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('fc -e vim'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_normal_command(self):
|
||||
assert validate_zsh_dangerous_commands(_ctx('ls -la')).behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_env_var_prefix(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('FOO=bar zmodload zsh/system'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_precommand_modifier(self):
|
||||
result = validate_zsh_dangerous_commands(_ctx('command zmodload zsh/system'))
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# bash_command_is_safe (integration)
|
||||
# ===========================================================================
|
||||
|
||||
class TestBashCommandIsSafe:
|
||||
def test_empty_command(self):
|
||||
result = bash_command_is_safe('')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_simple_ls(self):
|
||||
result = bash_command_is_safe('ls -la')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_simple_echo(self):
|
||||
result = bash_command_is_safe('echo hello world')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_command_substitution_blocked(self):
|
||||
result = bash_command_is_safe('echo $(cat /etc/passwd)')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_backtick_blocked(self):
|
||||
result = bash_command_is_safe('echo `date`')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_redirect_blocked(self):
|
||||
result = bash_command_is_safe('echo evil > /etc/profile')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_null_byte_blocked(self):
|
||||
result = bash_command_is_safe('echo\x00rm')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_ifs_blocked(self):
|
||||
result = bash_command_is_safe('echo$IFS/etc/passwd')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_proc_environ_blocked(self):
|
||||
result = bash_command_is_safe('cat /proc/self/environ')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_git_commit_allowed(self):
|
||||
result = bash_command_is_safe("git commit -m 'fix bug'")
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH # early-allow → passthrough
|
||||
|
||||
def test_zmodload_blocked(self):
|
||||
result = bash_command_is_safe('zmodload zsh/system')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_brace_expansion_blocked(self):
|
||||
result = bash_command_is_safe('echo {a,b,c}')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
def test_dev_null_redirect_ok(self):
|
||||
result = bash_command_is_safe('cmd > /dev/null 2>&1')
|
||||
assert result.behavior == SecurityBehavior.PASSTHROUGH
|
||||
|
||||
def test_cr_injection(self):
|
||||
result = bash_command_is_safe('TZ=UTC\recho curl evil.com')
|
||||
assert result.behavior == SecurityBehavior.ASK
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# get_destructive_command_warning
|
||||
# ===========================================================================
|
||||
|
||||
class TestGetDestructiveCommandWarning:
|
||||
def test_git_reset_hard(self):
|
||||
assert get_destructive_command_warning('git reset --hard') is not None
|
||||
|
||||
def test_rm_rf(self):
|
||||
assert get_destructive_command_warning('rm -rf /') is not None
|
||||
|
||||
def test_git_push_force(self):
|
||||
assert get_destructive_command_warning('git push origin main --force') is not None
|
||||
|
||||
def test_git_clean_f(self):
|
||||
assert get_destructive_command_warning('git clean -fd') is not None
|
||||
|
||||
def test_kubectl_delete(self):
|
||||
assert get_destructive_command_warning('kubectl delete pod mypod') is not None
|
||||
|
||||
def test_safe_command(self):
|
||||
assert get_destructive_command_warning('echo hello') is None
|
||||
|
||||
def test_git_push_no_force(self):
|
||||
assert get_destructive_command_warning('git push origin main') is None
|
||||
|
||||
def test_drop_table(self):
|
||||
assert get_destructive_command_warning('DROP TABLE users;') is not None
|
||||
|
||||
def test_terraform_destroy(self):
|
||||
assert get_destructive_command_warning('terraform destroy') is not None
|
||||
|
||||
def test_git_stash_drop(self):
|
||||
assert get_destructive_command_warning('git stash drop') is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# interpret_command_result
|
||||
# ===========================================================================
|
||||
|
||||
class TestInterpretCommandResult:
|
||||
def test_success(self):
|
||||
is_error, msg = interpret_command_result('echo hello', 0, 'hello', '')
|
||||
assert is_error is False
|
||||
|
||||
def test_failure(self):
|
||||
is_error, msg = interpret_command_result('unknown_cmd', 127, '', 'not found')
|
||||
assert is_error is True
|
||||
|
||||
def test_grep_no_match(self):
|
||||
is_error, msg = interpret_command_result('grep pattern file', 1, '', '')
|
||||
assert is_error is False
|
||||
assert msg == 'No matches found'
|
||||
|
||||
def test_grep_error(self):
|
||||
is_error, msg = interpret_command_result('grep pattern file', 2, '', 'error')
|
||||
assert is_error is True
|
||||
|
||||
def test_diff_files_differ(self):
|
||||
is_error, msg = interpret_command_result('diff a b', 1, 'output', '')
|
||||
assert is_error is False
|
||||
assert msg == 'Files differ'
|
||||
|
||||
def test_find_partial(self):
|
||||
is_error, msg = interpret_command_result('find / -name x', 1, '', '')
|
||||
assert is_error is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# is_command_read_only
|
||||
# ===========================================================================
|
||||
|
||||
class TestIsCommandReadOnly:
|
||||
def test_ls(self):
|
||||
assert is_command_read_only('ls -la') is True
|
||||
|
||||
def test_cat(self):
|
||||
assert is_command_read_only('cat file.txt') is True
|
||||
|
||||
def test_grep(self):
|
||||
assert is_command_read_only('grep -r pattern .') is True
|
||||
|
||||
def test_git_status(self):
|
||||
assert is_command_read_only('git status') is True
|
||||
|
||||
def test_git_log(self):
|
||||
assert is_command_read_only('git log --oneline') is True
|
||||
|
||||
def test_git_push(self):
|
||||
assert is_command_read_only('git push') is False
|
||||
|
||||
def test_rm(self):
|
||||
assert is_command_read_only('rm file.txt') is False
|
||||
|
||||
def test_sed_read_only(self):
|
||||
assert is_command_read_only("sed -n '1,5p' file") is True
|
||||
|
||||
def test_sed_in_place(self):
|
||||
assert is_command_read_only("sed -i 's/old/new/' file") is False
|
||||
|
||||
def test_find_safe(self):
|
||||
assert is_command_read_only('find . -name "*.py"') is True
|
||||
|
||||
def test_find_exec(self):
|
||||
assert is_command_read_only('find . -exec rm {} ;') is False
|
||||
|
||||
def test_find_delete(self):
|
||||
assert is_command_read_only('find . -name "*.tmp" -delete') is False
|
||||
|
||||
def test_echo(self):
|
||||
assert is_command_read_only('echo hello') is True
|
||||
|
||||
def test_python_version(self):
|
||||
assert is_command_read_only('python --version') is True
|
||||
|
||||
def test_unknown_command(self):
|
||||
assert is_command_read_only('some_random_command') is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# check_shell_security (integration)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCheckShellSecurity:
|
||||
def test_shell_disabled(self):
|
||||
allowed, msg = check_shell_security('ls', allow_shell=False)
|
||||
assert allowed is False
|
||||
assert 'disabled' in msg.lower()
|
||||
|
||||
def test_safe_command_allowed(self):
|
||||
allowed, msg = check_shell_security('ls -la')
|
||||
assert allowed is True
|
||||
|
||||
def test_destructive_blocked(self):
|
||||
allowed, msg = check_shell_security('rm -rf /', allow_destructive=False)
|
||||
assert allowed is False
|
||||
assert 'destructive' in msg.lower()
|
||||
|
||||
def test_destructive_allowed_when_enabled(self):
|
||||
allowed, msg = check_shell_security('rm -rf /tmp/test', allow_destructive=True)
|
||||
# rm -rf still triggers destructive check, but allow_destructive=True skips it
|
||||
# However rm -rf may also trigger the security check for force-remove
|
||||
# Let's check: the main security check should pass (no injection)
|
||||
# and destructive should be allowed
|
||||
assert allowed is True
|
||||
|
||||
def test_injection_blocked(self):
|
||||
allowed, msg = check_shell_security('echo `evil`')
|
||||
assert allowed is False
|
||||
assert 'backtick' in msg.lower() or 'security' in msg.lower()
|
||||
|
||||
def test_misparsing_always_blocked(self):
|
||||
allowed, msg = check_shell_security('echo\x00rm')
|
||||
assert allowed is False
|
||||
|
||||
def test_safe_git_commit(self):
|
||||
allowed, msg = check_shell_security("git commit -m 'fix'")
|
||||
assert allowed is True
|
||||
@@ -1,136 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from benchmarks.suites.base import BenchmarkResult, BenchmarkSuite
|
||||
from benchmarks.suites.gsm8k import GSM8KBenchmark
|
||||
from benchmarks.suites.humaneval import HumanEvalBenchmark
|
||||
|
||||
|
||||
class _DummyBenchmark(BenchmarkSuite):
|
||||
name = "DummySuite"
|
||||
description = "dummy"
|
||||
category = "coding"
|
||||
|
||||
def __init__(self, *, pass_result: bool, **kwargs: object) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._pass_result = pass_result
|
||||
|
||||
def load_dataset(self) -> list[dict[str, object]]:
|
||||
return [{"id": "dummy/0", "value": 1}]
|
||||
|
||||
def build_prompt(self, problem: dict[str, object]) -> str:
|
||||
del problem
|
||||
return "write solution.py"
|
||||
|
||||
def setup_workspace(self, problem: dict[str, object], workspace: str) -> None:
|
||||
del problem
|
||||
Path(workspace, "input.txt").write_text("fixture", encoding="utf-8")
|
||||
|
||||
def run_agent(self, instruction: str, workspace: str) -> tuple[int, str, float]:
|
||||
del instruction
|
||||
Path(workspace, "solution.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
return 0, "agent completed", 0.1
|
||||
|
||||
def evaluate(self, problem: dict[str, object], workspace: str) -> BenchmarkResult:
|
||||
del problem, workspace
|
||||
if self._pass_result:
|
||||
return BenchmarkResult(problem_id="dummy/0", passed=True)
|
||||
return BenchmarkResult(problem_id="dummy/0", passed=False, error="boom")
|
||||
|
||||
|
||||
class BenchmarkArtifactTests(unittest.TestCase):
|
||||
def test_failed_problem_saves_artifacts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
suite = _DummyBenchmark(
|
||||
pass_result=False,
|
||||
artifacts_dir=tmp_dir,
|
||||
)
|
||||
report = suite.run_all()
|
||||
result = report.results[0]
|
||||
artifact_path = result.metadata.get("artifact_path")
|
||||
self.assertIsInstance(artifact_path, str)
|
||||
artifact_root = Path(artifact_path)
|
||||
self.assertTrue((artifact_root / "problem.json").exists())
|
||||
self.assertTrue((artifact_root / "prompt.txt").exists())
|
||||
self.assertTrue((artifact_root / "agent_output.txt").exists())
|
||||
self.assertTrue((artifact_root / "result.json").exists())
|
||||
self.assertTrue((artifact_root / "workspace" / "solution.py").exists())
|
||||
payload = json.loads((artifact_root / "result.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["agent_exit_code"], 0)
|
||||
self.assertFalse(payload["passed"])
|
||||
|
||||
def test_passing_problem_not_saved_by_default(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
suite = _DummyBenchmark(
|
||||
pass_result=True,
|
||||
artifacts_dir=tmp_dir,
|
||||
)
|
||||
report = suite.run_all()
|
||||
result = report.results[0]
|
||||
self.assertNotIn("artifact_path", result.metadata)
|
||||
|
||||
def test_passing_problem_saved_when_enabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
suite = _DummyBenchmark(
|
||||
pass_result=True,
|
||||
artifacts_dir=tmp_dir,
|
||||
save_passing_artifacts=True,
|
||||
)
|
||||
report = suite.run_all()
|
||||
result = report.results[0]
|
||||
artifact_path = result.metadata.get("artifact_path")
|
||||
self.assertIsInstance(artifact_path, str)
|
||||
self.assertTrue(Path(str(artifact_path)).exists())
|
||||
|
||||
def test_humaneval_recovers_solution_from_chat_code_block(self) -> None:
|
||||
class _RecoveringHumanEval(HumanEvalBenchmark):
|
||||
def run_agent(self, instruction: str, workspace: str) -> tuple[int, str, float]:
|
||||
del instruction, workspace
|
||||
output = """Here is the implementation:
|
||||
|
||||
```python
|
||||
from typing import List
|
||||
|
||||
def has_close_elements(numbers: List[float], threshold: float) -> bool:
|
||||
for i in range(len(numbers)):
|
||||
for j in range(i + 1, len(numbers)):
|
||||
if abs(numbers[i] - numbers[j]) < threshold:
|
||||
return True
|
||||
return False
|
||||
```
|
||||
"""
|
||||
return 0, output, 0.1
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
suite = _RecoveringHumanEval(
|
||||
data_dir=str(Path(tmp_dir) / "missing"),
|
||||
limit=1,
|
||||
)
|
||||
report = suite.run_all()
|
||||
result = report.results[0]
|
||||
self.assertTrue(result.passed)
|
||||
self.assertTrue(result.metadata.get("recovered_solution_from_output"))
|
||||
|
||||
def test_gsm8k_recovers_answer_from_chat_output(self) -> None:
|
||||
class _RecoveringGSM8K(GSM8KBenchmark):
|
||||
def run_agent(self, instruction: str, workspace: str) -> tuple[int, str, float]:
|
||||
del instruction, workspace
|
||||
return 0, "The answer is 18.", 0.1
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
suite = _RecoveringGSM8K(
|
||||
data_dir=str(Path(tmp_dir) / "missing"),
|
||||
limit=1,
|
||||
)
|
||||
report = suite.run_all()
|
||||
result = report.results[0]
|
||||
self.assertTrue(result.passed)
|
||||
self.assertTrue(result.metadata.get("recovered_answer_from_output"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,177 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from benchmarks.download_datasets import (
|
||||
_download_gsm8k,
|
||||
_download_humaneval,
|
||||
_extract_gsm8k_answer,
|
||||
_extract_math_answer,
|
||||
_fetch_hf_rows,
|
||||
_write_manifest,
|
||||
prepare_suite,
|
||||
)
|
||||
|
||||
|
||||
class BenchmarkDatasetDownloadTests(unittest.TestCase):
|
||||
def test_extract_gsm8k_answer_uses_hash_marker(self) -> None:
|
||||
self.assertEqual(_extract_gsm8k_answer("work #### 42"), "42")
|
||||
self.assertEqual(_extract_gsm8k_answer("Total is 1,234 dollars"), "1234")
|
||||
|
||||
def test_extract_math_answer_prefers_boxed_content(self) -> None:
|
||||
solution = "We compute everything and get \\boxed{\\frac{3}{8}} as the result."
|
||||
self.assertEqual(_extract_math_answer(solution), "3/8")
|
||||
|
||||
def test_fetch_hf_rows_paginates(self) -> None:
|
||||
calls: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
def fake_fetcher(
|
||||
endpoint: str,
|
||||
params: dict[str, object],
|
||||
headers: dict[str, str] | None,
|
||||
timeout: float,
|
||||
) -> object:
|
||||
del headers, timeout
|
||||
calls.append((endpoint, dict(params)))
|
||||
if endpoint == "splits":
|
||||
return {
|
||||
"splits": [
|
||||
{"dataset": "demo", "config": "main", "split": "test"},
|
||||
]
|
||||
}
|
||||
if params["offset"] == 0:
|
||||
return {
|
||||
"rows": [{"row": {"value": 1}}, {"row": {"value": 2}}],
|
||||
"num_rows_total": 3,
|
||||
}
|
||||
return {
|
||||
"rows": [{"row": {"value": 3}}],
|
||||
"num_rows_total": 3,
|
||||
}
|
||||
|
||||
rows = _fetch_hf_rows(
|
||||
"demo",
|
||||
config_preference=("main",),
|
||||
split_preference=("test",),
|
||||
json_fetcher=fake_fetcher,
|
||||
)
|
||||
self.assertEqual(rows, [{"value": 1}, {"value": 2}, {"value": 3}])
|
||||
self.assertEqual([call[0] for call in calls], ["splits", "rows", "rows"])
|
||||
|
||||
def test_download_gsm8k_normalizes_answers(self) -> None:
|
||||
def fake_fetcher(
|
||||
endpoint: str,
|
||||
params: dict[str, object],
|
||||
headers: dict[str, str] | None,
|
||||
timeout: float,
|
||||
) -> object:
|
||||
del headers, timeout
|
||||
if endpoint == "splits":
|
||||
return {
|
||||
"splits": [
|
||||
{"dataset": "openai/gsm8k", "config": "main", "split": "test"},
|
||||
]
|
||||
}
|
||||
self.assertEqual(params["dataset"], "openai/gsm8k")
|
||||
return {
|
||||
"rows": [
|
||||
{"row": {"question": "q1", "answer": "reasoning #### 12"}},
|
||||
{"row": {"question": "q2", "answer": "Total = 1,004"}},
|
||||
],
|
||||
"num_rows_total": 2,
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
output_path = Path(tmp_dir) / "gsm8k.jsonl"
|
||||
result = _download_gsm8k(
|
||||
output_path,
|
||||
timeout=1.0,
|
||||
json_fetcher=fake_fetcher,
|
||||
)
|
||||
self.assertEqual(result.rows, 2)
|
||||
rows = [json.loads(line) for line in output_path.read_text().splitlines()]
|
||||
self.assertEqual(rows[0]["answer"], "12")
|
||||
self.assertEqual(rows[1]["answer"], "1004")
|
||||
|
||||
def test_download_humaneval_decompresses_gzip_payload(self) -> None:
|
||||
payload = gzip.compress(
|
||||
(
|
||||
json.dumps(
|
||||
{
|
||||
"task_id": "HumanEval/0",
|
||||
"prompt": "def f():\n pass\n",
|
||||
"canonical_solution": " return 1\n",
|
||||
"test": "def check(candidate):\n assert True\n",
|
||||
"entry_point": "f",
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
output_path = Path(tmp_dir) / "humaneval.jsonl"
|
||||
with patch(
|
||||
"benchmarks.download_datasets.fetch_bytes",
|
||||
return_value=payload,
|
||||
):
|
||||
result = _download_humaneval(output_path, timeout=1.0)
|
||||
self.assertEqual(result.rows, 1)
|
||||
rows = [json.loads(line) for line in output_path.read_text().splitlines()]
|
||||
self.assertEqual(rows[0]["task_id"], "HumanEval/0")
|
||||
|
||||
def test_prepare_suite_exports_builtin_only_suite(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
result = prepare_suite(
|
||||
"ifeval",
|
||||
data_dir=Path(tmp_dir),
|
||||
force=True,
|
||||
builtin_only=False,
|
||||
official_only=False,
|
||||
timeout=1.0,
|
||||
)
|
||||
output_path = Path(tmp_dir) / "ifeval.jsonl"
|
||||
self.assertEqual(result.source, "builtin")
|
||||
self.assertTrue(output_path.exists())
|
||||
self.assertGreater(result.rows, 0)
|
||||
|
||||
def test_prepare_suite_falls_back_to_builtin_when_official_download_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
with patch(
|
||||
"benchmarks.download_datasets._download_mbpp",
|
||||
side_effect=RuntimeError("network down"),
|
||||
):
|
||||
result = prepare_suite(
|
||||
"mbpp",
|
||||
data_dir=Path(tmp_dir),
|
||||
force=True,
|
||||
builtin_only=False,
|
||||
official_only=False,
|
||||
timeout=1.0,
|
||||
)
|
||||
self.assertEqual(result.source, "builtin-fallback")
|
||||
self.assertIn("official download failed", result.note)
|
||||
self.assertTrue((Path(tmp_dir) / "mbpp.jsonl").exists())
|
||||
|
||||
def test_write_manifest_writes_results(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
result = prepare_suite(
|
||||
"aime",
|
||||
data_dir=Path(tmp_dir),
|
||||
force=True,
|
||||
builtin_only=False,
|
||||
official_only=False,
|
||||
timeout=1.0,
|
||||
)
|
||||
manifest_path = _write_manifest(Path(tmp_dir), [result])
|
||||
payload = json.loads(manifest_path.read_text())
|
||||
self.assertEqual(payload["results"][0]["suite"], "aime")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,42 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from benchmarks.suites.base import make_temp_workspace, resolve_temp_root
|
||||
|
||||
|
||||
class BenchmarkTempWorkspaceTests(unittest.TestCase):
|
||||
def test_make_temp_workspace_sanitizes_suite_and_problem_ids(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
with patch(
|
||||
"benchmarks.suites.base.tempfile.gettempdir",
|
||||
return_value=tmp_dir,
|
||||
):
|
||||
workspace = make_temp_workspace("claw", "HumanEval", "HumanEval/0")
|
||||
try:
|
||||
workspace_path = Path(workspace)
|
||||
self.assertTrue(workspace_path.is_dir())
|
||||
self.assertEqual(workspace_path.parent, Path(tmp_dir))
|
||||
self.assertNotIn("/", workspace_path.name)
|
||||
self.assertIn("HumanEval_0", workspace_path.name)
|
||||
finally:
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
def test_resolve_temp_root_creates_missing_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
missing_root = Path(tmp_dir) / "nested" / "tmp"
|
||||
with patch(
|
||||
"benchmarks.suites.base.tempfile.gettempdir",
|
||||
return_value=str(missing_root),
|
||||
):
|
||||
resolved = resolve_temp_root()
|
||||
self.assertEqual(resolved, missing_root.resolve())
|
||||
self.assertTrue(missing_root.is_dir())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,20 @@
|
||||
from agent_platform.bootstrap import _model_payload
|
||||
|
||||
|
||||
def test_bootstrap_models_override_provider_ids_and_are_public() -> None:
|
||||
models = _model_payload()
|
||||
assert len(models) == 6
|
||||
assert all(model["base_model_id"] is None for model in models)
|
||||
assert all(
|
||||
model["access_grants"] == [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
|
||||
for model in models
|
||||
)
|
||||
|
||||
chat = next(model for model in models if model["id"] == "chat-medium")
|
||||
assert chat["params"]["function_calling"] == "native"
|
||||
assert chat["meta"]["toolIds"] == ["server:workspace"]
|
||||
assert chat["meta"]["capabilities"]["builtin_tools"] is False
|
||||
|
||||
work = next(model for model in models if model["id"] == "work-medium")
|
||||
assert work["params"] == {}
|
||||
assert work["meta"]["toolIds"] == []
|
||||
@@ -1,155 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.bundled_skills import (
|
||||
find_bundled_skill,
|
||||
format_skills_for_system_prompt,
|
||||
get_bundled_skills,
|
||||
load_directory_skills,
|
||||
load_project_skills,
|
||||
)
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
|
||||
|
||||
class BundledSkillsTests(unittest.TestCase):
|
||||
def test_directory_skills_are_loaded_with_metadata(self) -> None:
|
||||
skills = {skill.name: skill for skill in get_bundled_skills()}
|
||||
self.assertIn('verify', skills)
|
||||
self.assertEqual(skills['verify'].source, 'directory')
|
||||
self.assertIn('bash', skills['verify'].allowed_tools)
|
||||
|
||||
def test_directory_skill_aliases_are_resolved(self) -> None:
|
||||
skill = find_bundled_skill('config-help')
|
||||
self.assertIsNotNone(skill)
|
||||
assert skill is not None
|
||||
self.assertEqual(skill.name, 'update-config')
|
||||
self.assertEqual(skill.source, 'directory')
|
||||
|
||||
def test_directory_skill_prompt_appends_invocation_args(self) -> None:
|
||||
skill = find_bundled_skill('verify')
|
||||
self.assertIsNotNone(skill)
|
||||
assert skill is not None
|
||||
prompt = skill.get_prompt(None, 'focus: tests') # type: ignore[arg-type]
|
||||
self.assertIn('Verify that the recent code changes work correctly.', prompt)
|
||||
self.assertIn('## Invocation Arguments', prompt)
|
||||
self.assertIn('focus: tests', prompt)
|
||||
|
||||
def test_custom_directory_skill_loader(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
skill_dir = root / 'sample'
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / 'SKILL.md').write_text(
|
||||
(
|
||||
'---\n'
|
||||
'name: sample\n'
|
||||
'description: Sample directory skill.\n'
|
||||
'aliases: s1, s2\n'
|
||||
'allowed_tools: read_file, write_file\n'
|
||||
'---\n'
|
||||
'Use sample instructions.\n'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
skills = load_directory_skills(root)
|
||||
self.assertEqual(len(skills), 1)
|
||||
self.assertEqual(skills[0].name, 'sample')
|
||||
self.assertEqual(skills[0].aliases, ('s1', 's2'))
|
||||
self.assertEqual(skills[0].allowed_tools, ('read_file', 'write_file'))
|
||||
|
||||
def test_skills_prompt_includes_directory_skill(self) -> None:
|
||||
rendered = format_skills_for_system_prompt()
|
||||
self.assertIn('verify', rendered)
|
||||
self.assertIn('update-config', rendered)
|
||||
|
||||
def test_skills_prompt_respects_enabled_skill_filter(self) -> None:
|
||||
rendered = format_skills_for_system_prompt(
|
||||
enabled_skill_names=('verify', 'product-data')
|
||||
)
|
||||
self.assertIn('verify', rendered)
|
||||
self.assertIn('product-data', rendered)
|
||||
self.assertNotIn('online-mining', rendered)
|
||||
|
||||
def test_project_skills_are_loaded_from_workspace_skills_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
skill_dir = workspace / 'skills' / 'project-skill'
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / 'SKILL.md').write_text(
|
||||
(
|
||||
'---\n'
|
||||
'name: project-skill\n'
|
||||
'description: Project-maintained skill.\n'
|
||||
'aliases: local-skill\n'
|
||||
'allowed_tools: read_file\n'
|
||||
'---\n'
|
||||
'Project skill body.\n'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
skills = get_bundled_skills(workspace)
|
||||
resolved = find_bundled_skill('local-skill', cwd=workspace)
|
||||
by_name = {skill.name: skill for skill in skills}
|
||||
self.assertIn('project-skill', by_name)
|
||||
self.assertEqual(by_name['project-skill'].source, 'project')
|
||||
self.assertIsNotNone(resolved)
|
||||
assert resolved is not None
|
||||
self.assertEqual(resolved.name, 'project-skill')
|
||||
|
||||
def test_project_skills_override_bundled_directory_skills(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
skill_dir = workspace / 'skills' / 'verify'
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / 'SKILL.md').write_text(
|
||||
(
|
||||
'---\n'
|
||||
'name: verify\n'
|
||||
'description: Project override for verify.\n'
|
||||
'---\n'
|
||||
'Project verify body.\n'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
skill = find_bundled_skill('verify', cwd=workspace)
|
||||
self.assertIsNotNone(skill)
|
||||
assert skill is not None
|
||||
self.assertEqual(skill.source, 'project')
|
||||
self.assertIn('Project verify body.', skill.get_prompt(None, '')) # type: ignore[arg-type]
|
||||
|
||||
def test_load_project_skills_returns_empty_without_skills_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
self.assertEqual(load_project_skills(Path(tmp_dir)), ())
|
||||
|
||||
def test_agent_skill_tool_executes_project_skill_from_cwd(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
skill_dir = workspace / 'skills' / 'project-run'
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / 'SKILL.md').write_text(
|
||||
(
|
||||
'---\n'
|
||||
'name: project-run\n'
|
||||
'description: Project runnable skill.\n'
|
||||
'---\n'
|
||||
'Run project skill body.\n'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent._execute_skill({'skill': 'project-run', 'args': 'demo'})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('Run project skill body.', result.content)
|
||||
self.assertEqual(result.metadata.get('source'), 'project')
|
||||
self.assertIn('SKILL.md', str(result.metadata.get('skill_path')))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,198 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.command_graph import CommandGraph, build_command_graph
|
||||
from src.models import PortingModule
|
||||
|
||||
|
||||
def _module(name: str, source_hint: str) -> PortingModule:
|
||||
return PortingModule(name=name, responsibility="stub", source_hint=source_hint)
|
||||
|
||||
|
||||
class CommandGraphTests(unittest.TestCase):
|
||||
# -- construction & immutability ------------------------------------------
|
||||
|
||||
def test_empty_graph(self) -> None:
|
||||
graph = CommandGraph(builtins=(), plugin_like=(), skill_like=())
|
||||
self.assertEqual(graph.builtins, ())
|
||||
self.assertEqual(graph.plugin_like, ())
|
||||
self.assertEqual(graph.skill_like, ())
|
||||
|
||||
def test_fields_are_tuples(self) -> None:
|
||||
b = (_module("b1", "core/b1.ts"),)
|
||||
p = (_module("p1", "plugin/p1.ts"),)
|
||||
s = (_module("s1", "skills/s1.ts"),)
|
||||
graph = CommandGraph(builtins=b, plugin_like=p, skill_like=s)
|
||||
self.assertIsInstance(graph.builtins, tuple)
|
||||
self.assertIsInstance(graph.plugin_like, tuple)
|
||||
self.assertIsInstance(graph.skill_like, tuple)
|
||||
|
||||
def test_frozen_dataclass_rejects_mutation(self) -> None:
|
||||
graph = CommandGraph(builtins=(), plugin_like=(), skill_like=())
|
||||
with self.assertRaises(AttributeError):
|
||||
graph.builtins = () # type: ignore[misc]
|
||||
|
||||
# -- flattened -------------------------------------------------------------
|
||||
|
||||
def test_flattened_combines_all_categories(self) -> None:
|
||||
b = (_module("b1", "core/b1.ts"),)
|
||||
p = (_module("p1", "plugin/p1.ts"),)
|
||||
s = (_module("s1", "skills/s1.ts"),)
|
||||
graph = CommandGraph(builtins=b, plugin_like=p, skill_like=s)
|
||||
self.assertEqual(graph.flattened(), b + p + s)
|
||||
|
||||
def test_flattened_preserves_order(self) -> None:
|
||||
b1 = _module("b1", "core/b1.ts")
|
||||
b2 = _module("b2", "core/b2.ts")
|
||||
p1 = _module("p1", "plugin/p1.ts")
|
||||
s1 = _module("s1", "skills/s1.ts")
|
||||
graph = CommandGraph(builtins=(b1, b2), plugin_like=(p1,), skill_like=(s1,))
|
||||
self.assertEqual(graph.flattened(), (b1, b2, p1, s1))
|
||||
|
||||
def test_flattened_of_empty_graph_returns_empty_tuple(self) -> None:
|
||||
graph = CommandGraph(builtins=(), plugin_like=(), skill_like=())
|
||||
self.assertEqual(graph.flattened(), ())
|
||||
|
||||
def test_flattened_length_is_sum_of_categories(self) -> None:
|
||||
b = (_module("b1", "core/b1.ts"), _module("b2", "core/b2.ts"))
|
||||
p = (_module("p1", "plugin/p1.ts"),)
|
||||
s = ()
|
||||
graph = CommandGraph(builtins=b, plugin_like=p, skill_like=s)
|
||||
self.assertEqual(len(graph.flattened()), 3)
|
||||
|
||||
# -- as_markdown -----------------------------------------------------------
|
||||
|
||||
def test_as_markdown_includes_header(self) -> None:
|
||||
graph = CommandGraph(builtins=(), plugin_like=(), skill_like=())
|
||||
md = graph.as_markdown()
|
||||
self.assertIn("# Command Graph", md)
|
||||
|
||||
def test_as_markdown_includes_counts(self) -> None:
|
||||
b = (_module("b1", "core/b1.ts"), _module("b2", "core/b2.ts"))
|
||||
p = (_module("p1", "plugin/p1.ts"),)
|
||||
s = (_module("s1", "skills/s1.ts"), _module("s2", "skills/s2.ts"), _module("s3", "skills/s3.ts"))
|
||||
graph = CommandGraph(builtins=b, plugin_like=p, skill_like=s)
|
||||
md = graph.as_markdown()
|
||||
self.assertIn("Builtins: 2", md)
|
||||
self.assertIn("Plugin-like commands: 1", md)
|
||||
self.assertIn("Skill-like commands: 3", md)
|
||||
|
||||
def test_as_markdown_empty_counts(self) -> None:
|
||||
graph = CommandGraph(builtins=(), plugin_like=(), skill_like=())
|
||||
md = graph.as_markdown()
|
||||
self.assertIn("Builtins: 0", md)
|
||||
self.assertIn("Plugin-like commands: 0", md)
|
||||
self.assertIn("Skill-like commands: 0", md)
|
||||
|
||||
def test_as_markdown_returns_string(self) -> None:
|
||||
graph = CommandGraph(builtins=(), plugin_like=(), skill_like=())
|
||||
self.assertIsInstance(graph.as_markdown(), str)
|
||||
|
||||
|
||||
class BuildCommandGraphTests(unittest.TestCase):
|
||||
# -- return type -----------------------------------------------------------
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_returns_command_graph(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
mock_get.return_value = ()
|
||||
result = build_command_graph()
|
||||
self.assertIsInstance(result, CommandGraph)
|
||||
|
||||
# -- categorization --------------------------------------------------------
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_plugin_source_goes_to_plugin_like(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
p = _module("p1", "plugin/p1.ts")
|
||||
mock_get.return_value = (p,)
|
||||
graph = build_command_graph()
|
||||
self.assertIn(p, graph.plugin_like)
|
||||
self.assertNotIn(p, graph.builtins)
|
||||
self.assertNotIn(p, graph.skill_like)
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_skills_source_goes_to_skill_like(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
s = _module("s1", "skills/s1.ts")
|
||||
mock_get.return_value = (s,)
|
||||
graph = build_command_graph()
|
||||
self.assertIn(s, graph.skill_like)
|
||||
self.assertNotIn(s, graph.builtins)
|
||||
self.assertNotIn(s, graph.plugin_like)
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_plain_source_goes_to_builtins(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
b = _module("b1", "core/b1.ts")
|
||||
mock_get.return_value = (b,)
|
||||
graph = build_command_graph()
|
||||
self.assertIn(b, graph.builtins)
|
||||
self.assertNotIn(b, graph.plugin_like)
|
||||
self.assertNotIn(b, graph.skill_like)
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_case_insensitive_plugin_match(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
p = _module("p1", "Plugin/p1.ts")
|
||||
mock_get.return_value = (p,)
|
||||
graph = build_command_graph()
|
||||
self.assertIn(p, graph.plugin_like)
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_case_insensitive_skills_match(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
s = _module("s1", "Skills/s1.ts")
|
||||
mock_get.return_value = (s,)
|
||||
graph = build_command_graph()
|
||||
self.assertIn(s, graph.skill_like)
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_mixed_commands_are_sorted_correctly(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
b = _module("b1", "core/b1.ts")
|
||||
p = _module("p1", "plugin/p1.ts")
|
||||
s = _module("s1", "skills/s1.ts")
|
||||
mock_get.return_value = (b, p, s)
|
||||
graph = build_command_graph()
|
||||
self.assertEqual(graph.builtins, (b,))
|
||||
self.assertEqual(graph.plugin_like, (p,))
|
||||
self.assertEqual(graph.skill_like, (s,))
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_empty_commands_yields_empty_graph(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
mock_get.return_value = ()
|
||||
graph = build_command_graph()
|
||||
self.assertEqual(graph.builtins, ())
|
||||
self.assertEqual(graph.plugin_like, ())
|
||||
self.assertEqual(graph.skill_like, ())
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_all_builtins(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
b1 = _module("b1", "core/b1.ts")
|
||||
b2 = _module("b2", "commands/b2.ts")
|
||||
mock_get.return_value = (b1, b2)
|
||||
graph = build_command_graph()
|
||||
self.assertEqual(len(graph.builtins), 2)
|
||||
self.assertEqual(graph.plugin_like, ())
|
||||
self.assertEqual(graph.skill_like, ())
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_builtins_plugin_skill_are_tuples_of_porting_module(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
b = _module("b1", "core/b1.ts")
|
||||
p = _module("p1", "plugin/p1.ts")
|
||||
s = _module("s1", "skills/s1.ts")
|
||||
mock_get.return_value = (b, p, s)
|
||||
graph = build_command_graph()
|
||||
for category in (graph.builtins, graph.plugin_like, graph.skill_like):
|
||||
self.assertIsInstance(category, tuple)
|
||||
for item in category:
|
||||
self.assertIsInstance(item, PortingModule)
|
||||
|
||||
@patch("src.command_graph.get_commands")
|
||||
def test_flattened_matches_original_commands(self, mock_get: unittest.mock.MagicMock) -> None:
|
||||
modules = (
|
||||
_module("b1", "core/b1.ts"),
|
||||
_module("p1", "plugin/p1.ts"),
|
||||
_module("s1", "skills/s1.ts"),
|
||||
)
|
||||
mock_get.return_value = modules
|
||||
graph = build_command_graph()
|
||||
self.assertEqual(set(graph.flattened()), set(modules))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,409 +0,0 @@
|
||||
"""Tests for src/compact.py – the conversation compaction service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_session import AgentMessage, AgentSessionState
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
from src.compact import (
|
||||
AUTOCOMPACT_BUFFER_TOKENS,
|
||||
ERROR_INCOMPLETE_RESPONSE,
|
||||
ERROR_NOT_ENOUGH_MESSAGES,
|
||||
CompactionResult,
|
||||
compact_conversation,
|
||||
format_compact_summary,
|
||||
get_compact_prompt,
|
||||
get_compact_user_summary_message,
|
||||
)
|
||||
|
||||
|
||||
class TestGetCompactPrompt(unittest.TestCase):
|
||||
"""Tests for the compact prompt builder."""
|
||||
|
||||
def test_basic_prompt_contains_all_nine_sections(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
for section in [
|
||||
'1. Primary Request and Intent',
|
||||
'2. Key Technical Concepts',
|
||||
'3. Files and Code Sections',
|
||||
'4. Errors and fixes',
|
||||
'5. Problem Solving',
|
||||
'6. All user messages',
|
||||
'7. Pending Tasks',
|
||||
'8. Current Work',
|
||||
'9. Optional Next Step',
|
||||
]:
|
||||
self.assertIn(section, prompt, f'Missing section: {section}')
|
||||
|
||||
def test_no_tools_preamble_present(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
self.assertIn('CRITICAL: Respond with TEXT ONLY', prompt)
|
||||
self.assertIn('Do NOT call any tools', prompt)
|
||||
|
||||
def test_no_tools_trailer_present(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
self.assertIn('REMINDER: Do NOT call any tools', prompt)
|
||||
|
||||
def test_analysis_and_summary_example_tags_present(self) -> None:
|
||||
prompt = get_compact_prompt()
|
||||
self.assertIn('<analysis>', prompt)
|
||||
self.assertIn('</analysis>', prompt)
|
||||
self.assertIn('<summary>', prompt)
|
||||
self.assertIn('</summary>', prompt)
|
||||
|
||||
def test_custom_instructions_appended(self) -> None:
|
||||
prompt = get_compact_prompt('Focus on database changes.')
|
||||
self.assertIn('Additional Instructions:', prompt)
|
||||
self.assertIn('Focus on database changes.', prompt)
|
||||
|
||||
def test_empty_custom_instructions_ignored(self) -> None:
|
||||
prompt_no_custom = get_compact_prompt()
|
||||
prompt_empty = get_compact_prompt('')
|
||||
prompt_whitespace = get_compact_prompt(' ')
|
||||
self.assertEqual(prompt_no_custom, prompt_empty)
|
||||
self.assertEqual(prompt_no_custom, prompt_whitespace)
|
||||
|
||||
def test_none_custom_instructions_ignored(self) -> None:
|
||||
prompt_none = get_compact_prompt(None)
|
||||
prompt_no_arg = get_compact_prompt()
|
||||
self.assertEqual(prompt_none, prompt_no_arg)
|
||||
|
||||
|
||||
class TestFormatCompactSummary(unittest.TestCase):
|
||||
"""Tests for the summary formatting / XML stripping."""
|
||||
|
||||
def test_strips_analysis_block(self) -> None:
|
||||
raw = '<analysis>thinking here</analysis>\n\n<summary>result</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('<analysis>', formatted)
|
||||
self.assertNotIn('thinking here', formatted)
|
||||
self.assertIn('result', formatted)
|
||||
|
||||
def test_unwraps_summary_tags(self) -> None:
|
||||
raw = '<summary>The main points.\n1. First</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('<summary>', formatted)
|
||||
self.assertNotIn('</summary>', formatted)
|
||||
self.assertIn('Summary:', formatted)
|
||||
self.assertIn('The main points.', formatted)
|
||||
|
||||
def test_handles_no_xml_tags(self) -> None:
|
||||
raw = 'Plain text summary without any tags.'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertEqual(formatted, raw)
|
||||
|
||||
def test_collapses_excess_blank_lines(self) -> None:
|
||||
raw = '<analysis>x</analysis>\n\n\n\n<summary>y</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('\n\n\n', formatted)
|
||||
|
||||
def test_multiline_analysis_stripped(self) -> None:
|
||||
raw = (
|
||||
'<analysis>\nLine 1\nLine 2\nLine 3\n</analysis>\n'
|
||||
'<summary>Final summary</summary>'
|
||||
)
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('Line 1', formatted)
|
||||
self.assertIn('Final summary', formatted)
|
||||
|
||||
|
||||
class TestGetCompactUserSummaryMessage(unittest.TestCase):
|
||||
"""Tests for the post-compact user message builder."""
|
||||
|
||||
def test_basic_message_structure(self) -> None:
|
||||
msg = get_compact_user_summary_message('<summary>overview</summary>')
|
||||
self.assertIn('continued from a previous conversation', msg)
|
||||
self.assertIn('overview', msg)
|
||||
|
||||
def test_transcript_path_appended(self) -> None:
|
||||
msg = get_compact_user_summary_message(
|
||||
'<summary>ok</summary>',
|
||||
transcript_path='/tmp/transcript.json',
|
||||
)
|
||||
self.assertIn('/tmp/transcript.json', msg)
|
||||
|
||||
def test_suppress_follow_up(self) -> None:
|
||||
msg = get_compact_user_summary_message(
|
||||
'<summary>ok</summary>',
|
||||
suppress_follow_up=True,
|
||||
)
|
||||
self.assertIn('without asking the user any further questions', msg)
|
||||
|
||||
def test_no_suppress_follow_up_default(self) -> None:
|
||||
msg = get_compact_user_summary_message('<summary>ok</summary>')
|
||||
self.assertNotIn('without asking the user any further questions', msg)
|
||||
|
||||
|
||||
class TestCompactConversation(unittest.TestCase):
|
||||
"""Tests for the core compact_conversation() function."""
|
||||
|
||||
def _make_agent(self, tmp_dir: str) -> LocalCodingAgent:
|
||||
"""Create a minimal agent with a session loaded."""
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
return agent
|
||||
|
||||
def _set_session(
|
||||
self, agent: LocalCodingAgent, messages: list[AgentMessage]
|
||||
) -> None:
|
||||
session = AgentSessionState(
|
||||
system_prompt_parts=('You are a helpful assistant.',),
|
||||
messages=messages,
|
||||
)
|
||||
agent.last_session = session
|
||||
|
||||
def _make_messages(self, count: int) -> list[AgentMessage]:
|
||||
msgs: list[AgentMessage] = []
|
||||
for i in range(count):
|
||||
role = 'user' if i % 2 == 0 else 'assistant'
|
||||
msgs.append(
|
||||
AgentMessage(
|
||||
role=role,
|
||||
content=f'Message {i} content. ' * 10,
|
||||
message_id=f'msg_{i}',
|
||||
)
|
||||
)
|
||||
return msgs
|
||||
|
||||
def test_no_session_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
agent.last_session = None
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
self.assertIn('Not enough', result.error)
|
||||
|
||||
def test_empty_session_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, [])
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
|
||||
def test_too_few_messages_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
# With only 2 messages and preserve_count=4, nothing to compact
|
||||
self._set_session(agent, self._make_messages(2))
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
|
||||
def test_successful_compaction(self) -> None:
|
||||
"""Simulate a successful model call and verify session is compacted."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
msgs = self._make_messages(10)
|
||||
self._set_session(agent, msgs)
|
||||
|
||||
# Mock the client's complete method
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
mock_turn = AssistantTurn(
|
||||
content=(
|
||||
'<analysis>Thinking through the conversation...</analysis>\n'
|
||||
'<summary>\n1. Primary Request and Intent:\n'
|
||||
' User wanted to test compaction.\n'
|
||||
'2. Key Technical Concepts:\n - Testing\n'
|
||||
'3. Files and Code Sections:\n - test.py\n'
|
||||
'4. Errors and fixes:\n - None\n'
|
||||
'5. Problem Solving:\n Basic testing.\n'
|
||||
'6. All user messages:\n - "test compaction"\n'
|
||||
'7. Pending Tasks:\n - None\n'
|
||||
'8. Current Work:\n Testing compaction.\n'
|
||||
'9. Optional Next Step:\n Verify it works.\n'
|
||||
'</summary>'
|
||||
),
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = mock_turn
|
||||
|
||||
result = compact_conversation(agent)
|
||||
|
||||
self.assertIsNone(result.error)
|
||||
self.assertGreater(result.pre_compact_token_count, 0)
|
||||
# Session should have fewer messages than original 10
|
||||
self.assertLess(
|
||||
len(agent.last_session.messages), 10,
|
||||
'Session should have fewer messages after compaction',
|
||||
)
|
||||
# Should contain a compact_boundary message
|
||||
boundary_msgs = [
|
||||
m for m in agent.last_session.messages
|
||||
if m.metadata.get('kind') == 'compact_boundary'
|
||||
]
|
||||
self.assertEqual(len(boundary_msgs), 1)
|
||||
# Should contain a compact_summary message
|
||||
summary_msgs = [
|
||||
m for m in agent.last_session.messages
|
||||
if m.metadata.get('kind') == 'compact_summary'
|
||||
]
|
||||
self.assertEqual(len(summary_msgs), 1)
|
||||
# Summary should not contain <analysis> block
|
||||
self.assertNotIn('<analysis>', result.summary_text)
|
||||
# Summary should contain the actual summary content
|
||||
self.assertIn('User wanted to test compaction', result.summary_text)
|
||||
# UI display transcript must remain append-only and not be replaced by
|
||||
# compact_summary / compact_boundary model-context messages.
|
||||
display_contents = [m.content for m in agent.last_session.display_messages]
|
||||
self.assertEqual(display_contents, [m.content for m in msgs])
|
||||
|
||||
def test_api_error_returns_compaction_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, self._make_messages(10))
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.side_effect = RuntimeError('API down')
|
||||
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
self.assertIn('API down', result.error)
|
||||
|
||||
def test_empty_model_response_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, self._make_messages(10))
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
result = compact_conversation(agent)
|
||||
self.assertIsNotNone(result.error)
|
||||
|
||||
def test_preserves_tail_messages(self) -> None:
|
||||
"""The most recent messages should survive compaction."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
msgs = self._make_messages(12)
|
||||
self._set_session(agent, msgs)
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='<summary>Summarised.</summary>',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
result = compact_conversation(agent)
|
||||
|
||||
self.assertIsNone(result.error)
|
||||
# The last 4 messages (default preserve_count) should still be present
|
||||
session_contents = [m.content for m in agent.last_session.messages]
|
||||
for original_msg in msgs[-4:]:
|
||||
self.assertIn(
|
||||
original_msg.content,
|
||||
session_contents,
|
||||
f'Tail message "{original_msg.message_id}" should be preserved',
|
||||
)
|
||||
|
||||
def test_custom_instructions_passed_to_prompt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = self._make_agent(tmp_dir)
|
||||
self._set_session(agent, self._make_messages(10))
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='<summary>Custom summary.</summary>',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
compact_conversation(agent, custom_instructions='Focus on CSS.')
|
||||
|
||||
# Check that the API was called with custom instructions in the prompt
|
||||
call_args = agent.client.complete.call_args
|
||||
messages = call_args[0][0]
|
||||
last_user_msg = messages[-1]['content']
|
||||
self.assertIn('Focus on CSS.', last_user_msg)
|
||||
self.assertIn('Additional Instructions:', last_user_msg)
|
||||
|
||||
|
||||
class TestCompactSlashCommand(unittest.TestCase):
|
||||
"""Test the /compact slash command handler end-to-end."""
|
||||
|
||||
def test_slash_compact_returns_success_message(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
# Set up a session with enough messages
|
||||
session = AgentSessionState(
|
||||
system_prompt_parts=('You are a helper.',),
|
||||
messages=[
|
||||
AgentMessage(role='user', content='Hello', message_id=f'u{i}')
|
||||
if i % 2 == 0
|
||||
else AgentMessage(role='assistant', content='Hi', message_id=f'a{i}')
|
||||
for i in range(10)
|
||||
],
|
||||
)
|
||||
agent.last_session = session
|
||||
|
||||
from src.openai_compat import AssistantTurn
|
||||
from src.agent_types import UsageStats
|
||||
|
||||
agent.client = MagicMock()
|
||||
agent.client.complete.return_value = AssistantTurn(
|
||||
content='<summary>Session summarised.</summary>',
|
||||
tool_calls=(),
|
||||
finish_reason='stop',
|
||||
raw_message={},
|
||||
usage=UsageStats(),
|
||||
)
|
||||
|
||||
result = agent.run('/compact')
|
||||
|
||||
self.assertIn('Conversation compacted', result.final_output)
|
||||
self.assertIn('Tokens before', result.final_output)
|
||||
|
||||
def test_slash_compact_no_session_returns_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/compact')
|
||||
self.assertIn('failed', result.final_output.lower())
|
||||
|
||||
|
||||
class TestConstants(unittest.TestCase):
|
||||
"""Verify key constants match the npm reference."""
|
||||
|
||||
def test_autocompact_buffer_tokens(self) -> None:
|
||||
self.assertEqual(AUTOCOMPACT_BUFFER_TOKENS, 13_000)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,8 @@
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_internal_secrets_must_be_at_least_32_bytes(settings) -> None:
|
||||
with pytest.raises(RuntimeError, match="at least 32 bytes"):
|
||||
replace(settings, internal_gateway_key="too-short").validate_gateway()
|
||||
@@ -1,82 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||
from src.config_runtime import ConfigRuntime
|
||||
|
||||
|
||||
class ConfigRuntimeTests(unittest.TestCase):
|
||||
def test_config_runtime_loads_and_merges_sources(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
claude_dir = workspace / '.claude'
|
||||
claude_dir.mkdir()
|
||||
(claude_dir / 'settings.json').write_text(
|
||||
'{"model":{"name":"project-model","temperature":0.1},"review":{"strict":false}}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
(claude_dir / 'settings.local.json').write_text(
|
||||
'{"model":{"temperature":0.0},"review":{"strict":true}}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = ConfigRuntime.from_workspace(workspace)
|
||||
|
||||
self.assertTrue(runtime.has_config())
|
||||
self.assertEqual(runtime.get_value('model.name'), 'project-model')
|
||||
self.assertEqual(runtime.get_value('model.temperature'), 0.0)
|
||||
self.assertEqual(runtime.get_value('review.strict'), True)
|
||||
self.assertIn('Config sources: 2', runtime.render_summary())
|
||||
|
||||
def test_config_runtime_persists_set_value(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = ConfigRuntime.from_workspace(workspace)
|
||||
mutation = runtime.set_value('review.mode', 'strict', source='local')
|
||||
restored = ConfigRuntime.from_workspace(workspace)
|
||||
|
||||
self.assertEqual(mutation.source_name, 'local')
|
||||
self.assertEqual(restored.get_value('review.mode'), 'strict')
|
||||
self.assertIn('review.mode', restored.render_keys())
|
||||
self.assertEqual(json.loads(restored.render_value('review.mode')), 'strict')
|
||||
|
||||
def test_config_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = ConfigRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
config_runtime=runtime,
|
||||
)
|
||||
set_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'config_set',
|
||||
{'key_path': 'review.mode', 'value': 'strict'},
|
||||
context,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'config_list',
|
||||
{'prefix': 'review'},
|
||||
context,
|
||||
)
|
||||
get_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'config_get',
|
||||
{'key_path': 'review.mode'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(set_result.ok)
|
||||
self.assertEqual(set_result.metadata.get('source_name'), 'local')
|
||||
self.assertIn('# Config Keys', list_result.content)
|
||||
self.assertIn('review.mode', list_result.content)
|
||||
self.assertIn('# Config Value', get_result.content)
|
||||
self.assertIn('"strict"', get_result.content)
|
||||
@@ -0,0 +1,24 @@
|
||||
from agent_platform.runtime.context import ContextPolicy
|
||||
|
||||
|
||||
def test_context_removes_rendered_tool_details_and_compacts() -> None:
|
||||
policy = ContextPolicy()
|
||||
messages = [
|
||||
{"role": "user", "content": "old-" + "x" * 10_000},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": '<details type="tool_calls" done="true"><summary>x</summary>secret trace</details> visible',
|
||||
},
|
||||
{"role": "user", "content": "current task"},
|
||||
]
|
||||
result = policy.prepare(
|
||||
messages,
|
||||
system_prompt="system",
|
||||
memories=[{"content": "remember this"}],
|
||||
char_budget=8_500,
|
||||
)
|
||||
serialized = str(result.messages)
|
||||
assert "secret trace" not in serialized
|
||||
assert "current task" in serialized
|
||||
assert "remember this" in serialized
|
||||
assert result.dropped_messages >= 1
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Tests for /cost, /exit, and /diff slash commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_slash_commands import preprocess_slash_command
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig, UsageStats
|
||||
|
||||
|
||||
class TestCostCommand(unittest.TestCase):
|
||||
"""Tests for the /cost slash command."""
|
||||
|
||||
def test_cost_shows_zero_initially(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/cost')
|
||||
self.assertIn('Total cost:', result.final_output)
|
||||
self.assertIn('$0.0000', result.final_output)
|
||||
self.assertIn('Total input tokens:', result.final_output)
|
||||
self.assertIn('Total output tokens:', result.final_output)
|
||||
self.assertIn('Total tokens:', result.final_output)
|
||||
|
||||
def test_cost_shows_accumulated_usage(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
# Simulate accumulated usage
|
||||
agent.cumulative_usage = UsageStats(
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
cache_read_input_tokens=200,
|
||||
)
|
||||
agent.cumulative_cost_usd = 0.05
|
||||
result = agent.run('/cost')
|
||||
self.assertIn('$0.05', result.final_output)
|
||||
self.assertIn('1,000', result.final_output)
|
||||
self.assertIn('500', result.final_output)
|
||||
self.assertIn('Cache read tokens:', result.final_output)
|
||||
|
||||
def test_cost_hides_zero_cache_and_reasoning(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
agent.cumulative_usage = UsageStats(
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
)
|
||||
result = agent.run('/cost')
|
||||
# Should NOT show cache/reasoning lines when they're zero
|
||||
self.assertNotIn('Cache read tokens:', result.final_output)
|
||||
self.assertNotIn('Cache creation tokens:', result.final_output)
|
||||
self.assertNotIn('Reasoning tokens:', result.final_output)
|
||||
|
||||
def test_cost_small_amounts_show_four_decimals(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
agent.cumulative_cost_usd = 0.0023
|
||||
result = agent.run('/cost')
|
||||
self.assertIn('$0.0023', result.final_output)
|
||||
|
||||
|
||||
class TestExitCommand(unittest.TestCase):
|
||||
"""Tests for the /exit and /quit slash commands."""
|
||||
|
||||
def test_exit_triggers_system_exit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
agent.run('/exit')
|
||||
self.assertEqual(cm.exception.code, 0)
|
||||
|
||||
def test_quit_triggers_system_exit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
with self.assertRaises(SystemExit):
|
||||
agent.run('/quit')
|
||||
|
||||
|
||||
class TestDiffCommand(unittest.TestCase):
|
||||
"""Tests for the /diff slash command."""
|
||||
|
||||
def test_diff_in_non_git_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
result = agent.run('/diff')
|
||||
# In a non-git dir, git diff returns an error
|
||||
output = result.final_output.lower()
|
||||
self.assertTrue(
|
||||
'no uncommitted' in output
|
||||
or 'not a git' in output
|
||||
or 'error' in output,
|
||||
f'Expected a non-git or no-changes message, got: {result.final_output}',
|
||||
)
|
||||
|
||||
def test_diff_in_git_repo_with_no_changes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
subprocess.run(
|
||||
['git', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.email', 'test@test.com'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.name', 'Test'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
(workspace / 'hello.txt').write_text('hello\n')
|
||||
subprocess.run(
|
||||
['git', 'add', '.'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'commit', '-m', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('/diff')
|
||||
self.assertIn('No uncommitted changes', result.final_output)
|
||||
|
||||
def test_diff_shows_changes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
subprocess.run(
|
||||
['git', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.email', 'test@test.com'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'config', 'user.name', 'Test'],
|
||||
cwd=str(workspace), capture_output=True,
|
||||
)
|
||||
(workspace / 'hello.txt').write_text('hello\n')
|
||||
subprocess.run(
|
||||
['git', 'add', '.'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
['git', 'commit', '-m', 'init'], cwd=str(workspace),
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
# Make a change
|
||||
(workspace / 'hello.txt').write_text('hello world\n')
|
||||
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('/diff')
|
||||
self.assertIn('hello', result.final_output)
|
||||
self.assertIn('diff', result.final_output.lower())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,52 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.cost_tracker import CostTracker
|
||||
|
||||
|
||||
class CostTrackerTests(unittest.TestCase):
|
||||
def test_fresh_tracker_starts_at_zero_with_empty_events(self) -> None:
|
||||
tracker = CostTracker()
|
||||
self.assertEqual(tracker.total_units, 0)
|
||||
self.assertEqual(tracker.events, [])
|
||||
|
||||
def test_record_single_event(self) -> None:
|
||||
tracker = CostTracker()
|
||||
tracker.record('inference', 42)
|
||||
self.assertEqual(tracker.total_units, 42)
|
||||
self.assertEqual(len(tracker.events), 1)
|
||||
|
||||
def test_record_multiple_events_accumulates_totals(self) -> None:
|
||||
tracker = CostTracker()
|
||||
tracker.record('inference', 10)
|
||||
tracker.record('embedding', 20)
|
||||
tracker.record('search', 30)
|
||||
self.assertEqual(tracker.total_units, 60)
|
||||
self.assertEqual(len(tracker.events), 3)
|
||||
|
||||
def test_record_zero_units(self) -> None:
|
||||
tracker = CostTracker()
|
||||
tracker.record('noop', 0)
|
||||
self.assertEqual(tracker.total_units, 0)
|
||||
self.assertEqual(len(tracker.events), 1)
|
||||
self.assertIn('noop:0', tracker.events)
|
||||
|
||||
def test_record_large_units(self) -> None:
|
||||
tracker = CostTracker()
|
||||
large = 10**9
|
||||
tracker.record('bulk', large)
|
||||
self.assertEqual(tracker.total_units, large)
|
||||
self.assertEqual(tracker.events, [f'bulk:{large}'])
|
||||
|
||||
def test_event_format_is_label_colon_units(self) -> None:
|
||||
tracker = CostTracker()
|
||||
tracker.record('inference', 42)
|
||||
self.assertEqual(tracker.events[0], 'inference:42')
|
||||
|
||||
def test_events_are_ordered_chronologically(self) -> None:
|
||||
tracker = CostTracker()
|
||||
labels = ['first', 'second', 'third']
|
||||
for i, label in enumerate(labels):
|
||||
tracker.record(label, i)
|
||||
self.assertEqual(tracker.events, ['first:0', 'second:1', 'third:2'])
|
||||
@@ -1,153 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
from src.data_agent_inputs import (
|
||||
extract_case_evidence,
|
||||
load_input_sources,
|
||||
render_source_context,
|
||||
)
|
||||
|
||||
|
||||
class DataAgentInputTests(unittest.TestCase):
|
||||
def test_load_input_sources_reads_xlsx_tables(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir) / 'cases.xlsx'
|
||||
_write_xlsx(path)
|
||||
|
||||
payload = load_input_sources(tmp_dir, ['cases.xlsx'])
|
||||
|
||||
self.assertEqual(payload['source_count'], 1)
|
||||
table = payload['sources'][0]['tables'][0]
|
||||
self.assertEqual(table['title'], 'Sheet')
|
||||
self.assertEqual(table['rows'][0], ['query', '预期domain', '0106-prev-domain', 'type', '备注'])
|
||||
|
||||
def test_load_input_sources_reads_explicit_external_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as root_dir, tempfile.TemporaryDirectory() as external_dir:
|
||||
path = Path(external_dir) / 'cases.xlsx'
|
||||
_write_xlsx(path)
|
||||
|
||||
payload = load_input_sources(root_dir, [str(path)])
|
||||
|
||||
self.assertEqual(payload['source_count'], 1)
|
||||
self.assertEqual(payload['sources'][0]['path'], path.resolve().as_posix())
|
||||
self.assertEqual(payload['sources'][0]['tables'][0]['rows'][1][0], '怎么开启查找设备')
|
||||
|
||||
def test_extract_case_evidence_profiles_and_extracts_rows(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir) / 'cases.xlsx'
|
||||
_write_xlsx(path)
|
||||
|
||||
payload = extract_case_evidence(tmp_dir, paths=['cases.xlsx'])
|
||||
|
||||
self.assertEqual(payload['required_questions'], [])
|
||||
self.assertEqual(payload['evidence_count'], 2)
|
||||
self.assertEqual(payload['evidence'][0]['query'], '怎么开启查找设备')
|
||||
self.assertEqual(payload['evidence'][0]['expected_label'], 'productAgent')
|
||||
self.assertEqual(payload['evidence'][0]['predicted_label'], 'QA')
|
||||
|
||||
def test_extract_case_evidence_asks_when_expected_label_is_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir) / 'ambiguous.xlsx'
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.append(['query', '备注'])
|
||||
sheet.append(['怎么开启查找设备', '需要产品问答'])
|
||||
workbook.save(path)
|
||||
|
||||
payload = extract_case_evidence(tmp_dir, paths=['ambiguous.xlsx'])
|
||||
|
||||
self.assertEqual(payload['evidence_count'], 0)
|
||||
self.assertTrue(any('预期标签列' in question for question in payload['required_questions']))
|
||||
|
||||
def test_render_source_context_reads_docx_tables(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir) / 'prd.docx'
|
||||
_write_minimal_docx(path)
|
||||
|
||||
payload = render_source_context(tmp_dir, paths=['prd.docx'])
|
||||
|
||||
self.assertFalse(payload['truncated'])
|
||||
self.assertIn('complex_task(tag="设备控制")', payload['context_text'])
|
||||
self.assertIn('query示例: 我到家了布置下客厅灯光', payload['context_text'])
|
||||
|
||||
def test_tools_execute_against_registry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
path = Path(tmp_dir) / 'cases.xlsx'
|
||||
_write_xlsx(path)
|
||||
context = build_tool_context(AgentRuntimeConfig(cwd=Path(tmp_dir)))
|
||||
registry = default_tool_registry()
|
||||
|
||||
load_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_load_input_sources',
|
||||
{'paths': ['cases.xlsx'], 'max_rows_per_table': 5},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(load_result.ok, load_result.content)
|
||||
loaded = json.loads(load_result.content)
|
||||
|
||||
evidence_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_extract_case_evidence',
|
||||
{'loaded_sources': loaded},
|
||||
context,
|
||||
)
|
||||
render_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_render_source_context',
|
||||
{'loaded_sources': loaded, 'max_chars': 2000},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(evidence_result.ok, evidence_result.content)
|
||||
evidence = json.loads(evidence_result.content)
|
||||
self.assertEqual(evidence['evidence_count'], 2)
|
||||
self.assertTrue(render_result.ok, render_result.content)
|
||||
rendered = json.loads(render_result.content)
|
||||
self.assertIn('怎么开启查找设备', rendered['context_text'])
|
||||
|
||||
|
||||
def _write_xlsx(path: Path) -> None:
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.append(['query', '预期domain', '0106-prev-domain', 'type', '备注'])
|
||||
sheet.append(['怎么开启查找设备', 'productAgent', 'QA', '设置问答', '预期落产品问答'])
|
||||
sheet.append(['查找设备打开了吗', 'productAgent', 'smartApp', '状态问答', ''])
|
||||
workbook.save(path)
|
||||
|
||||
|
||||
def _write_minimal_docx(path: Path) -> None:
|
||||
document_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>中控tag:complex_task(tag="设备控制")</w:t></w:r></w:p>
|
||||
<w:tbl>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>意图类别</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>query示例</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>高置信品类</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>设备控制</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>我到家了布置下客厅灯光</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>light</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
</w:body>
|
||||
</w:document>
|
||||
'''
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('word/document.xml', document_xml)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,919 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
from src.data_agent_records import (
|
||||
build_planning_prompt,
|
||||
confirm_generation_goal,
|
||||
confirm_generation_plan,
|
||||
export_dataset_records,
|
||||
export_planning_eval_csv,
|
||||
export_training_jsonl,
|
||||
get_generation_plan,
|
||||
normalize_dataset_draft,
|
||||
prepare_generation_goal,
|
||||
prepare_generation_plan,
|
||||
render_dataset_records_table_csv,
|
||||
render_planning_eval_csv,
|
||||
update_generation_plan,
|
||||
validate_dataset_records,
|
||||
)
|
||||
|
||||
|
||||
class DataAgentRecordTests(unittest.TestCase):
|
||||
def test_normalize_dataset_draft_builds_canonical_records(self) -> None:
|
||||
draft = '''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
|
||||
### case: 附近餐饮查询
|
||||
用户: 帮我看看附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
notes: 附近生活服务查询
|
||||
|
||||
### case: 多轮承接附近餐饮
|
||||
用户: 我想出门逛逛
|
||||
小爱: 好的
|
||||
用户: 看看附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
notes: 多轮承接附近生活服务查询
|
||||
'''.strip()
|
||||
|
||||
payload = normalize_dataset_draft(
|
||||
draft,
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
timestamp_step_ms=60_000,
|
||||
)
|
||||
|
||||
self.assertEqual(payload['warnings'], [])
|
||||
records = payload['records']
|
||||
self.assertEqual(len(records), 2)
|
||||
self.assertEqual(records[0]['record_id'], 'gen_demo_000001')
|
||||
self.assertEqual(records[0]['turn']['query'], '帮我看看附近有什么好吃的')
|
||||
self.assertEqual(records[0]['prev_session'], [])
|
||||
self.assertEqual(records[0]['label']['target_type'], 'agent')
|
||||
self.assertEqual(records[0]['dimensions'], {'complex': False})
|
||||
self.assertEqual(records[1]['turn']['query'], '看看附近有什么好吃的')
|
||||
self.assertEqual(
|
||||
records[1]['prev_session'],
|
||||
[
|
||||
{
|
||||
'query': '我想出门逛逛',
|
||||
'tts': '好的',
|
||||
'timestamp': 1_755_569_070_500,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_normalize_dataset_draft_canonicalizes_single_quote_agent_target(self) -> None:
|
||||
payload = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图导航边界数据
|
||||
### case: 顺路停车场
|
||||
用户: 帮我找个顺路的停车场
|
||||
target: Agent(tag='地图导航')
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)
|
||||
|
||||
record = payload['records'][0]
|
||||
self.assertEqual(record['label']['target'], 'Agent(tag="地图导航")')
|
||||
self.assertEqual(record['label']['target_type'], 'agent')
|
||||
|
||||
def test_normalize_dataset_draft_keeps_complex_dimension_independent(self) -> None:
|
||||
payload = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图导航复杂样例
|
||||
### case: 复杂导航
|
||||
用户: 帮我规划一条先去加油站再去公司的路线
|
||||
complex: true
|
||||
target: Agent(tag='地图导航')
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)
|
||||
|
||||
record = payload['records'][0]
|
||||
self.assertEqual(record['label']['target'], 'Agent(tag="地图导航")')
|
||||
self.assertEqual(record['dimensions']['complex'], True)
|
||||
|
||||
def test_validate_dataset_records_reports_valid_payload(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
### case: 附近餐饮查询
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)['records']
|
||||
|
||||
result = validate_dataset_records(records)
|
||||
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['error_count'], 0)
|
||||
|
||||
def test_validate_dataset_records_rejects_current_turn_tts(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
### case: 附近餐饮查询
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)['records']
|
||||
records[0]['turn']['tts'] = '不应该出现'
|
||||
|
||||
result = validate_dataset_records(records)
|
||||
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(result['errors'][0]['path'], 'records[0].turn.tts')
|
||||
|
||||
def test_export_dataset_records_writes_compact_jsonl(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
### case: 附近餐饮查询
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)['records']
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
payload = export_dataset_records(
|
||||
records,
|
||||
root=tmp_dir,
|
||||
output_path='tasks/demo/records.jsonl',
|
||||
)
|
||||
output_path = Path(tmp_dir) / payload['output_path']
|
||||
lines = output_path.read_text(encoding='utf-8').splitlines()
|
||||
table_path = Path(tmp_dir) / payload['table_output_path']
|
||||
table_rows = list(csv.DictReader(io.StringIO(table_path.read_text(encoding='utf-8-sig'))))
|
||||
|
||||
self.assertEqual(payload['output_format'], 'jsonl')
|
||||
self.assertEqual(payload['table_output_format'], 'csv')
|
||||
self.assertEqual(payload['record_count'], 1)
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertNotIn(': ', lines[0])
|
||||
self.assertEqual(json.loads(lines[0])['turn']['query'], '附近有什么好吃的')
|
||||
self.assertEqual(len(table_rows), 1)
|
||||
self.assertEqual(
|
||||
list(table_rows[0].keys()),
|
||||
['request_id', 'timestamp', 'query', 'prev_session', 'context', 'label', '是否迁移Function', 'function'],
|
||||
)
|
||||
self.assertEqual(table_rows[0]['request_id'], 'aabbccdd')
|
||||
self.assertEqual(table_rows[0]['query'], '附近有什么好吃的')
|
||||
self.assertEqual(table_rows[0]['prev_session'], '[]')
|
||||
self.assertEqual(table_rows[0]['context'], '{}')
|
||||
self.assertEqual(table_rows[0]['label'], '地图和生活边界数据')
|
||||
self.assertEqual(table_rows[0]['是否迁移Function'], '')
|
||||
self.assertEqual(table_rows[0]['function'], 'complex=false\nAgent(tag="life_service")')
|
||||
|
||||
def test_render_dataset_records_table_csv_keeps_prev_session_json(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图导航边界数据
|
||||
### case: 多轮顺路停车场
|
||||
用户: 查一下附近停车场
|
||||
小爱: 找到了附近停车场
|
||||
用户: 帮我找个最顺路的
|
||||
target: Agent(tag='地图导航')
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
timestamp_step_ms=60_000,
|
||||
)['records']
|
||||
|
||||
rows = list(csv.DictReader(io.StringIO(render_dataset_records_table_csv(records))))
|
||||
|
||||
prev_session = json.loads(rows[0]['prev_session'])
|
||||
self.assertEqual(prev_session[0]['query'], '查一下附近停车场')
|
||||
self.assertEqual(prev_session[0]['tts'], '找到了附近停车场')
|
||||
self.assertEqual(prev_session[0]['timestamp'], '1755567870500')
|
||||
self.assertEqual(rows[0]['function'], 'complex=false\nAgent(tag="地图导航")')
|
||||
|
||||
def test_export_training_jsonl_uses_prompt_template_and_history(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图导航边界数据
|
||||
### case: 多轮顺路停车场
|
||||
用户: 查一下附近停车场
|
||||
小爱: 找到了附近停车场
|
||||
用户: 帮我找个顺路的
|
||||
target: Agent(tag='地图导航')
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
timestamp_step_ms=60_000,
|
||||
)['records']
|
||||
records[0]['context'] = {'location': '北京', 'rag': '地图服务可用', 'other': '忽略'}
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
payload = export_training_jsonl(
|
||||
records,
|
||||
root=tmp_dir,
|
||||
output_path='output/training.jsonl',
|
||||
)
|
||||
output_path = Path(tmp_dir) / payload['output_path']
|
||||
line = json.loads(output_path.read_text(encoding='utf-8').splitlines()[0])
|
||||
|
||||
self.assertEqual(payload['output_format'], 'jsonl')
|
||||
self.assertEqual(line['system'], '你是小爱同学,中文智能语音助手。')
|
||||
self.assertEqual(line['output'], 'complex=false\nAgent(tag="地图导航")')
|
||||
self.assertIn('[知识注入]\n{\n"location": "北京",\n"rag": "地图服务可用"\n}', line['instruction'])
|
||||
self.assertIn('用户: 查一下附近停车场\n小爱: 找到了附近停车场', line['instruction'])
|
||||
self.assertIn('[当前query]\n用户: 帮我找个顺路的', line['instruction'])
|
||||
|
||||
def test_render_planning_eval_csv_uses_expected_columns(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图导航边界数据
|
||||
### case: 多轮顺路停车场
|
||||
用户: 查一下附近停车场
|
||||
小爱: 找到了附近停车场
|
||||
用户: 帮我找个顺路的
|
||||
target: Agent(tag='地图导航')
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)['records']
|
||||
|
||||
rows = list(csv.DictReader(io.StringIO(render_planning_eval_csv(records))))
|
||||
|
||||
self.assertEqual(
|
||||
list(rows[0].keys()),
|
||||
['request_id', 'newPrompt', 'query', '类别真实标签', 'code标签', 'complex'],
|
||||
)
|
||||
self.assertEqual(rows[0]['request_id'], 'aabbccdd')
|
||||
self.assertEqual(rows[0]['query'], '帮我找个顺路的')
|
||||
self.assertEqual(rows[0]['类别真实标签'], '地图导航')
|
||||
self.assertEqual(rows[0]['code标签'], 'Agent(tag="地图导航")')
|
||||
self.assertEqual(rows[0]['complex'], 'FALSE')
|
||||
self.assertTrue(rows[0]['newPrompt'].startswith('<|im_start|>system\n你是小爱同学,中文智能语音助手。<|im_end|>'))
|
||||
self.assertIn('<|im_start|>user\n请参考用户的[当前query]', rows[0]['newPrompt'])
|
||||
self.assertIn('[function]', rows[0]['newPrompt'])
|
||||
self.assertTrue(rows[0]['newPrompt'].endswith('<|im_start|>assistant\n'))
|
||||
|
||||
def test_build_planning_prompt_matches_standard_chat_template(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图导航边界数据
|
||||
### case: 多轮顺路停车场
|
||||
用户: 帮我查一下最近的停车场
|
||||
小爱: 最近的停车场离你27米
|
||||
用户: 帮我找一个最顺路的停车场
|
||||
target: Agent(tag='地图导航')
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
timestamp_step_ms=60_000,
|
||||
)['records']
|
||||
records[0]['context'] = {'location': '', 'rag': ''}
|
||||
|
||||
prompt = build_planning_prompt(records[0], system_prompt='你是小爱同学,中文智能语音助手。\n')
|
||||
|
||||
self.assertEqual(
|
||||
prompt,
|
||||
'<|im_start|>system\n'
|
||||
'你是小爱同学,中文智能语音助手。<|im_end|>\n'
|
||||
'<|im_start|>user\n'
|
||||
'请参考用户的[当前query]、[对话历史]、[知识注入]、[系统状态]识别出[当前query]的[function]结果,[function]是python的code形式。\n'
|
||||
'[知识注入]\n'
|
||||
'{\n'
|
||||
'"location": "",\n'
|
||||
'"rag": ""\n'
|
||||
'}\n'
|
||||
'[系统状态]\n'
|
||||
'{}\n'
|
||||
'[对话历史]\n'
|
||||
'用户: 帮我查一下最近的停车场\n'
|
||||
'小爱: 最近的停车场离你27米\n'
|
||||
'[当前query]\n'
|
||||
'用户: 帮我找一个最顺路的停车场\n'
|
||||
'[function]\n'
|
||||
'<|im_end|>\n'
|
||||
'<|im_start|>assistant\n',
|
||||
)
|
||||
|
||||
def test_export_planning_eval_csv_writes_file(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 时间工具数据
|
||||
### case: 几点
|
||||
用户: 现在几点
|
||||
complex: true
|
||||
target: CalendarQA(type="TIME")
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)['records']
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
payload = export_planning_eval_csv(
|
||||
records,
|
||||
root=tmp_dir,
|
||||
output_path='output/eval_planning.csv',
|
||||
)
|
||||
output_path = Path(tmp_dir) / payload['output_path']
|
||||
rows = list(csv.DictReader(io.StringIO(output_path.read_text(encoding='utf-8-sig'))))
|
||||
|
||||
self.assertEqual(payload['output_format'], 'csv')
|
||||
self.assertEqual(rows[0]['类别真实标签'], 'CalendarQA')
|
||||
self.assertEqual(rows[0]['code标签'], 'CalendarQA(type="TIME")')
|
||||
self.assertEqual(rows[0]['complex'], 'TRUE')
|
||||
|
||||
def test_normalize_online_draft_uses_real_request_metadata(self) -> None:
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 线上误召回badcase专项
|
||||
### case: 线上样例
|
||||
request_id: rid-123
|
||||
timestamp: 1755567930500
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
source_type='online',
|
||||
)['records']
|
||||
|
||||
self.assertEqual(records[0]['record_id'], 'online_rid-123_000001')
|
||||
self.assertEqual(records[0]['source']['request_id'], 'rid-123')
|
||||
self.assertEqual(records[0]['source']['timestamp'], 1_755_567_930_500)
|
||||
|
||||
def test_normalize_requires_confirmed_plan_when_state_root_is_provided(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
with self.assertRaisesRegex(Exception, 'confirmed_plan_id is required'):
|
||||
normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
### case: 附近餐饮查询
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
plan_state_root=tmp_dir,
|
||||
)
|
||||
|
||||
def test_generation_plan_can_be_confirmed_then_used(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
plan_payload = prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
dataset_label='地图和生活边界数据',
|
||||
target='Agent(tag="life_service")',
|
||||
total_count=2,
|
||||
turn_mix='1 条单轮,1 条多轮',
|
||||
coverage='附近吃喝玩乐',
|
||||
exclusions='不要生成导航路线类 query',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
plan_id = plan_payload['plan']['plan_id']
|
||||
confirmed = confirm_generation_plan(root=tmp_dir, plan_id=plan_id, confirmation='确认,开始生成')
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
### case: 附近餐饮查询
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
confirmed_plan_id=confirmed['confirmed_plan_id'],
|
||||
plan_state_root=tmp_dir,
|
||||
)['records']
|
||||
|
||||
self.assertEqual(records[0]['label']['dataset_label'], '地图和生活边界数据')
|
||||
|
||||
def test_generation_goal_can_gate_plan_creation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
goal_payload = prepare_generation_goal(
|
||||
root=tmp_dir,
|
||||
dataset_label='地图和生活边界数据',
|
||||
goal_summary='生成附近生活服务边界数据',
|
||||
target='Agent(tag="life_service")',
|
||||
plan_hint='建议先生成 2 条单轮,输出到 tasks/demo/records.jsonl',
|
||||
coverage='附近吃喝玩乐',
|
||||
exclusions='不要生成导航路线类 query',
|
||||
source_refs=['manual:user'],
|
||||
)
|
||||
goal_id = goal_payload['goal']['goal_id']
|
||||
with self.assertRaisesRegex(Exception, 'generation goal is not confirmed'):
|
||||
prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
confirmed_goal_id=goal_id,
|
||||
dataset_label='地图和生活边界数据',
|
||||
target='Agent(tag="life_service")',
|
||||
total_count=2,
|
||||
turn_mix='2 条单轮',
|
||||
coverage='附近吃喝玩乐',
|
||||
exclusions='不要生成导航路线类 query',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
confirmed_goal = confirm_generation_goal(
|
||||
root=tmp_dir,
|
||||
goal_id=goal_id,
|
||||
confirmation='确认 goal',
|
||||
reviewed_revision=1,
|
||||
)
|
||||
plan_payload = prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
confirmed_goal_id=confirmed_goal['confirmed_goal_id'],
|
||||
dataset_label='地图和生活边界数据',
|
||||
target='Agent(tag="life_service")',
|
||||
total_count=2,
|
||||
turn_mix='2 条单轮',
|
||||
coverage='附近吃喝玩乐',
|
||||
exclusions='不要生成导航路线类 query',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
|
||||
self.assertEqual(plan_payload['plan']['confirmed_goal_id'], goal_id)
|
||||
self.assertEqual(
|
||||
goal_payload['goal']['plan_hint'],
|
||||
'建议先生成 2 条单轮,输出到 tasks/demo/records.jsonl',
|
||||
)
|
||||
|
||||
def test_generation_goal_requires_declared_targets_before_review(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
with self.assertRaisesRegex(Exception, 'must include target or target_definitions'):
|
||||
prepare_generation_goal(
|
||||
root=tmp_dir,
|
||||
dataset_label='地图和生活边界数据',
|
||||
goal_summary='生成附近生活服务边界数据',
|
||||
coverage='附近吃喝玩乐',
|
||||
exclusions='不要生成导航路线类 query',
|
||||
)
|
||||
|
||||
def test_generation_plan_inherits_targets_from_confirmed_goal(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
goal_payload = prepare_generation_goal(
|
||||
root=tmp_dir,
|
||||
dataset_label='餐饮和导航边界数据',
|
||||
goal_summary='生成餐饮服务和地图导航边界数据',
|
||||
target_definitions=[
|
||||
{'name': '餐饮服务', 'target': 'Agent(tag="餐饮服务")', 'rule': '找附近餐饮'},
|
||||
{'name': '地图导航', 'target': 'Agent(tag="地图导航")', 'rule': '明确导航'},
|
||||
],
|
||||
coverage='餐饮服务和地图导航边界',
|
||||
exclusions='不要生成无关闲聊',
|
||||
)
|
||||
goal_id = goal_payload['goal']['goal_id']
|
||||
confirm_generation_goal(root=tmp_dir, goal_id=goal_id, confirmation='确认目标')
|
||||
|
||||
plan_payload = prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
confirmed_goal_id=goal_id,
|
||||
dataset_label='餐饮和导航边界数据',
|
||||
target='',
|
||||
total_count=2,
|
||||
turn_mix='2 条单轮',
|
||||
coverage='餐饮服务和地图导航边界',
|
||||
exclusions='不要生成无关闲聊',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[item['target'] for item in plan_payload['plan']['target_definitions']],
|
||||
['Agent(tag="餐饮服务")', 'Agent(tag="地图导航")'],
|
||||
)
|
||||
|
||||
def test_generation_plan_dedupes_existing_task_output_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
existing = Path(tmp_dir) / 'tasks' / 'demo' / 'artifacts'
|
||||
existing.mkdir(parents=True)
|
||||
plan_payload = prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
dataset_label='地图和生活边界数据',
|
||||
target='Agent(tag="life_service")',
|
||||
total_count=2,
|
||||
turn_mix='1 条单轮,1 条多轮',
|
||||
coverage='附近吃喝玩乐',
|
||||
exclusions='不要生成导航路线类 query',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
plan_payload['plan']['output_path'],
|
||||
'tasks/demo-data_plan_000001/artifacts',
|
||||
)
|
||||
self.assertEqual(plan_payload['plan']['requested_output_path'], 'tasks/demo/artifacts')
|
||||
|
||||
def test_generation_plan_review_update_and_revision_confirmation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
plan_payload = prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
dataset_label='地图和生活边界数据',
|
||||
target='Agent(tag="life_service")',
|
||||
total_count=2,
|
||||
turn_mix='1 条单轮,1 条多轮',
|
||||
coverage='附近吃喝玩乐',
|
||||
exclusions='不要生成导航路线类 query',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
plan_id = plan_payload['plan']['plan_id']
|
||||
updated = update_generation_plan(
|
||||
root=tmp_dir,
|
||||
plan_id=plan_id,
|
||||
review_feedback='多轮数据多一点',
|
||||
updates={'turn_mix': '1 条单轮,3 条多轮', 'total_count': 4},
|
||||
)
|
||||
|
||||
self.assertEqual(updated['plan']['revision'], 2)
|
||||
self.assertEqual(updated['plan']['turn_mix'], '1 条单轮,3 条多轮')
|
||||
self.assertEqual(len(updated['plan']['review_history']), 1)
|
||||
shown = get_generation_plan(root=tmp_dir, plan_id=plan_id)
|
||||
self.assertEqual(shown['plan']['revision'], 2)
|
||||
with self.assertRaisesRegex(Exception, 'reviewed_revision must match'):
|
||||
confirm_generation_plan(
|
||||
root=tmp_dir,
|
||||
plan_id=plan_id,
|
||||
confirmation='确认,开始生成',
|
||||
reviewed_revision=1,
|
||||
)
|
||||
confirmed = confirm_generation_plan(
|
||||
root=tmp_dir,
|
||||
plan_id=plan_id,
|
||||
confirmation='确认,开始生成',
|
||||
reviewed_revision=2,
|
||||
)
|
||||
|
||||
self.assertEqual(confirmed['plan']['status'], 'confirmed')
|
||||
self.assertEqual(confirmed['plan']['confirmed_revision'], 2)
|
||||
|
||||
def test_multi_target_plan_allows_only_declared_targets(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
plan_payload = prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
dataset_label='餐饮和导航边界数据',
|
||||
target='',
|
||||
target_definitions=[
|
||||
{
|
||||
'name': '餐饮服务',
|
||||
'target': 'Agent(tag="餐饮服务")',
|
||||
'rule': '找附近美食但不导航',
|
||||
},
|
||||
{
|
||||
'name': '地图导航',
|
||||
'target': 'Agent(tag="地图导航")',
|
||||
'rule': '明确要求导航去某地',
|
||||
},
|
||||
],
|
||||
total_count=2,
|
||||
turn_mix='2 条单轮',
|
||||
coverage='餐饮服务和地图导航边界',
|
||||
exclusions='不要生成无关闲聊',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
plan_id = plan_payload['plan']['plan_id']
|
||||
confirm_generation_plan(root=tmp_dir, plan_id=plan_id, confirmation='确认,开始生成')
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 餐饮和导航边界数据
|
||||
### case: 找奶茶
|
||||
用户: 附近有没有奶茶店
|
||||
target: Agent(tag="餐饮服务")
|
||||
### case: 导航去奶茶店
|
||||
用户: 导航去最近的奶茶店
|
||||
target: Agent(tag="地图导航")
|
||||
'''.strip(),
|
||||
confirmed_plan_id=plan_id,
|
||||
plan_state_root=tmp_dir,
|
||||
)['records']
|
||||
|
||||
self.assertEqual(records[0]['label']['target'], 'Agent(tag="餐饮服务")')
|
||||
self.assertEqual(records[1]['label']['target'], 'Agent(tag="地图导航")')
|
||||
|
||||
def test_multi_target_plan_rejects_undeclared_targets(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
plan_payload = prepare_generation_plan(
|
||||
root=tmp_dir,
|
||||
dataset_label='餐饮和导航边界数据',
|
||||
target='',
|
||||
target_definitions=[
|
||||
{'target': 'Agent(tag="餐饮服务")'},
|
||||
{'target': 'Agent(tag="地图导航")'},
|
||||
],
|
||||
total_count=1,
|
||||
turn_mix='1 条单轮',
|
||||
coverage='餐饮服务和地图导航边界',
|
||||
exclusions='不要生成无关闲聊',
|
||||
output_path='tasks/demo/artifacts',
|
||||
)
|
||||
plan_id = plan_payload['plan']['plan_id']
|
||||
confirm_generation_plan(root=tmp_dir, plan_id=plan_id, confirmation='确认,开始生成')
|
||||
with self.assertRaisesRegex(Exception, 'record targets must match'):
|
||||
normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 餐饮和导航边界数据
|
||||
### case: 天气
|
||||
用户: 明天天气怎么样
|
||||
target: Agent(tag="天气")
|
||||
'''.strip(),
|
||||
confirmed_plan_id=plan_id,
|
||||
plan_state_root=tmp_dir,
|
||||
)
|
||||
|
||||
def test_tools_execute_against_registry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
context = build_tool_context(AgentRuntimeConfig(cwd=Path(tmp_dir)))
|
||||
goal_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_prepare_generation_goal',
|
||||
{
|
||||
'dataset_label': '地图和生活边界数据',
|
||||
'goal_summary': '生成附近生活服务边界数据',
|
||||
'target': 'Agent(tag="life_service")',
|
||||
'coverage': '附近吃喝玩乐',
|
||||
'exclusions': '不要生成导航路线类 query',
|
||||
'source_refs': ['manual:user'],
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(goal_result.ok, goal_result.content)
|
||||
goal_id = json.loads(goal_result.content)['goal']['goal_id']
|
||||
blocked_plan_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_prepare_generation_plan',
|
||||
{
|
||||
'dataset_label': '地图和生活边界数据',
|
||||
'target': 'Agent(tag="life_service")',
|
||||
'total_count': 1,
|
||||
'turn_mix': '1 条单轮',
|
||||
'coverage': '附近吃喝玩乐',
|
||||
'exclusions': '不要生成导航路线类 query',
|
||||
'output_path': 'tasks/demo/artifacts',
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertFalse(blocked_plan_result.ok)
|
||||
confirmed_goal_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_confirm_generation_goal',
|
||||
{'goal_id': goal_id, 'confirmation': '确认 goal', 'reviewed_revision': 1},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(confirmed_goal_result.ok, confirmed_goal_result.content)
|
||||
plan_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_prepare_generation_plan',
|
||||
{
|
||||
'confirmed_goal_id': goal_id,
|
||||
'dataset_label': '地图和生活边界数据',
|
||||
'target': 'Agent(tag="life_service")',
|
||||
'total_count': 1,
|
||||
'turn_mix': '1 条单轮',
|
||||
'coverage': '附近吃喝玩乐',
|
||||
'exclusions': '不要生成导航路线类 query',
|
||||
'output_path': 'tasks/demo/artifacts',
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(plan_result.ok)
|
||||
plan_id = json.loads(plan_result.content)['plan']['plan_id']
|
||||
update_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_update_generation_plan',
|
||||
{
|
||||
'plan_id': plan_id,
|
||||
'review_feedback': '多轮不需要,先只测单轮',
|
||||
'updates': {'turn_mix': '1 条单轮'},
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(update_result.ok)
|
||||
revision = json.loads(update_result.content)['plan']['revision']
|
||||
confirm_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_confirm_generation_plan',
|
||||
{'plan_id': plan_id, 'confirmation': '确认,开始生成', 'reviewed_revision': revision},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(confirm_result.ok)
|
||||
normalize_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_normalize_dataset_draft',
|
||||
{
|
||||
'draft_text': '''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
### case: 附近餐饮查询
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
'batch_id': 'demo',
|
||||
'base_timestamp': 1_755_567_930_500,
|
||||
'confirmed_plan_id': plan_id,
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(normalize_result.ok)
|
||||
records = json.loads(normalize_result.content)['records']
|
||||
|
||||
validate_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_validate_dataset_records',
|
||||
{'records': records},
|
||||
context,
|
||||
)
|
||||
export_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_export_dataset_records',
|
||||
{
|
||||
'records': records,
|
||||
'output_path': 'tasks/demo/records.jsonl',
|
||||
},
|
||||
context,
|
||||
)
|
||||
export_payload = json.loads(export_result.content) if export_result.ok else {}
|
||||
exported_lines = (
|
||||
(Path(tmp_dir) / export_payload['output_path']).read_text(encoding='utf-8').splitlines()
|
||||
if export_result.ok
|
||||
else []
|
||||
)
|
||||
|
||||
self.assertTrue(validate_result.ok)
|
||||
self.assertTrue(json.loads(validate_result.content)['ok'])
|
||||
self.assertTrue(export_result.ok, export_result.content)
|
||||
self.assertEqual(export_payload['record_count'], 1)
|
||||
self.assertEqual(len(exported_lines), 1)
|
||||
|
||||
def test_data_agent_tools_accept_draft_and_records_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
context = build_tool_context(AgentRuntimeConfig(cwd=root))
|
||||
draft_path = root / 'draft.txt'
|
||||
draft_path.write_text(
|
||||
'''
|
||||
# dataset_label: 地图导航边界数据
|
||||
### case: 顺路停车场
|
||||
用户: 帮我找个顺路的停车场
|
||||
target: Agent(tag='地图导航')
|
||||
'''.strip(),
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
normalize_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_normalize_dataset_draft',
|
||||
{
|
||||
'draft_path': str(draft_path),
|
||||
'batch_id': 'demo',
|
||||
'source_type': 'manual',
|
||||
'base_timestamp': 1_755_567_930_500,
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(normalize_result.ok, normalize_result.content)
|
||||
records = json.loads(normalize_result.content)['records']
|
||||
self.assertEqual(records[0]['label']['target'], 'Agent(tag="地图导航")')
|
||||
|
||||
records_path = root / 'records.jsonl'
|
||||
records_path.write_text(
|
||||
'\n'.join(json.dumps(record, ensure_ascii=False) for record in records),
|
||||
encoding='utf-8',
|
||||
)
|
||||
validate_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_validate_dataset_records',
|
||||
{'records_path': str(records_path)},
|
||||
context,
|
||||
)
|
||||
export_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_export_dataset_records',
|
||||
{
|
||||
'records_path': str(records_path),
|
||||
'output_path': 'output/records.jsonl',
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(export_result.ok, export_result.content)
|
||||
export_payload = json.loads(export_result.content)
|
||||
table_exists = (root / export_payload['table_output_path']).exists()
|
||||
training_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_export_training_jsonl',
|
||||
{'records_path': str(records_path)},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(training_result.ok, training_result.content)
|
||||
training_payload = json.loads(training_result.content)
|
||||
training_exists = (root / training_payload['output_path']).exists()
|
||||
eval_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_export_planning_eval_csv',
|
||||
{'records_path': str(records_path)},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(eval_result.ok, eval_result.content)
|
||||
eval_payload = json.loads(eval_result.content)
|
||||
eval_exists = (root / eval_payload['output_path']).exists()
|
||||
|
||||
self.assertTrue(validate_result.ok, validate_result.content)
|
||||
self.assertTrue(json.loads(validate_result.content)['ok'])
|
||||
self.assertEqual(export_payload['record_count'], 1)
|
||||
self.assertTrue(table_exists)
|
||||
self.assertEqual(training_payload['output_path'], '.port_sessions/data_agent_output/training.jsonl')
|
||||
self.assertTrue(training_exists)
|
||||
self.assertEqual(eval_payload['output_path'], '.port_sessions/data_agent_output/eval_planning.csv')
|
||||
self.assertTrue(eval_exists)
|
||||
|
||||
def test_tool_schemas_avoid_top_level_composition_keywords(self) -> None:
|
||||
# 部分 Bedrock/Anthropic 兼容后端不接受顶层 anyOf/oneOf/allOf。
|
||||
blocked_keywords = {'anyOf', 'oneOf', 'allOf'}
|
||||
violations = [
|
||||
name
|
||||
for name, tool in default_tool_registry().items()
|
||||
if blocked_keywords.intersection(tool.parameters)
|
||||
]
|
||||
|
||||
self.assertEqual(violations, [])
|
||||
|
||||
def test_data_agent_tools_route_relative_outputs_to_session_output(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
scratchpad = root / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
context = build_tool_context(AgentRuntimeConfig(cwd=root), scratchpad_directory=scratchpad)
|
||||
registry = default_tool_registry()
|
||||
|
||||
plan_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_prepare_generation_plan',
|
||||
{
|
||||
'direct_review': True,
|
||||
'dataset_label': '地图和生活边界数据',
|
||||
'target': 'Agent(tag="life_service")',
|
||||
'total_count': 1,
|
||||
'turn_mix': '1 条单轮',
|
||||
'coverage': '附近吃喝玩乐',
|
||||
'exclusions': '不要生成导航路线类 query',
|
||||
'output_path': 'output/demo/records.jsonl',
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(plan_result.ok, plan_result.content)
|
||||
plan_payload = json.loads(plan_result.content)
|
||||
|
||||
records = normalize_dataset_draft(
|
||||
'''
|
||||
# dataset_label: 地图和生活边界数据
|
||||
### case: 附近餐饮查询
|
||||
用户: 附近有什么好吃的
|
||||
target: Agent(tag="life_service")
|
||||
'''.strip(),
|
||||
batch_id='demo',
|
||||
base_timestamp=1_755_567_930_500,
|
||||
)['records']
|
||||
export_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_export_dataset_records',
|
||||
{'records': records, 'output_path': 'output/地图和生活边界数据/自定义名字.jsonl'},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(export_result.ok, export_result.content)
|
||||
export_payload = json.loads(export_result.content)
|
||||
|
||||
expected = '.port_sessions/accounts/alice/sessions/s1/output/records.jsonl'
|
||||
expected_table = '.port_sessions/accounts/alice/sessions/s1/output/records.csv'
|
||||
self.assertEqual(plan_payload['plan']['output_path'], expected)
|
||||
self.assertEqual(export_payload['output_path'], expected)
|
||||
self.assertEqual(export_payload['table_output_path'], expected_table)
|
||||
|
||||
def test_data_agent_session_records_path_stays_stable_when_file_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
scratchpad = root / '.port_sessions' / 'accounts' / 'alice' / 'sessions' / 's1' / 'scratchpad'
|
||||
output_root = scratchpad.parent / 'output'
|
||||
output_root.mkdir(parents=True)
|
||||
(output_root / 'records.jsonl').write_text('{}\n', encoding='utf-8')
|
||||
context = build_tool_context(AgentRuntimeConfig(cwd=root), scratchpad_directory=scratchpad)
|
||||
|
||||
plan_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'data_agent_prepare_generation_plan',
|
||||
{
|
||||
'direct_review': True,
|
||||
'dataset_label': '地图和生活边界数据',
|
||||
'target': 'Agent(tag="life_service")',
|
||||
'total_count': 1,
|
||||
'turn_mix': '1 条单轮',
|
||||
'coverage': '附近吃喝玩乐',
|
||||
'exclusions': '不要生成导航路线类 query',
|
||||
'output_path': 'output/任意子目录/任意名字.jsonl',
|
||||
},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(plan_result.ok, plan_result.content)
|
||||
plan_payload = json.loads(plan_result.content)
|
||||
|
||||
self.assertEqual(
|
||||
plan_payload['plan']['output_path'],
|
||||
'.port_sessions/accounts/alice/sessions/s1/output/records.jsonl',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,256 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
from src.data_agent_router_sessions import (
|
||||
convert_router_candidates_to_records,
|
||||
profile_router_sessions,
|
||||
sample_router_candidates,
|
||||
search_router_sessions,
|
||||
)
|
||||
|
||||
|
||||
HAS_PYARROW = importlib.util.find_spec('pyarrow') is not None
|
||||
|
||||
|
||||
class DataAgentRouterSessionTests(unittest.TestCase):
|
||||
def test_sample_router_candidates_accepts_search_payload(self) -> None:
|
||||
payload = {
|
||||
'candidates': [
|
||||
_candidate('s1', 'phone', 'QA', 'unknown', '怎么查找设备'),
|
||||
_candidate('s2', 'car', 'mapCopilot', '导航', '导航去公司'),
|
||||
_candidate('s3', 'phone', 'QA', 'unknown', '手机丢了怎么办'),
|
||||
]
|
||||
}
|
||||
|
||||
result = sample_router_candidates(payload, sample_size=2, strategy='stride')
|
||||
|
||||
self.assertEqual(result['total_candidate_count'], 3)
|
||||
self.assertEqual(result['sampled_count'], 2)
|
||||
self.assertEqual(result['summary']['device'][0], {'value': 'phone', 'count': 2})
|
||||
self.assertEqual(result['candidates'][0]['semantic_session_id'], 's1')
|
||||
self.assertEqual(result['candidates'][1]['semantic_session_id'], 's3')
|
||||
|
||||
def test_sample_router_candidates_accepts_json_string(self) -> None:
|
||||
result = sample_router_candidates(
|
||||
json.dumps([_candidate('s1', 'phone', 'QA', 'unknown', '怎么查找设备')], ensure_ascii=False),
|
||||
sample_size=1,
|
||||
)
|
||||
|
||||
self.assertEqual(result['sampled_count'], 1)
|
||||
self.assertEqual(result['candidates'][0]['matched_turn']['query'], '怎么查找设备')
|
||||
|
||||
def test_convert_router_candidates_to_records_keeps_online_metadata(self) -> None:
|
||||
candidate = _candidate('s1', 'phone', 'QA', 'unknown', '总结一下')
|
||||
candidate['turn_count'] = 2
|
||||
candidate['matched_turn_index'] = 1
|
||||
candidate['matched_turn']['timestamp'] = 20
|
||||
candidate['prev_turns'] = [{'timestamp': 10, 'query': '这篇文章讲了什么', 'tts': ''}]
|
||||
|
||||
result = convert_router_candidates_to_records(
|
||||
[candidate],
|
||||
dataset_label='总结类边界评测集',
|
||||
default_target='Summarize',
|
||||
default_complex=True,
|
||||
batch_id='summary',
|
||||
)
|
||||
|
||||
self.assertEqual(result['record_count'], 1)
|
||||
record = result['records'][0]
|
||||
self.assertEqual(record['source']['type'], 'online')
|
||||
self.assertEqual(record['source']['request_id'], 's1-rid')
|
||||
self.assertEqual(record['turn']['query'], '总结一下')
|
||||
self.assertEqual(record['prev_session'], [{'query': '这篇文章讲了什么', 'tts': '', 'timestamp': 10}])
|
||||
self.assertEqual(record['context']['domain'], 'QA')
|
||||
self.assertEqual(record['label']['target'], 'Summarize')
|
||||
self.assertEqual(record['dimensions'], {'complex': True})
|
||||
|
||||
def test_convert_router_candidates_to_records_applies_review_decisions(self) -> None:
|
||||
candidates = [
|
||||
_candidate('s1', 'phone', 'QA', 'unknown', '总结一下'),
|
||||
_candidate('s2', 'phone', 'QA', 'unknown', '总结一下红楼梦'),
|
||||
]
|
||||
|
||||
result = convert_router_candidates_to_records(
|
||||
candidates,
|
||||
dataset_label='总结类边界评测集',
|
||||
review_decisions=[
|
||||
{
|
||||
'semantic_session_id': 's1',
|
||||
'matched_turn_index': 0,
|
||||
'decision': 'include',
|
||||
'target': 'Summarize',
|
||||
'complex': True,
|
||||
'notes': '前文有可总结内容',
|
||||
},
|
||||
{
|
||||
'semantic_session_id': 's2',
|
||||
'matched_turn_index': 0,
|
||||
'decision': 'exclude',
|
||||
'notes': '开放知识对象',
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result['record_count'], 1)
|
||||
self.assertEqual(result['skipped_count'], 1)
|
||||
self.assertEqual(result['records'][0]['meta']['notes'], '前文有可总结内容')
|
||||
self.assertEqual(result['records'][0]['dimensions']['complex'], True)
|
||||
|
||||
@unittest.skipUnless(HAS_PYARROW, 'pyarrow is required for parquet tests')
|
||||
def test_profile_and_search_router_sessions_read_parquet(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
_write_router_parquet(Path(tmp_dir))
|
||||
|
||||
profile = profile_router_sessions(tmp_dir, dates=['20260428'], max_files=1)
|
||||
search = search_router_sessions(
|
||||
tmp_dir,
|
||||
dates=['20260428'],
|
||||
domains=['mapCopilot'],
|
||||
query_keywords=['导航'],
|
||||
max_files=1,
|
||||
max_candidates=5,
|
||||
)
|
||||
|
||||
self.assertEqual(profile['sampled_row_count'], 2)
|
||||
self.assertEqual(profile['missing_req_id_count'], 0)
|
||||
self.assertTrue(any(item['value'] == 'phone' for item in profile['distributions']['device']))
|
||||
self.assertEqual(search['candidate_count'], 1)
|
||||
candidate = search['candidates'][0]
|
||||
self.assertEqual(candidate['req_id'], 'rid-1')
|
||||
self.assertEqual(candidate['matched_turn']['query'], '导航去公司')
|
||||
self.assertEqual(candidate['prev_turns'][0]['query'], '你好小爱')
|
||||
|
||||
@unittest.skipUnless(HAS_PYARROW, 'pyarrow is required for parquet tests')
|
||||
def test_router_session_tools_allow_external_absolute_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as workspace_dir, tempfile.TemporaryDirectory() as data_dir:
|
||||
_write_router_parquet(Path(data_dir))
|
||||
external_partition = Path(data_dir) / 'router_session_parquet' / 'date=20260428'
|
||||
|
||||
profile = profile_router_sessions(
|
||||
workspace_dir,
|
||||
paths=[str(external_partition)],
|
||||
max_files=1,
|
||||
)
|
||||
search = search_router_sessions(
|
||||
workspace_dir,
|
||||
paths=[str(external_partition)],
|
||||
query_keywords=['导航'],
|
||||
max_files=1,
|
||||
max_candidates=5,
|
||||
)
|
||||
|
||||
self.assertEqual(profile['sampled_row_count'], 2)
|
||||
self.assertEqual(search['candidate_count'], 1)
|
||||
self.assertEqual(search['candidates'][0]['matched_turn']['query'], '导航去公司')
|
||||
|
||||
@unittest.skipUnless(HAS_PYARROW, 'pyarrow is required for parquet tests')
|
||||
def test_router_session_tools_execute_against_registry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
_write_router_parquet(Path(tmp_dir))
|
||||
context = build_tool_context(AgentRuntimeConfig(cwd=Path(tmp_dir)))
|
||||
registry = default_tool_registry()
|
||||
|
||||
search_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_search_router_sessions',
|
||||
{'dates': ['20260428'], 'query_regex': '查找设备|手机丢了', 'max_files': 1},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(search_result.ok, search_result.content)
|
||||
sample_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_sample_router_candidates',
|
||||
{'candidates': json.loads(search_result.content), 'sample_size': 1, 'strategy': 'first'},
|
||||
context,
|
||||
)
|
||||
convert_result = execute_tool(
|
||||
registry,
|
||||
'data_agent_convert_router_candidates_to_records',
|
||||
{
|
||||
'candidates': json.loads(sample_result.content),
|
||||
'dataset_label': '查找设备评测集',
|
||||
'default_target': 'QA',
|
||||
'batch_id': 'find_device',
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(sample_result.ok, sample_result.content)
|
||||
self.assertEqual(json.loads(sample_result.content)['sampled_count'], 1)
|
||||
self.assertTrue(convert_result.ok, convert_result.content)
|
||||
self.assertTrue(json.loads(convert_result.content)['validation']['ok'])
|
||||
|
||||
|
||||
def _candidate(session_id: str, device: str, domain: str, intent: str, query: str) -> dict[str, object]:
|
||||
return {
|
||||
'semantic_session_id': session_id,
|
||||
'req_id': f'{session_id}-rid',
|
||||
'device': device,
|
||||
'turn_count': 1,
|
||||
'matched_turn_index': 0,
|
||||
'matched_turn': {
|
||||
'timestamp': 1,
|
||||
'query': query,
|
||||
'tts': '',
|
||||
'action': {'domain': domain, 'intent': intent},
|
||||
},
|
||||
'prev_turns': [],
|
||||
'source_path': 'router_session_parquet/date=20260428/part-0.parquet',
|
||||
'date': '20260428',
|
||||
}
|
||||
|
||||
|
||||
def _write_router_parquet(root: Path) -> None:
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
partition = root / 'router_session_parquet' / 'date=20260428'
|
||||
partition.mkdir(parents=True)
|
||||
rows = [
|
||||
{
|
||||
'semantic_session_id': 'phone_20260428_1',
|
||||
'device': 'phone',
|
||||
'req_id': 'rid-1',
|
||||
'turn_count': 2,
|
||||
'turns_array': [
|
||||
{
|
||||
'timestamp': 1,
|
||||
'query': '你好小爱',
|
||||
'action_json': '{"domain":"dialogCopilot","intent":"问候"}',
|
||||
'tts': '你好',
|
||||
},
|
||||
{
|
||||
'timestamp': 2,
|
||||
'query': '导航去公司',
|
||||
'action_json': '{"domain":"mapCopilot","intent":"导航","func":"start_navigation"}',
|
||||
'tts': '开始导航',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'semantic_session_id': 'phone_20260428_2',
|
||||
'device': 'phone',
|
||||
'req_id': 'rid-2',
|
||||
'turn_count': 1,
|
||||
'turns_array': [
|
||||
{
|
||||
'timestamp': 3,
|
||||
'query': '手机丢了怎么查找设备',
|
||||
'action_json': '{"domain":"QA","intent":"unknown"}',
|
||||
'tts': '',
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
pq.write_table(pa.Table.from_pylist(rows), partition / 'part-0.parquet')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
import docker
|
||||
from agent_platform.gateway.provider import DockerExecutionProvider, workspace_ref
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.getenv("RUN_DOCKER_INTEGRATION") != "1", reason="set RUN_DOCKER_INTEGRATION=1")
|
||||
async def test_real_docker_workspaces_are_isolated(settings) -> None:
|
||||
client = docker.from_env()
|
||||
settings = replace(settings, workspace_network_enabled=True)
|
||||
provider = DockerExecutionProvider(settings, client=client)
|
||||
users = ("integration-user-a", "integration-user-b")
|
||||
try:
|
||||
assert (await provider.write_file(users[0], "secret.txt", "only-a")).ok
|
||||
assert (await provider.write_file(users[1], "secret.txt", "only-b")).ok
|
||||
first = await provider.read_file(users[0], "secret.txt", 1, 10)
|
||||
second = await provider.read_file(users[1], "secret.txt", 1, 10)
|
||||
assert "only-a" in first.output and "only-b" not in first.output
|
||||
assert "only-b" in second.output and "only-a" not in second.output
|
||||
escaped = await provider.exec(users[0], "test ! -e /var/run/docker.sock", ".", 10)
|
||||
assert escaped.ok
|
||||
|
||||
git_init = "git init -q && git config user.email test@example.invalid && git config user.name Integration"
|
||||
assert (await provider.exec(users[0], git_init, ".", 10)).ok
|
||||
assert (await provider.write_file(users[0], "tracked.txt", "before\n")).ok
|
||||
assert (await provider.exec(users[0], "git add tracked.txt && git commit -qm initial", ".", 10)).ok
|
||||
patch = (
|
||||
"diff --git a/tracked.txt b/tracked.txt\n"
|
||||
"index 90be1a7..2a140c2 100644\n"
|
||||
"--- a/tracked.txt\n"
|
||||
"+++ b/tracked.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-before\n"
|
||||
"+after\n"
|
||||
)
|
||||
assert (await provider.apply_patch(users[0], patch, ".")).ok
|
||||
assert "after" in (await provider.read_file(users[0], "tracked.txt", 1, 10)).output
|
||||
|
||||
started = await provider.start_process(users[0], "sleep 0.2; echo background-done", ".")
|
||||
process_id = started.metadata["process_id"]
|
||||
polled = await provider.poll_process(users[0], process_id)
|
||||
for _ in range(20):
|
||||
if not polled.metadata.get("running"):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
polled = await provider.poll_process(users[0], process_id)
|
||||
assert not polled.metadata.get("running")
|
||||
assert polled.exit_code == 0
|
||||
assert "background-done" in polled.output
|
||||
|
||||
container = client.containers.get(workspace_ref(users[0]).container_name)
|
||||
container.reload()
|
||||
second_container = client.containers.get(workspace_ref(users[1]).container_name)
|
||||
second_container.reload()
|
||||
assert container.attrs["Config"]["User"] == "1000:1000"
|
||||
assert container.attrs["HostConfig"]["ReadonlyRootfs"] is True
|
||||
assert container.attrs["HostConfig"]["CapDrop"] == ["ALL"]
|
||||
assert container.attrs["HostConfig"]["PidsLimit"] == settings.workspace_pids_limit
|
||||
assert set(container.attrs["NetworkSettings"]["Networks"]) == {workspace_ref(users[0]).network_name}
|
||||
assert set(second_container.attrs["NetworkSettings"]["Networks"]) == {workspace_ref(users[1]).network_name}
|
||||
finally:
|
||||
for user in users:
|
||||
ref = workspace_ref(user)
|
||||
try:
|
||||
client.containers.get(ref.container_name).remove(force=True)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
try:
|
||||
client.volumes.get(ref.volume_name).remove(force=True)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
try:
|
||||
client.networks.get(ref.network_name).remove()
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
@@ -1,179 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.execution_registry import (
|
||||
ExecutionRegistry,
|
||||
MirroredCommand,
|
||||
MirroredTool,
|
||||
build_execution_registry,
|
||||
)
|
||||
from src.commands import PORTED_COMMANDS
|
||||
from src.tools import PORTED_TOOLS
|
||||
|
||||
|
||||
class TestBuildExecutionRegistry(unittest.TestCase):
|
||||
"""Tests for build_execution_registry and basic registry properties."""
|
||||
|
||||
def test_returns_execution_registry(self) -> None:
|
||||
registry = build_execution_registry()
|
||||
self.assertIsInstance(registry, ExecutionRegistry)
|
||||
|
||||
def test_has_non_empty_commands(self) -> None:
|
||||
registry = build_execution_registry()
|
||||
self.assertGreater(len(registry.commands), 0)
|
||||
|
||||
def test_has_non_empty_tools(self) -> None:
|
||||
registry = build_execution_registry()
|
||||
self.assertGreater(len(registry.tools), 0)
|
||||
|
||||
def test_command_count_matches_ported_commands(self) -> None:
|
||||
registry = build_execution_registry()
|
||||
self.assertEqual(len(registry.commands), len(PORTED_COMMANDS))
|
||||
|
||||
def test_tool_count_matches_ported_tools(self) -> None:
|
||||
registry = build_execution_registry()
|
||||
self.assertEqual(len(registry.tools), len(PORTED_TOOLS))
|
||||
|
||||
|
||||
class TestCommandLookup(unittest.TestCase):
|
||||
"""Tests for ExecutionRegistry.command() lookup."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.registry = build_execution_registry()
|
||||
self.known_name = self.registry.commands[0].name
|
||||
|
||||
def test_lookup_exact_case(self) -> None:
|
||||
result = self.registry.command(self.known_name)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_lookup_case_insensitive_lower(self) -> None:
|
||||
result = self.registry.command(self.known_name.lower())
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_lookup_case_insensitive_upper(self) -> None:
|
||||
result = self.registry.command(self.known_name.upper())
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_lookup_case_insensitive_mixed(self) -> None:
|
||||
mixed = ''.join(
|
||||
c.upper() if i % 2 else c.lower()
|
||||
for i, c in enumerate(self.known_name)
|
||||
)
|
||||
result = self.registry.command(mixed)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_returns_none_for_unknown_name(self) -> None:
|
||||
result = self.registry.command('__nonexistent_command_xyz__')
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestToolLookup(unittest.TestCase):
|
||||
"""Tests for ExecutionRegistry.tool() lookup."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.registry = build_execution_registry()
|
||||
self.known_name = self.registry.tools[0].name
|
||||
|
||||
def test_lookup_exact_case(self) -> None:
|
||||
result = self.registry.tool(self.known_name)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_lookup_case_insensitive_lower(self) -> None:
|
||||
result = self.registry.tool(self.known_name.lower())
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_lookup_case_insensitive_upper(self) -> None:
|
||||
result = self.registry.tool(self.known_name.upper())
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_lookup_case_insensitive_mixed(self) -> None:
|
||||
mixed = ''.join(
|
||||
c.upper() if i % 2 else c.lower()
|
||||
for i, c in enumerate(self.known_name)
|
||||
)
|
||||
result = self.registry.tool(mixed)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.name, self.known_name)
|
||||
|
||||
def test_returns_none_for_unknown_name(self) -> None:
|
||||
result = self.registry.tool('__nonexistent_tool_xyz__')
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestMirroredCommand(unittest.TestCase):
|
||||
"""Tests for MirroredCommand dataclass."""
|
||||
|
||||
def test_has_correct_name(self) -> None:
|
||||
cmd = MirroredCommand(name='review', source_hint='copilot')
|
||||
self.assertEqual(cmd.name, 'review')
|
||||
|
||||
def test_has_correct_source_hint(self) -> None:
|
||||
cmd = MirroredCommand(name='review', source_hint='copilot')
|
||||
self.assertEqual(cmd.source_hint, 'copilot')
|
||||
|
||||
def test_is_frozen(self) -> None:
|
||||
cmd = MirroredCommand(name='review', source_hint='copilot')
|
||||
with self.assertRaises(AttributeError):
|
||||
cmd.name = 'other' # type: ignore[misc]
|
||||
|
||||
def test_execute_returns_string(self) -> None:
|
||||
registry = build_execution_registry()
|
||||
cmd = registry.commands[0]
|
||||
result = cmd.execute('test prompt')
|
||||
self.assertIsInstance(result, str)
|
||||
self.assertIn('Mirrored command', result)
|
||||
|
||||
|
||||
class TestMirroredTool(unittest.TestCase):
|
||||
"""Tests for MirroredTool dataclass."""
|
||||
|
||||
def test_has_correct_name(self) -> None:
|
||||
tool = MirroredTool(name='BashTool', source_hint='vscode')
|
||||
self.assertEqual(tool.name, 'BashTool')
|
||||
|
||||
def test_has_correct_source_hint(self) -> None:
|
||||
tool = MirroredTool(name='BashTool', source_hint='vscode')
|
||||
self.assertEqual(tool.source_hint, 'vscode')
|
||||
|
||||
def test_is_frozen(self) -> None:
|
||||
tool = MirroredTool(name='BashTool', source_hint='vscode')
|
||||
with self.assertRaises(AttributeError):
|
||||
tool.name = 'other' # type: ignore[misc]
|
||||
|
||||
def test_execute_returns_string(self) -> None:
|
||||
registry = build_execution_registry()
|
||||
tool = registry.tools[0]
|
||||
result = tool.execute('test payload')
|
||||
self.assertIsInstance(result, str)
|
||||
self.assertIn('Mirrored tool', result)
|
||||
|
||||
|
||||
class TestEmptyRegistry(unittest.TestCase):
|
||||
"""Tests for an empty ExecutionRegistry."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.registry = ExecutionRegistry(commands=(), tools=())
|
||||
|
||||
def test_command_returns_none(self) -> None:
|
||||
self.assertIsNone(self.registry.command('anything'))
|
||||
|
||||
def test_tool_returns_none(self) -> None:
|
||||
self.assertIsNone(self.registry.tool('anything'))
|
||||
|
||||
def test_empty_commands_tuple(self) -> None:
|
||||
self.assertEqual(len(self.registry.commands), 0)
|
||||
|
||||
def test_empty_tools_tuple(self) -> None:
|
||||
self.assertEqual(len(self.registry.tools), 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,365 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||
from src.lsp_runtime import LSPRuntime
|
||||
|
||||
|
||||
class ExtendedToolTests(unittest.TestCase):
|
||||
def test_web_fetch_rejects_file_url(self) -> None:
|
||||
"""Verify that file:// URLs are blocked to prevent SSRF attacks."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
target = workspace / 'page.txt'
|
||||
target.write_text('hello from web fetch\n', encoding='utf-8')
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
tool_registry=default_tool_registry(),
|
||||
)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'web_fetch',
|
||||
{'url': target.resolve().as_uri()},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn('http or https', result.content)
|
||||
|
||||
def test_tool_search_lists_matching_tools(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
tool_registry=registry,
|
||||
)
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'tool_search',
|
||||
{'query': 'file'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('# Tool Search', result.content)
|
||||
self.assertIn('read_file', result.content)
|
||||
self.assertIn('write_file', result.content)
|
||||
|
||||
def test_grep_search_skips_generated_dirs_by_default(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.next' / 'static').mkdir(parents=True)
|
||||
(workspace / '.next' / 'static' / 'bundle.js').write_text(
|
||||
'router_session_parquet should not be searched by default\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
(workspace / 'README.md').write_text(
|
||||
'router_session_parquet is documented here\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
tool_registry=registry,
|
||||
)
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'grep_search',
|
||||
{'pattern': 'router_session_parquet'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('README.md:1:', result.content)
|
||||
self.assertNotIn('.next/static/bundle.js', result.content)
|
||||
|
||||
def test_grep_search_truncates_very_long_lines(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
long_line = 'prefix needle ' + ('x' * 5000)
|
||||
(workspace / 'large.txt').write_text(long_line, encoding='utf-8')
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace, max_output_chars=1200),
|
||||
tool_registry=registry,
|
||||
)
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'grep_search',
|
||||
{'pattern': 'needle'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('large.txt:1:', result.content)
|
||||
self.assertIn('[line truncated,', result.content)
|
||||
self.assertLess(len(result.content), 1200)
|
||||
|
||||
def test_read_file_can_read_explicit_external_path(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as workspace_dir, tempfile.TemporaryDirectory() as external_dir:
|
||||
external = Path(external_dir) / 'reference.txt'
|
||||
external.write_text('external reference\n', encoding='utf-8')
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=Path(workspace_dir)),
|
||||
tool_registry=registry,
|
||||
)
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'read_file',
|
||||
{'path': str(external)},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('external reference', result.content)
|
||||
|
||||
def test_write_file_blocks_platform_code_paths(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'src').mkdir()
|
||||
(workspace / 'src' / 'agent_tools.py').write_text('', encoding='utf-8')
|
||||
(workspace / 'backend' / 'api').mkdir(parents=True)
|
||||
(workspace / 'backend' / 'api' / 'server.py').write_text('', encoding='utf-8')
|
||||
(workspace / 'frontend' / 'app').mkdir(parents=True)
|
||||
scratchpad = (
|
||||
workspace
|
||||
/ '.port_sessions'
|
||||
/ 'accounts'
|
||||
/ 'user'
|
||||
/ 'sessions'
|
||||
/ 'thread'
|
||||
/ 'scratchpad'
|
||||
)
|
||||
scratchpad.mkdir(parents=True)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
scratchpad_directory=scratchpad,
|
||||
tool_registry=registry,
|
||||
)
|
||||
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{'path': 'src/new_file.py', 'content': 'print(1)\n'},
|
||||
context,
|
||||
)
|
||||
output_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{
|
||||
'path': '.port_sessions/accounts/user/sessions/thread/output/report.txt',
|
||||
'content': 'ok\n',
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata.get('error_kind'), 'permission_denied')
|
||||
self.assertTrue(output_result.ok)
|
||||
|
||||
def test_logical_session_paths_route_to_current_session(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
scratchpad = (
|
||||
workspace
|
||||
/ '.port_sessions'
|
||||
/ 'accounts'
|
||||
/ 'user'
|
||||
/ 'sessions'
|
||||
/ 'thread'
|
||||
/ 'scratchpad'
|
||||
)
|
||||
scratchpad.mkdir(parents=True)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
scratchpad_directory=scratchpad,
|
||||
tool_registry=registry,
|
||||
)
|
||||
write_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{'path': 'output/report.md', 'content': 'session report\n'},
|
||||
context,
|
||||
)
|
||||
read_result = execute_tool(
|
||||
registry,
|
||||
'read_file',
|
||||
{'path': 'output/report.md'},
|
||||
context,
|
||||
)
|
||||
session_file_exists = (scratchpad.parent / 'output' / 'report.md').is_file()
|
||||
root_file_exists = (workspace / 'output' / 'report.md').exists()
|
||||
|
||||
self.assertTrue(write_result.ok, write_result.content)
|
||||
self.assertTrue(read_result.ok, read_result.content)
|
||||
self.assertIn('session report', read_result.content)
|
||||
self.assertTrue(session_file_exists)
|
||||
self.assertFalse(root_file_exists)
|
||||
|
||||
def test_write_file_serializes_structured_payloads(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
scratchpad = workspace / '.port_sessions' / 'accounts' / 'user' / 'sessions' / 'thread' / 'scratchpad'
|
||||
scratchpad.mkdir(parents=True)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
scratchpad_directory=scratchpad,
|
||||
tool_registry=registry,
|
||||
)
|
||||
json_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{
|
||||
'path': 'output/data.json',
|
||||
'json_content': {'query': '导航到公司', 'target': 'Agent(tag="地图导航")'},
|
||||
'newline_at_end': True,
|
||||
},
|
||||
context,
|
||||
)
|
||||
jsonl_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{
|
||||
'path': 'output/data.jsonl',
|
||||
'jsonl_records': [
|
||||
{'query': '附近的奶茶', 'target': 'Agent(tag="餐饮服务")'},
|
||||
{'query': '导航去海底捞', 'target': 'Agent(tag="地图导航")'},
|
||||
],
|
||||
'newline_at_end': True,
|
||||
},
|
||||
context,
|
||||
)
|
||||
csv_result = execute_tool(
|
||||
registry,
|
||||
'write_file',
|
||||
{
|
||||
'path': 'output/data.csv',
|
||||
'csv_headers': ['query', 'target'],
|
||||
'csv_rows': [
|
||||
['附近的奶茶', 'Agent(tag="餐饮服务")'],
|
||||
['导航去海底捞', 'Agent(tag="地图导航")'],
|
||||
],
|
||||
'newline_at_end': True,
|
||||
},
|
||||
context,
|
||||
)
|
||||
output_dir = scratchpad.parent / 'output'
|
||||
json_text = (output_dir / 'data.json').read_text(encoding='utf-8')
|
||||
jsonl_text = (output_dir / 'data.jsonl').read_text(encoding='utf-8')
|
||||
csv_text = (output_dir / 'data.csv').read_text(encoding='utf-8')
|
||||
|
||||
self.assertTrue(json_result.ok, json_result.content)
|
||||
self.assertTrue(jsonl_result.ok, jsonl_result.content)
|
||||
self.assertTrue(csv_result.ok, csv_result.content)
|
||||
self.assertIn('"query": "导航到公司"', json_text)
|
||||
self.assertEqual(
|
||||
jsonl_text.splitlines()[0],
|
||||
'{"query":"附近的奶茶","target":"Agent(tag=\\"餐饮服务\\")"}',
|
||||
)
|
||||
self.assertIn('query,target', csv_text)
|
||||
|
||||
def test_sleep_tool_waits_briefly_and_returns_metadata(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
tool_registry=registry,
|
||||
)
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'sleep',
|
||||
{'seconds': 0.01},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('slept for', result.content)
|
||||
self.assertEqual(result.metadata.get('action'), 'sleep')
|
||||
|
||||
def test_notebook_edit_updates_ipynb_cell(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
notebook = workspace / 'demo.ipynb'
|
||||
notebook.write_text(
|
||||
'{\n'
|
||||
' "cells": [\n'
|
||||
' {"cell_type": "code", "metadata": {}, "source": ["print(1)\\n"], "outputs": [], "execution_count": null}\n'
|
||||
' ],\n'
|
||||
' "metadata": {},\n'
|
||||
' "nbformat": 4,\n'
|
||||
' "nbformat_minor": 5\n'
|
||||
'}\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
tool_registry=registry,
|
||||
)
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'notebook_edit',
|
||||
{'path': 'demo.ipynb', 'cell_index': 0, 'source': 'print(2)\n'},
|
||||
context,
|
||||
)
|
||||
updated = notebook.read_text(encoding='utf-8')
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('updated notebook cell 0', result.content)
|
||||
self.assertIn('print(2)', updated)
|
||||
self.assertEqual(result.metadata.get('action'), 'notebook_edit')
|
||||
|
||||
def test_lsp_tool_returns_definition_report(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'sample.py').write_text(
|
||||
'def helper(value):\n'
|
||||
' return value * 2\n'
|
||||
'\n'
|
||||
'def run(item):\n'
|
||||
' return helper(item)\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
tool_registry=registry,
|
||||
lsp_runtime=LSPRuntime.from_workspace(workspace),
|
||||
)
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'LSP',
|
||||
{
|
||||
'operation': 'goToDefinition',
|
||||
'file_path': 'sample.py',
|
||||
'line': 5,
|
||||
'character': 12,
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('# LSP Definition', result.content)
|
||||
self.assertIn('function helper', result.content)
|
||||
self.assertEqual(result.metadata.get('action'), 'lsp_query')
|
||||
self.assertEqual(result.metadata.get('operation'), 'goToDefinition')
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Tests for the informational slash commands ported from the npm source.
|
||||
|
||||
Covers /output-style, /release-notes, /feedback, /upgrade, /stickers, /mobile,
|
||||
/desktop, /install-github-app, /install-slack-app, /privacy-settings,
|
||||
/extra-usage, /passes, /rate-limit-options, /chrome, /reload-plugins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
|
||||
|
||||
def _make_agent(tmp_dir: str) -> LocalCodingAgent:
|
||||
return LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
|
||||
|
||||
class ExternalSlashCommandsTest(unittest.TestCase):
|
||||
"""Each test runs with CLAUDE_CODE_NO_BROWSER=1 so no browser opens."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
os.environ['CLAUDE_CODE_NO_BROWSER'] = '1'
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop('CLAUDE_CODE_NO_BROWSER', None)
|
||||
|
||||
def _run(self, cmd: str) -> str:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
return agent.run(cmd).final_output
|
||||
|
||||
def test_output_style_is_deprecated(self) -> None:
|
||||
out = self._run('/output-style')
|
||||
self.assertIn('deprecated', out.lower())
|
||||
self.assertIn('/config', out)
|
||||
|
||||
def test_release_notes_falls_back_to_link(self) -> None:
|
||||
out = self._run('/release-notes')
|
||||
self.assertIn('CHANGELOG.md', out)
|
||||
self.assertIn('https://github.com/anthropics/claude-code', out)
|
||||
|
||||
def test_release_notes_reads_local_changelog(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / 'CHANGELOG.md').write_text(
|
||||
'# Changelog\n\n## 1.2.3\n- did a thing\n\n## 1.2.2\n- old\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/release-notes').final_output
|
||||
self.assertIn('1.2.3', out)
|
||||
self.assertIn('did a thing', out)
|
||||
self.assertNotIn('1.2.2', out)
|
||||
|
||||
def test_feedback_returns_link(self) -> None:
|
||||
out = self._run('/feedback')
|
||||
self.assertIn('https://github.com/anthropics/claude-code/issues', out)
|
||||
|
||||
def test_bug_aliases_to_feedback(self) -> None:
|
||||
out = self._run('/bug')
|
||||
self.assertIn('https://github.com/anthropics/claude-code/issues', out)
|
||||
|
||||
def test_feedback_includes_user_note(self) -> None:
|
||||
out = self._run('/feedback the wrap selector keeps eating my prompt')
|
||||
self.assertIn('Draft note', out)
|
||||
self.assertIn('wrap selector', out)
|
||||
|
||||
def test_upgrade_returns_link(self) -> None:
|
||||
out = self._run('/upgrade')
|
||||
self.assertIn('https://claude.ai/upgrade/max', out)
|
||||
|
||||
def test_stickers_returns_link(self) -> None:
|
||||
out = self._run('/stickers')
|
||||
self.assertIn('stickermule.com/claudecode', out)
|
||||
|
||||
def test_mobile_lists_both_stores(self) -> None:
|
||||
out = self._run('/mobile')
|
||||
self.assertIn('apps.apple.com', out)
|
||||
self.assertIn('play.google.com', out)
|
||||
|
||||
def test_ios_alias(self) -> None:
|
||||
out = self._run('/ios')
|
||||
self.assertIn('apps.apple.com', out)
|
||||
|
||||
def test_android_alias(self) -> None:
|
||||
out = self._run('/android')
|
||||
self.assertIn('play.google.com', out)
|
||||
|
||||
def test_desktop_returns_link(self) -> None:
|
||||
out = self._run('/desktop')
|
||||
self.assertIn('claude.ai/download', out)
|
||||
|
||||
def test_app_aliases_to_desktop(self) -> None:
|
||||
out = self._run('/app')
|
||||
self.assertIn('claude.ai/download', out)
|
||||
|
||||
def test_install_github_app(self) -> None:
|
||||
out = self._run('/install-github-app')
|
||||
self.assertIn('github.com/apps/claude', out)
|
||||
|
||||
def test_install_slack_app(self) -> None:
|
||||
out = self._run('/install-slack-app')
|
||||
self.assertIn('slack.com/marketplace/A08SF47R6P4-claude', out)
|
||||
|
||||
def test_privacy_settings(self) -> None:
|
||||
out = self._run('/privacy-settings')
|
||||
self.assertIn('claude.ai/settings/data-privacy-controls', out)
|
||||
|
||||
def test_extra_usage_points_to_upgrade(self) -> None:
|
||||
out = self._run('/extra-usage')
|
||||
self.assertIn('claude.ai/upgrade/max', out)
|
||||
self.assertIn('/login', out)
|
||||
|
||||
def test_passes_mentions_claude_ai(self) -> None:
|
||||
out = self._run('/passes')
|
||||
self.assertIn('claude.ai', out.lower())
|
||||
self.assertIn('passes', out.lower())
|
||||
|
||||
def test_rate_limit_options_lists_actions(self) -> None:
|
||||
out = self._run('/rate-limit-options')
|
||||
self.assertIn('/upgrade', out)
|
||||
self.assertIn('/extra-usage', out)
|
||||
self.assertIn('/login', out)
|
||||
|
||||
def test_chrome_returns_link(self) -> None:
|
||||
out = self._run('/chrome')
|
||||
self.assertIn('claude.ai/chrome', out)
|
||||
|
||||
def test_reload_plugins_reports_counts(self) -> None:
|
||||
out = self._run('/reload-plugins')
|
||||
self.assertIn('Reloaded plugins', out)
|
||||
self.assertIn('plugin(s)', out)
|
||||
self.assertIn('tool(s)', out)
|
||||
self.assertIn('hook(s)', out)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,291 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import openpyxl
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api import server as gui_server
|
||||
from backend.api.server import AgentState, create_app
|
||||
|
||||
|
||||
def _build_client(tmp: Path) -> tuple[TestClient, AgentState]:
|
||||
state = AgentState(
|
||||
cwd=tmp,
|
||||
model='test-model',
|
||||
base_url='http://127.0.0.1:8000/v1',
|
||||
api_key='local-token',
|
||||
timeout_seconds=120.0,
|
||||
allow_shell=False,
|
||||
allow_write=False,
|
||||
session_directory=tmp / 'sessions',
|
||||
)
|
||||
return TestClient(create_app(state)), state
|
||||
|
||||
|
||||
class FeishuIntegrationTests(unittest.TestCase):
|
||||
def test_convert_xlsx_and_docx_to_markdown(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
xlsx_path = root / '样例.xlsx'
|
||||
docx_path = root / '说明.docx'
|
||||
_write_xlsx(xlsx_path)
|
||||
_write_docx(docx_path)
|
||||
|
||||
xlsx_markdown = gui_server._convert_file_to_feishu_markdown(
|
||||
xlsx_path,
|
||||
title='表格样例',
|
||||
)
|
||||
docx_markdown = gui_server._convert_file_to_feishu_markdown(
|
||||
docx_path,
|
||||
title='文档样例',
|
||||
)
|
||||
|
||||
self.assertIn('| query | label |', xlsx_markdown)
|
||||
self.assertIn('| 怎么去公司 | Agent(tag="地图导航") |', xlsx_markdown)
|
||||
self.assertIn('产品定义说明', docx_markdown)
|
||||
self.assertIn('| 功能点 | 示例 |', docx_markdown)
|
||||
|
||||
def test_create_online_doc_requires_feishu_login(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(state, 'alice', 'draft.md', '# hello')
|
||||
|
||||
with patch.object(
|
||||
gui_server,
|
||||
'_feishu_status_payload',
|
||||
return_value={'logged_in': False, 'status': 'not_logged_in'},
|
||||
):
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()['detail']['code'], 'feishu_not_logged_in')
|
||||
|
||||
def test_create_online_doc_calls_feishu_mcp(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(state, 'alice', 'draft.md', '# hello')
|
||||
|
||||
with patch.object(
|
||||
gui_server,
|
||||
'_feishu_status_payload',
|
||||
return_value={'logged_in': True, 'status': 'logged_in'},
|
||||
), patch.object(
|
||||
gui_server.MCPRuntime,
|
||||
'call_tool',
|
||||
return_value=(
|
||||
'{"url":"https://mi.feishu.cn/docx/example"}',
|
||||
{'server_name': 'feishu-mcp-pro', 'tool_name': 'doc_create'},
|
||||
),
|
||||
) as call_tool:
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload['url'], 'https://mi.feishu.cn/docx/example')
|
||||
call_tool.assert_called_once()
|
||||
self.assertEqual(call_tool.call_args.args[0], 'doc_create')
|
||||
map_path = (
|
||||
state.account_paths('alice')['base']
|
||||
/ 'integrations'
|
||||
/ 'feishu'
|
||||
/ 'online-docs.json'
|
||||
)
|
||||
online_docs = json.loads(map_path.read_text(encoding='utf-8'))
|
||||
self.assertEqual(
|
||||
online_docs['files'][str(file_path)]['url'],
|
||||
'https://mi.feishu.cn/docx/example',
|
||||
)
|
||||
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'doc')
|
||||
|
||||
def test_create_online_doc_converts_csv_to_feishu_sheet(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(
|
||||
state,
|
||||
'alice',
|
||||
'records.csv',
|
||||
'query,label\n导航到公司,"Agent(tag=""地图导航"")"\n',
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
gui_server,
|
||||
'_feishu_status_payload',
|
||||
return_value={'logged_in': True, 'status': 'logged_in'},
|
||||
), patch.object(
|
||||
gui_server.MCPRuntime,
|
||||
'call_tool',
|
||||
side_effect=[
|
||||
(
|
||||
'{"url":"https://mi.feishu.cn/sheets/shtcn123","spreadsheet_token":"shtcn123"}',
|
||||
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
|
||||
),
|
||||
(
|
||||
'{"ok":true}',
|
||||
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
|
||||
),
|
||||
],
|
||||
) as call_tool:
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload['kind'], 'sheet')
|
||||
self.assertEqual(payload['url'], 'https://mi.feishu.cn/sheets/shtcn123')
|
||||
self.assertEqual(call_tool.call_count, 2)
|
||||
self.assertEqual(call_tool.call_args_list[0].args[0], 'sheet_ops')
|
||||
self.assertEqual(call_tool.call_args_list[0].kwargs['arguments']['action'], 'create')
|
||||
self.assertEqual(call_tool.call_args_list[1].kwargs['arguments']['action'], 'write')
|
||||
write_params = call_tool.call_args_list[1].kwargs['arguments']['params']
|
||||
self.assertEqual(write_params['range'], 'Sheet1!A1:B2')
|
||||
self.assertEqual(
|
||||
json.loads(write_params['values']),
|
||||
[['query', 'label'], ['导航到公司', 'Agent(tag="地图导航")']],
|
||||
)
|
||||
map_path = (
|
||||
state.account_paths('alice')['base']
|
||||
/ 'integrations'
|
||||
/ 'feishu'
|
||||
/ 'online-docs.json'
|
||||
)
|
||||
online_docs = json.loads(map_path.read_text(encoding='utf-8'))
|
||||
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'sheet')
|
||||
|
||||
def test_create_online_doc_surfaces_feishu_sheet_write_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(
|
||||
state,
|
||||
'alice',
|
||||
'records.csv',
|
||||
'query,label\n导航到公司,地图导航\n',
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
gui_server,
|
||||
'_feishu_status_payload',
|
||||
return_value={'logged_in': True, 'status': 'logged_in'},
|
||||
), patch.object(
|
||||
gui_server.MCPRuntime,
|
||||
'call_tool',
|
||||
side_effect=[
|
||||
(
|
||||
'{"url":"https://mi.feishu.cn/sheets/shtcn123","spreadsheet_token":"shtcn123"}',
|
||||
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
|
||||
),
|
||||
(
|
||||
'{"error":"Sheet not found","code":123}',
|
||||
{'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'},
|
||||
),
|
||||
],
|
||||
):
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Sheet not found', response.json()['detail'])
|
||||
|
||||
def test_extract_first_url_ignores_wrapping_quotes(self) -> None:
|
||||
url = gui_server._extract_first_url(
|
||||
'{"url":"https://mi.feishu.cn/docx/CZIldpM3QofbbXxxAq1cEininTd"}'
|
||||
)
|
||||
|
||||
self.assertEqual(url, 'https://mi.feishu.cn/docx/CZIldpM3QofbbXxxAq1cEininTd')
|
||||
|
||||
def test_feishu_command_uses_deployed_node_bin_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
gui_server.os.environ,
|
||||
{'CLAW_NODE_BIN_DIR': tmp_dir, 'PATH': ''},
|
||||
clear=False,
|
||||
):
|
||||
npx_path = Path(tmp_dir) / 'npx'
|
||||
npx_path.write_text('#!/usr/bin/env node\n', encoding='utf-8')
|
||||
npx_path.chmod(0o755)
|
||||
|
||||
command = gui_server._feishu_command()
|
||||
env = gui_server._feishu_env(
|
||||
{
|
||||
'home': Path(tmp_dir) / 'home',
|
||||
'config': Path(tmp_dir) / 'config',
|
||||
'npm_cache': Path(tmp_dir) / 'npm-cache',
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(command[0], str(npx_path))
|
||||
self.assertTrue(env['PATH'].startswith(tmp_dir))
|
||||
|
||||
def test_create_online_doc_rejects_jsonl_for_now(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(state, 'alice', 'records.jsonl', '{}\n')
|
||||
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('json/jsonl', response.json()['detail'])
|
||||
|
||||
|
||||
def _write_account_file(
|
||||
state: AgentState,
|
||||
account_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
) -> Path:
|
||||
path = state.account_paths(account_id)['sessions'] / 's1' / 'output' / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding='utf-8')
|
||||
return path
|
||||
|
||||
|
||||
def _write_xlsx(path: Path) -> None:
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = 'Sheet1'
|
||||
sheet.append(['query', 'label'])
|
||||
sheet.append(['怎么去公司', 'Agent(tag="地图导航")'])
|
||||
workbook.save(path)
|
||||
|
||||
|
||||
def _write_docx(path: Path) -> None:
|
||||
document_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>产品定义说明</w:t></w:r></w:p>
|
||||
<w:tbl>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>功能点</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>示例</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>导航</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>导航到公司</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
</w:body>
|
||||
</w:document>
|
||||
'''
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('word/document.xml', document_xml)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Tests for ``src/format_utils.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.format_utils import (
|
||||
format_duration,
|
||||
format_file_size,
|
||||
format_number,
|
||||
format_seconds_short,
|
||||
format_tokens,
|
||||
)
|
||||
|
||||
|
||||
class FormatFileSizeTest(unittest.TestCase):
|
||||
def test_bytes(self) -> None:
|
||||
self.assertEqual(format_file_size(0), '0 bytes')
|
||||
self.assertEqual(format_file_size(512), '512 bytes')
|
||||
|
||||
def test_kb_with_decimal(self) -> None:
|
||||
self.assertEqual(format_file_size(1536), '1.5KB')
|
||||
|
||||
def test_kb_trims_trailing_zero(self) -> None:
|
||||
self.assertEqual(format_file_size(2048), '2KB')
|
||||
|
||||
def test_mb(self) -> None:
|
||||
self.assertEqual(format_file_size(5 * 1024 * 1024), '5MB')
|
||||
|
||||
def test_gb(self) -> None:
|
||||
self.assertEqual(format_file_size(2 * 1024 * 1024 * 1024), '2GB')
|
||||
|
||||
|
||||
class FormatSecondsShortTest(unittest.TestCase):
|
||||
def test_basic(self) -> None:
|
||||
self.assertEqual(format_seconds_short(1234), '1.2s')
|
||||
|
||||
def test_under_one(self) -> None:
|
||||
self.assertEqual(format_seconds_short(450), '0.5s')
|
||||
|
||||
|
||||
class FormatDurationTest(unittest.TestCase):
|
||||
def test_zero(self) -> None:
|
||||
self.assertEqual(format_duration(0), '0s')
|
||||
|
||||
def test_sub_second(self) -> None:
|
||||
self.assertEqual(format_duration(0.5), '0.0s')
|
||||
|
||||
def test_seconds_only(self) -> None:
|
||||
self.assertEqual(format_duration(5_000), '5s')
|
||||
|
||||
def test_minutes_seconds(self) -> None:
|
||||
self.assertEqual(format_duration(125_000), '2m 5s')
|
||||
|
||||
def test_hours(self) -> None:
|
||||
self.assertEqual(format_duration(3_725_000), '1h 2m 5s')
|
||||
|
||||
def test_days(self) -> None:
|
||||
# 1d 2h 3m
|
||||
ms = 86_400_000 + 2 * 3_600_000 + 3 * 60_000 + 0
|
||||
self.assertEqual(format_duration(ms), '1d 2h 3m')
|
||||
|
||||
def test_hide_trailing_zeros_minutes(self) -> None:
|
||||
self.assertEqual(
|
||||
format_duration(120_000, hide_trailing_zeros=True), '2m',
|
||||
)
|
||||
|
||||
def test_hide_trailing_zeros_hours(self) -> None:
|
||||
self.assertEqual(
|
||||
format_duration(3_600_000, hide_trailing_zeros=True), '1h',
|
||||
)
|
||||
|
||||
def test_most_significant_only_picks_largest_unit(self) -> None:
|
||||
self.assertEqual(format_duration(125_000, most_significant_only=True), '2m')
|
||||
self.assertEqual(
|
||||
format_duration(3_725_000, most_significant_only=True), '1h',
|
||||
)
|
||||
|
||||
def test_rounding_carry_over(self) -> None:
|
||||
# 59,500 ms rounds seconds=60 → carries to 1m 0s
|
||||
self.assertEqual(format_duration(59_500 + 60_000), '2m 0s')
|
||||
|
||||
|
||||
class FormatNumberTest(unittest.TestCase):
|
||||
def test_below_thousand(self) -> None:
|
||||
self.assertEqual(format_number(900), '900')
|
||||
self.assertEqual(format_number(0), '0')
|
||||
|
||||
def test_thousands_with_decimal(self) -> None:
|
||||
self.assertEqual(format_number(1321), '1.3k')
|
||||
|
||||
def test_thousand_keeps_decimal(self) -> None:
|
||||
self.assertEqual(format_number(1000), '1.0k')
|
||||
|
||||
def test_millions(self) -> None:
|
||||
self.assertEqual(format_number(2_500_000), '2.5m')
|
||||
|
||||
def test_billions(self) -> None:
|
||||
self.assertEqual(format_number(3_700_000_000), '3.7b')
|
||||
|
||||
|
||||
class FormatTokensTest(unittest.TestCase):
|
||||
def test_trims_decimal_zero(self) -> None:
|
||||
self.assertEqual(format_tokens(1000), '1k')
|
||||
|
||||
def test_keeps_meaningful_decimal(self) -> None:
|
||||
self.assertEqual(format_tokens(1321), '1.3k')
|
||||
|
||||
def test_below_thousand(self) -> None:
|
||||
self.assertEqual(format_tokens(450), '450')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent_platform.gateway.app import create_app
|
||||
from agent_platform.gateway.provider import normalize_workspace_path, workspace_ref
|
||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceStatus
|
||||
|
||||
|
||||
def test_workspace_paths_cannot_escape() -> None:
|
||||
assert str(normalize_workspace_path("src/app.py")) == "src/app.py"
|
||||
assert str(normalize_workspace_path("/workspace/src/app.py")) == "src/app.py"
|
||||
for value in ("../secret", "/etc/passwd", "a/../../b", "bad\x00name"):
|
||||
with pytest.raises(ValueError):
|
||||
normalize_workspace_path(value)
|
||||
|
||||
|
||||
def test_workspace_identity_is_stable_and_separate() -> None:
|
||||
first = workspace_ref("user-1")
|
||||
assert first == workspace_ref("user-1")
|
||||
assert first != workspace_ref("user-2")
|
||||
assert "user-1" not in first.container_name
|
||||
assert "user-1" not in first.network_name
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
provider_name = "local-docker"
|
||||
|
||||
async def status(self, user_id: str) -> WorkspaceStatus:
|
||||
return WorkspaceStatus(
|
||||
workspace_id=workspace_ref(user_id).workspace_id,
|
||||
provider="local-docker",
|
||||
container_name=workspace_ref(user_id).container_name,
|
||||
state="running",
|
||||
)
|
||||
|
||||
async def list_files(self, user_id, path, max_depth, limit):
|
||||
return ToolResult(ok=True, output=f"{user_id}:{path}")
|
||||
|
||||
async def read_file(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def write_file(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def search_files(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def exec(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def apply_patch(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def start_process(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def poll_process(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def cancel_process(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
|
||||
def test_gateway_requires_service_key_and_signed_identity(settings, identity_jwt) -> None:
|
||||
app = create_app(settings, provider=FakeProvider())
|
||||
with TestClient(app) as client:
|
||||
assert client.post("/v1/tools/list_files", json={}).status_code == 401
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.internal_gateway_key}",
|
||||
"X-OpenWebUI-User-Jwt": identity_jwt,
|
||||
}
|
||||
response = client.post("/v1/tools/list_files", headers=headers, json={"path": "src"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["output"] == "user-123:src"
|
||||
|
||||
|
||||
def test_gateway_openapi_does_not_expose_auth_headers(settings) -> None:
|
||||
app = create_app(settings, provider=FakeProvider())
|
||||
with TestClient(app) as client:
|
||||
spec = client.get("/openapi.json").json()
|
||||
operation = spec["paths"]["/v1/tools/list_files"]["post"]
|
||||
assert all(
|
||||
parameter["name"] not in {"Authorization", "X-OpenWebUI-User-Jwt"}
|
||||
for parameter in operation.get("parameters", [])
|
||||
)
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Tests for ``src/git_utils.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src import git_utils
|
||||
from src.git_utils import (
|
||||
find_git_root,
|
||||
get_repo_remote_hash,
|
||||
normalize_git_remote_url,
|
||||
should_include_git_instructions,
|
||||
)
|
||||
|
||||
|
||||
class FindGitRootTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
find_git_root.cache_clear()
|
||||
|
||||
def test_finds_git_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.git').mkdir()
|
||||
self.assertEqual(find_git_root(tmp), str(Path(tmp).resolve()))
|
||||
|
||||
def test_walks_up_from_subdirectory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.git').mkdir()
|
||||
sub = Path(tmp) / 'a' / 'b' / 'c'
|
||||
sub.mkdir(parents=True)
|
||||
self.assertEqual(find_git_root(str(sub)), str(Path(tmp).resolve()))
|
||||
|
||||
def test_finds_when_git_is_a_file(self) -> None:
|
||||
# Worktrees and submodules use a .git file containing 'gitdir: ...'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.git').write_text('gitdir: /elsewhere/.git/worktrees/x')
|
||||
self.assertEqual(find_git_root(tmp), str(Path(tmp).resolve()))
|
||||
|
||||
def test_returns_none_when_not_in_repo(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertIsNone(find_git_root(tmp))
|
||||
|
||||
def test_memoizes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.git').mkdir()
|
||||
first = find_git_root(tmp)
|
||||
# Now remove .git — second lookup should still hit cache
|
||||
(Path(tmp) / '.git').rmdir()
|
||||
self.assertEqual(find_git_root(tmp), first)
|
||||
|
||||
|
||||
class NormalizeGitRemoteUrlTest(unittest.TestCase):
|
||||
def test_ssh_url(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('git@github.com:org/repo.git'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
def test_ssh_url_no_dot_git_suffix(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('git@github.com:org/repo'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
def test_https_url(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('https://github.com/org/repo.git'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
def test_https_with_user(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('https://user@github.com/org/repo.git'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
def test_ssh_protocol_url(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('ssh://git@github.com/org/repo.git'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
def test_lowercases_result(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('git@GitHub.com:Org/Repo.git'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
def test_ccr_proxy_legacy_assumes_github(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('http://x@127.0.0.1:8080/git/org/repo'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
def test_ccr_proxy_ghe_uses_first_segment_as_host(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url(
|
||||
'http://x@127.0.0.1:8080/git/ghe.example.com/org/repo',
|
||||
),
|
||||
'ghe.example.com/org/repo',
|
||||
)
|
||||
|
||||
def test_returns_none_on_garbage(self) -> None:
|
||||
self.assertIsNone(normalize_git_remote_url(''))
|
||||
self.assertIsNone(normalize_git_remote_url(' '))
|
||||
self.assertIsNone(normalize_git_remote_url('not-a-url'))
|
||||
|
||||
def test_localhost_alias(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_git_remote_url('http://localhost:8080/git/org/repo'),
|
||||
'github.com/org/repo',
|
||||
)
|
||||
|
||||
|
||||
class GetRepoRemoteHashTest(unittest.TestCase):
|
||||
def test_hashes_normalized_url(self) -> None:
|
||||
url = 'git@github.com:Org/Repo.git'
|
||||
normalized = 'github.com/org/repo'
|
||||
expected = hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:16]
|
||||
self.assertEqual(get_repo_remote_hash(url), expected)
|
||||
|
||||
def test_returns_none_for_empty(self) -> None:
|
||||
self.assertIsNone(get_repo_remote_hash(None))
|
||||
self.assertIsNone(get_repo_remote_hash(''))
|
||||
|
||||
def test_returns_none_for_unparseable(self) -> None:
|
||||
self.assertIsNone(get_repo_remote_hash('not-a-url'))
|
||||
|
||||
|
||||
class ShouldIncludeGitInstructionsTest(unittest.TestCase):
|
||||
def test_default_true_when_nothing_set(self) -> None:
|
||||
self.assertTrue(should_include_git_instructions(env={}))
|
||||
|
||||
def test_env_truthy_disables(self) -> None:
|
||||
for value in ('1', 'true', 'yes', 'on'):
|
||||
self.assertFalse(
|
||||
should_include_git_instructions(
|
||||
settings_value=True,
|
||||
env={'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS': value},
|
||||
),
|
||||
)
|
||||
|
||||
def test_env_falsy_overrides_settings(self) -> None:
|
||||
# Settings says off, but env explicitly says don't disable → on
|
||||
self.assertTrue(
|
||||
should_include_git_instructions(
|
||||
settings_value=False,
|
||||
env={'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS': '0'},
|
||||
),
|
||||
)
|
||||
|
||||
def test_settings_value_used_when_env_unset(self) -> None:
|
||||
self.assertFalse(
|
||||
should_include_git_instructions(settings_value=False, env={}),
|
||||
)
|
||||
self.assertTrue(
|
||||
should_include_git_instructions(settings_value=True, env={}),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,133 +0,0 @@
|
||||
"""Tests for ``src/ide_path_conversion.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from src.ide_path_conversion import (
|
||||
WindowsToWSLConverter,
|
||||
check_wsl_distro_match,
|
||||
)
|
||||
|
||||
|
||||
class CheckWslDistroMatchTest(unittest.TestCase):
|
||||
def test_matches_named_distro(self) -> None:
|
||||
self.assertTrue(
|
||||
check_wsl_distro_match(r'\\wsl$\Ubuntu\home\me', 'Ubuntu'),
|
||||
)
|
||||
|
||||
def test_matches_localhost_form(self) -> None:
|
||||
self.assertTrue(
|
||||
check_wsl_distro_match(
|
||||
r'\\wsl.localhost\Ubuntu\home\me', 'Ubuntu',
|
||||
),
|
||||
)
|
||||
|
||||
def test_mismatch(self) -> None:
|
||||
self.assertFalse(
|
||||
check_wsl_distro_match(r'\\wsl$\Debian\home\me', 'Ubuntu'),
|
||||
)
|
||||
|
||||
def test_non_unc_path_returns_true(self) -> None:
|
||||
self.assertTrue(check_wsl_distro_match(r'C:\Users\me', 'Ubuntu'))
|
||||
|
||||
|
||||
class WindowsToWSLConverterToLocalPathTest(unittest.TestCase):
|
||||
def test_empty_path_passthrough(self) -> None:
|
||||
conv = WindowsToWSLConverter('Ubuntu')
|
||||
self.assertEqual(conv.to_local_path(''), '')
|
||||
|
||||
def test_uses_wslpath_when_available(self) -> None:
|
||||
conv = WindowsToWSLConverter(None)
|
||||
with mock.patch(
|
||||
'src.ide_path_conversion.subprocess.run',
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout='/mnt/c/Users/me\n', stderr='',
|
||||
),
|
||||
) as run:
|
||||
self.assertEqual(conv.to_local_path(r'C:\Users\me'), '/mnt/c/Users/me')
|
||||
run.assert_called_once()
|
||||
args = run.call_args[0][0]
|
||||
self.assertEqual(args, ['wslpath', '-u', r'C:\Users\me'])
|
||||
|
||||
def test_falls_back_to_manual_when_wslpath_missing(self) -> None:
|
||||
conv = WindowsToWSLConverter(None)
|
||||
with mock.patch(
|
||||
'src.ide_path_conversion.subprocess.run',
|
||||
side_effect=FileNotFoundError(),
|
||||
):
|
||||
self.assertEqual(
|
||||
conv.to_local_path(r'C:\Users\me'), '/mnt/c/Users/me',
|
||||
)
|
||||
|
||||
def test_falls_back_to_manual_on_called_process_error(self) -> None:
|
||||
conv = WindowsToWSLConverter(None)
|
||||
with mock.patch(
|
||||
'src.ide_path_conversion.subprocess.run',
|
||||
side_effect=subprocess.CalledProcessError(1, 'wslpath'),
|
||||
):
|
||||
self.assertEqual(
|
||||
conv.to_local_path(r'D:\path\to\file.txt'),
|
||||
'/mnt/d/path/to/file.txt',
|
||||
)
|
||||
|
||||
def test_different_distro_path_returned_as_is(self) -> None:
|
||||
conv = WindowsToWSLConverter('Ubuntu')
|
||||
with mock.patch(
|
||||
'src.ide_path_conversion.subprocess.run',
|
||||
) as run:
|
||||
self.assertEqual(
|
||||
conv.to_local_path(r'\\wsl$\Debian\home\me'),
|
||||
r'\\wsl$\Debian\home\me',
|
||||
)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_same_distro_unc_uses_wslpath(self) -> None:
|
||||
conv = WindowsToWSLConverter('Ubuntu')
|
||||
with mock.patch(
|
||||
'src.ide_path_conversion.subprocess.run',
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout='/home/me\n', stderr='',
|
||||
),
|
||||
) as run:
|
||||
self.assertEqual(
|
||||
conv.to_local_path(r'\\wsl$\Ubuntu\home\me'),
|
||||
'/home/me',
|
||||
)
|
||||
run.assert_called_once()
|
||||
|
||||
|
||||
class WindowsToWSLConverterToIdePathTest(unittest.TestCase):
|
||||
def test_empty_passthrough(self) -> None:
|
||||
conv = WindowsToWSLConverter(None)
|
||||
self.assertEqual(conv.to_ide_path(''), '')
|
||||
|
||||
def test_uses_wslpath(self) -> None:
|
||||
conv = WindowsToWSLConverter(None)
|
||||
with mock.patch(
|
||||
'src.ide_path_conversion.subprocess.run',
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout=r'\\wsl$\Ubuntu\home\me' + '\n',
|
||||
stderr='',
|
||||
),
|
||||
) as run:
|
||||
self.assertEqual(
|
||||
conv.to_ide_path('/home/me'), r'\\wsl$\Ubuntu\home\me',
|
||||
)
|
||||
run.assert_called_once()
|
||||
args = run.call_args[0][0]
|
||||
self.assertEqual(args, ['wslpath', '-w', '/home/me'])
|
||||
|
||||
def test_returns_original_on_failure(self) -> None:
|
||||
conv = WindowsToWSLConverter(None)
|
||||
with mock.patch(
|
||||
'src.ide_path_conversion.subprocess.run',
|
||||
side_effect=FileNotFoundError(),
|
||||
):
|
||||
self.assertEqual(conv.to_ide_path('/home/me'), '/home/me')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,71 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.jupyter_runtime import (
|
||||
build_terminal_script_launcher,
|
||||
clean_terminal_output,
|
||||
extract_terminal_command_output,
|
||||
)
|
||||
|
||||
|
||||
class TestJupyterRuntimeOutputFiltering(unittest.TestCase):
|
||||
def test_extract_terminal_command_output_drops_wrapper_echo(self) -> None:
|
||||
start_marker = '__ZK_AGENT_START_test__'
|
||||
raw = (
|
||||
'root@host:/workspace# mkdir -p /workspace && cd /workspace && '
|
||||
'printf "\\n__ZK_AGENT_START_test__\\n"; '
|
||||
'( setsid bash -lc \'export A=1; echo real-output\' ) &\r\n'
|
||||
f'{start_marker}\r\n'
|
||||
'real-output\r\n'
|
||||
)
|
||||
|
||||
output = extract_terminal_command_output(
|
||||
clean_terminal_output(raw),
|
||||
start_marker=start_marker,
|
||||
)
|
||||
|
||||
self.assertEqual(output, 'real-output')
|
||||
|
||||
def test_extract_terminal_command_output_uses_last_marker(self) -> None:
|
||||
start_marker = '__ZK_AGENT_START_test__'
|
||||
output = extract_terminal_command_output(
|
||||
f'echo text containing {start_marker}\n{start_marker}\nactual',
|
||||
start_marker=start_marker,
|
||||
)
|
||||
|
||||
self.assertEqual(output, 'actual')
|
||||
|
||||
def test_clean_terminal_output_drops_wrapper_job_control_line(self) -> None:
|
||||
cleaned = clean_terminal_output(
|
||||
'real-output\r\n'
|
||||
'[1] + Done (setsid bash -lc "export A=1; lscpu")\r\n'
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned, 'real-output')
|
||||
|
||||
def test_clean_terminal_output_drops_continuation_prompt(self) -> None:
|
||||
cleaned = clean_terminal_output('real-output\r\n# >\r\n')
|
||||
|
||||
self.assertEqual(cleaned, 'real-output')
|
||||
|
||||
def test_terminal_script_launcher_hides_complex_wrapper_from_shell(self) -> None:
|
||||
launcher = build_terminal_script_launcher(
|
||||
script_path='/workspace/scratchpad/.run_scripts/run.sh',
|
||||
marker='__ZK_AGENT_EXIT_test__',
|
||||
)
|
||||
|
||||
self.assertNotIn('setsid bash -lc', launcher)
|
||||
self.assertEqual(
|
||||
launcher,
|
||||
'bash /workspace/scratchpad/.run_scripts/run.sh; '
|
||||
'__zk_outer_status=$?; '
|
||||
'rm -f /workspace/scratchpad/.run_scripts/run.sh; '
|
||||
'if [ "$__zk_outer_status" -ne 0 ]; then '
|
||||
'printf "\\n__ZK_AGENT_EXIT_test__:%s\\n" "$__zk_outer_status"; '
|
||||
'fi',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,74 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.lsp_runtime import LSPRuntime
|
||||
|
||||
|
||||
SAMPLE_SOURCE = '''def helper(value):
|
||||
"""Double a numeric value."""
|
||||
return value * 2
|
||||
|
||||
|
||||
def orchestrate(item):
|
||||
return helper(item)
|
||||
|
||||
|
||||
class Greeter:
|
||||
def greet(self, name):
|
||||
return helper(len(name))
|
||||
'''
|
||||
|
||||
|
||||
class LSPRuntimeTests(unittest.TestCase):
|
||||
def test_runtime_renders_symbols_definitions_references_and_hover(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'sample.py').write_text(SAMPLE_SOURCE, encoding='utf-8')
|
||||
runtime = LSPRuntime.from_workspace(workspace)
|
||||
|
||||
summary = runtime.render_summary()
|
||||
symbols = runtime.render_document_symbols('sample.py')
|
||||
workspace_symbols = runtime.render_workspace_symbols('helper')
|
||||
definition = runtime.render_definition('sample.py', 7, 12)
|
||||
references = runtime.render_references('sample.py', 7, 12)
|
||||
hover = runtime.render_hover('sample.py', 1, 5)
|
||||
|
||||
self.assertIn('Indexed candidate files: 1', summary)
|
||||
self.assertIn('# LSP Document Symbols', symbols)
|
||||
self.assertIn('function helper', symbols)
|
||||
self.assertIn('function orchestrate', symbols)
|
||||
self.assertIn('class Greeter', symbols)
|
||||
self.assertIn('# LSP Workspace Symbols', workspace_symbols)
|
||||
self.assertIn('helper', workspace_symbols)
|
||||
self.assertIn('# LSP Definition', definition)
|
||||
self.assertIn('function helper', definition)
|
||||
self.assertIn('# LSP References', references)
|
||||
self.assertIn('helper(item)', references)
|
||||
self.assertIn('# LSP Hover', hover)
|
||||
self.assertIn('signature=helper(value)', hover)
|
||||
self.assertIn('Double a numeric value.', hover)
|
||||
|
||||
def test_runtime_renders_call_hierarchy_and_diagnostics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'sample.py').write_text(SAMPLE_SOURCE, encoding='utf-8')
|
||||
(workspace / 'broken.py').write_text('def broken(:\n pass\n', encoding='utf-8')
|
||||
runtime = LSPRuntime.from_workspace(workspace)
|
||||
|
||||
hierarchy = runtime.render_prepare_call_hierarchy('sample.py', 6, 12)
|
||||
incoming = runtime.render_incoming_calls('sample.py', 1, 5)
|
||||
outgoing = runtime.render_outgoing_calls('sample.py', 6, 12)
|
||||
diagnostics = runtime.render_diagnostics('broken.py')
|
||||
|
||||
self.assertIn('# LSP Call Hierarchy', hierarchy)
|
||||
self.assertIn('symbol=orchestrate', hierarchy)
|
||||
self.assertIn('# LSP Incoming Calls', incoming)
|
||||
self.assertIn('orchestrate', incoming)
|
||||
self.assertIn('greet', incoming)
|
||||
self.assertIn('# LSP Outgoing Calls', outgoing)
|
||||
self.assertIn('helper', outgoing)
|
||||
self.assertIn('# LSP Diagnostics', diagnostics)
|
||||
self.assertIn('syntax-error', diagnostics)
|
||||
@@ -1,279 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.main import _build_runtime_config, _build_agent, _run_agent_chat_loop, build_parser, main
|
||||
|
||||
|
||||
class FakeHTTPResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode('utf-8')
|
||||
|
||||
def __enter__(self) -> 'FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_urlopen_side_effect(responses: list[dict[str, object]]):
|
||||
queued = [FakeHTTPResponse(payload) for payload in responses]
|
||||
|
||||
def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001
|
||||
return queued.pop(0)
|
||||
|
||||
return _fake_urlopen
|
||||
|
||||
|
||||
class MainCliTests(unittest.TestCase):
|
||||
def test_build_runtime_config_parses_model_and_session_budget_flags(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
'agent',
|
||||
'Summarize the repo',
|
||||
'--cwd',
|
||||
'.',
|
||||
'--max-model-calls',
|
||||
'3',
|
||||
'--max-session-turns',
|
||||
'5',
|
||||
]
|
||||
)
|
||||
runtime_config = _build_runtime_config(args)
|
||||
self.assertEqual(runtime_config.budget_config.max_model_calls, 3)
|
||||
self.assertEqual(runtime_config.budget_config.max_session_turns, 5)
|
||||
|
||||
def test_agent_chat_loop_runs_multiple_turns_and_reuses_session(self) -> None:
|
||||
responses = [
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'First chat reply.',
|
||||
},
|
||||
'finish_reason': 'stop',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 5, 'completion_tokens': 2},
|
||||
},
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'Second chat reply.',
|
||||
},
|
||||
'finish_reason': 'stop',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 6, 'completion_tokens': 2},
|
||||
},
|
||||
]
|
||||
recorded_results: list[str] = []
|
||||
recorded_lines: list[str] = []
|
||||
prompts = iter(['Second prompt', '/exit'])
|
||||
|
||||
def _input(prompt: str) -> str:
|
||||
return next(prompts)
|
||||
|
||||
def _output(line: str) -> None:
|
||||
recorded_lines.append(line)
|
||||
|
||||
def _result_printer(result, *, show_transcript: bool) -> None: # noqa: ANN001
|
||||
recorded_results.append(result.final_output)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
session_dir = workspace / '.port_sessions' / 'agent'
|
||||
with patch(
|
||||
'src.openai_compat.request.urlopen',
|
||||
side_effect=make_urlopen_side_effect(responses),
|
||||
):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
'agent-chat',
|
||||
'First prompt',
|
||||
'--model',
|
||||
'test-model',
|
||||
'--cwd',
|
||||
str(workspace),
|
||||
]
|
||||
)
|
||||
agent = _build_agent(args)
|
||||
agent.runtime_config = replace(
|
||||
agent.runtime_config,
|
||||
session_directory=session_dir,
|
||||
)
|
||||
exit_code = _run_agent_chat_loop(
|
||||
agent,
|
||||
initial_prompt=args.prompt,
|
||||
resume_session_id=None,
|
||||
show_transcript=False,
|
||||
input_func=_input,
|
||||
output_func=_output,
|
||||
result_printer=_result_printer,
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(recorded_results, ['First chat reply.', 'Second chat reply.'])
|
||||
self.assertIn('# Agent Chat', recorded_lines)
|
||||
self.assertIn('chat_ended=user_exit', recorded_lines)
|
||||
|
||||
def test_parser_accepts_remote_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['remote-profiles', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'remote-profiles')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_account_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['account-profiles', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'account-profiles')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_ask_user_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['ask-history', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'ask-history')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_search_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['search', 'repo query', '--cwd', '.', '--provider', 'local-search'])
|
||||
self.assertEqual(args.command, 'search')
|
||||
self.assertEqual(args.query, 'repo query')
|
||||
self.assertEqual(args.provider, 'local-search')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_worktree_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['worktree-exit', '--action', 'remove', '--discard-changes', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'worktree-exit')
|
||||
self.assertEqual(args.action, 'remove')
|
||||
self.assertTrue(args.discard_changes)
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_workflow_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['workflow-run', 'review', '--arguments-json', '{"path":"src"}', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'workflow-run')
|
||||
self.assertEqual(args.workflow_name, 'review')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_remote_trigger_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['trigger-run', 'nightly', '--body-json', '{"depth":"quick"}', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'trigger-run')
|
||||
self.assertEqual(args.trigger_id, 'nightly')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_mcp_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['mcp-tools', '--cwd', '.', '--server', 'remote'])
|
||||
self.assertEqual(args.command, 'mcp-tools')
|
||||
self.assertEqual(args.server, 'remote')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_daemon_subcommands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['daemon', 'ps'])
|
||||
self.assertEqual(args.command, 'daemon')
|
||||
self.assertEqual(args.daemon_command, 'ps')
|
||||
|
||||
def test_parser_accepts_config_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['config-get', 'review.mode', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'config-get')
|
||||
self.assertEqual(args.key_path, 'review.mode')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_lsp_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['lsp-definition', 'sample.py', '4', '12', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'lsp-definition')
|
||||
self.assertEqual(args.file_path, 'sample.py')
|
||||
self.assertEqual(args.line, 4)
|
||||
self.assertEqual(args.character, 12)
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_token_budget_command(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['token-budget', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'token-budget')
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_agents_command(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['agents', 'reviewer', '--cwd', '.', '--all'])
|
||||
self.assertEqual(args.command, 'agents')
|
||||
self.assertEqual(args.agent_type, 'reviewer')
|
||||
self.assertTrue(args.all)
|
||||
self.assertEqual(args.cwd, '.')
|
||||
|
||||
def test_parser_accepts_agent_management_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
create_args = parser.parse_args(
|
||||
['agents-create', 'reviewer', '--cwd', '.', '--description', 'Review code', '--prompt', 'Inspect diffs']
|
||||
)
|
||||
self.assertEqual(create_args.command, 'agents-create')
|
||||
self.assertEqual(create_args.agent_type, 'reviewer')
|
||||
self.assertEqual(create_args.description, 'Review code')
|
||||
|
||||
update_args = parser.parse_args(['agents-update', 'reviewer', '--cwd', '.', '--source', 'auto'])
|
||||
self.assertEqual(update_args.command, 'agents-update')
|
||||
self.assertEqual(update_args.source, 'auto')
|
||||
|
||||
delete_args = parser.parse_args(['agents-delete', 'reviewer', '--cwd', '.', '--source', 'project'])
|
||||
self.assertEqual(delete_args.command, 'agents-delete')
|
||||
self.assertEqual(delete_args.source, 'project')
|
||||
|
||||
def test_main_can_create_and_delete_agent_definition(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
with patch.dict('os.environ', {'HOME': home_dir}):
|
||||
exit_code = main(
|
||||
[
|
||||
'agents-create',
|
||||
'reviewer',
|
||||
'--cwd',
|
||||
str(workspace),
|
||||
'--description',
|
||||
'Review code carefully',
|
||||
'--prompt',
|
||||
'Inspect code and summarize risks.',
|
||||
]
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue((workspace / '.claude' / 'agents' / 'reviewer.md').exists())
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
'agents-delete',
|
||||
'reviewer',
|
||||
'--cwd',
|
||||
str(workspace),
|
||||
'--source',
|
||||
'project',
|
||||
]
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertFalse((workspace / '.claude' / 'agents' / 'reviewer.md').exists())
|
||||
|
||||
def test_parser_accepts_team_runtime_commands(self) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(['team-create', 'reviewers', '--member', 'alice', '--cwd', '.'])
|
||||
self.assertEqual(args.command, 'team-create')
|
||||
self.assertEqual(args.team_name, 'reviewers')
|
||||
self.assertEqual(args.member, ['alice'])
|
||||
self.assertEqual(args.cwd, '.')
|
||||
@@ -1,350 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
from src.mcp_runtime import MCPRuntime
|
||||
|
||||
|
||||
class FakeHTTPResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode('utf-8')
|
||||
|
||||
def __enter__(self) -> 'FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_urlopen_side_effect(responses: list[dict[str, object]]):
|
||||
queued = [FakeHTTPResponse(payload) for payload in responses]
|
||||
|
||||
def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001
|
||||
return queued.pop(0)
|
||||
|
||||
return _fake_urlopen
|
||||
|
||||
|
||||
class MCPRuntimeTests(unittest.TestCase):
|
||||
def _write_fake_stdio_server(self, workspace: Path) -> Path:
|
||||
server_path = workspace / 'fake_mcp_server.py'
|
||||
server_path.write_text(
|
||||
(
|
||||
'import json, sys\n'
|
||||
'RESOURCES = [{"uri": "mcp://remote/notes", "name": "Remote Notes", "mimeType": "text/plain"}]\n'
|
||||
'TOOLS = [{"name": "echo", "description": "Echo text", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}}]\n'
|
||||
'for raw in sys.stdin:\n'
|
||||
' raw = raw.strip()\n'
|
||||
' if not raw:\n'
|
||||
' continue\n'
|
||||
' message = json.loads(raw)\n'
|
||||
' method = message.get("method")\n'
|
||||
' if method == "initialize":\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"protocolVersion": "2025-11-25", "capabilities": {"resources": {}, "tools": {}}, "serverInfo": {"name": "fake-remote", "version": "1.0.0"}}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' if method == "notifications/initialized":\n'
|
||||
' continue\n'
|
||||
' if method == "resources/list":\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"resources": RESOURCES}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' if method == "resources/read":\n'
|
||||
' uri = message.get("params", {}).get("uri")\n'
|
||||
' text = "remote notes via stdio" if uri == "mcp://remote/notes" else "unknown resource"\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"contents": [{"uri": uri, "mimeType": "text/plain", "text": text}]}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' if method == "tools/list":\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"tools": TOOLS}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' if method == "tools/call":\n'
|
||||
' params = message.get("params", {})\n'
|
||||
' text = params.get("arguments", {}).get("text", "")\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "result": {"content": [{"type": "text", "text": "echo:" + text}], "isError": False}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
' continue\n'
|
||||
' response = {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32601, "message": "Method not found"}}\n'
|
||||
' print(json.dumps(response), flush=True)\n'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
return server_path
|
||||
|
||||
def test_runtime_discovers_and_reads_local_resources(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
(
|
||||
'{"servers":[{"name":"workspace","resources":['
|
||||
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"},'
|
||||
'{"uri":"mcp://workspace/inline","name":"Inline","text":"inline body"}'
|
||||
']}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = MCPRuntime.from_workspace(workspace)
|
||||
self.assertEqual(len(runtime.resources), 2)
|
||||
self.assertIn('Local MCP resources: 2', runtime.render_summary())
|
||||
self.assertEqual(runtime.read_resource('mcp://workspace/inline'), 'inline body')
|
||||
self.assertIn('mcp notes', runtime.read_resource('mcp://workspace/notes'))
|
||||
|
||||
def test_runtime_discovers_stdio_server_and_remote_resources_and_tools(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
server_path = self._write_fake_stdio_server(workspace)
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
json.dumps(
|
||||
{
|
||||
'mcpServers': {
|
||||
'remote': {
|
||||
'command': sys.executable,
|
||||
'args': ['-u', str(server_path)],
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = MCPRuntime.from_workspace(workspace)
|
||||
resources = runtime.list_resources()
|
||||
tools = runtime.list_tools()
|
||||
self.assertEqual(len(runtime.servers), 1)
|
||||
self.assertTrue(runtime.has_transport_servers())
|
||||
self.assertIn('Configured MCP servers: 1', runtime.render_summary())
|
||||
self.assertEqual(len(resources), 1)
|
||||
self.assertEqual(resources[0].uri, 'mcp://remote/notes')
|
||||
self.assertIn('remote notes via stdio', runtime.read_resource('mcp://remote/notes'))
|
||||
self.assertEqual(len(tools), 1)
|
||||
self.assertEqual(tools[0].name, 'echo')
|
||||
rendered, metadata = runtime.call_tool('echo', arguments={'text': 'hello'})
|
||||
self.assertIn('echo:hello', rendered)
|
||||
self.assertEqual(metadata.get('server_name'), 'remote')
|
||||
|
||||
def test_mcp_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
(
|
||||
'{"servers":[{"name":"workspace","resources":['
|
||||
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
|
||||
']}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = MCPRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
mcp_runtime=runtime,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'mcp_list_resources',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
read_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'mcp_read_resource',
|
||||
{'uri': 'mcp://workspace/notes'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(list_result.ok)
|
||||
self.assertIn('mcp://workspace/notes', list_result.content)
|
||||
self.assertTrue(read_result.ok)
|
||||
self.assertIn('mcp notes', read_result.content)
|
||||
|
||||
def test_mcp_transport_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
server_path = self._write_fake_stdio_server(workspace)
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
json.dumps(
|
||||
{
|
||||
'mcpServers': {
|
||||
'remote': {
|
||||
'command': sys.executable,
|
||||
'args': ['-u', str(server_path)],
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = MCPRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
mcp_runtime=runtime,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'mcp_list_tools',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
call_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'mcp_call_tool',
|
||||
{'tool_name': 'echo', 'arguments': {'text': 'tool-run'}},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(list_result.ok)
|
||||
self.assertIn('echo', list_result.content)
|
||||
self.assertTrue(call_result.ok)
|
||||
self.assertIn('echo:tool-run', call_result.content)
|
||||
self.assertEqual(call_result.metadata.get('action'), 'mcp_call_tool')
|
||||
|
||||
def test_agent_can_use_mcp_tools_in_model_loop(self) -> None:
|
||||
responses = [
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'I will inspect the MCP resource.',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_1',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'mcp_read_resource',
|
||||
'arguments': '{"uri": "mcp://workspace/notes"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
'finish_reason': 'tool_calls',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 8, 'completion_tokens': 3},
|
||||
},
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'The MCP resource says mcp notes.',
|
||||
},
|
||||
'finish_reason': 'stop',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 6, 'completion_tokens': 3},
|
||||
},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / 'notes.txt').write_text('mcp notes\n', encoding='utf-8')
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
(
|
||||
'{"servers":[{"name":"workspace","resources":['
|
||||
'{"uri":"mcp://workspace/notes","name":"Notes","path":"notes.txt"}'
|
||||
']}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(
|
||||
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
|
||||
base_url='http://127.0.0.1:8000/v1',
|
||||
),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('Read the MCP notes resource')
|
||||
|
||||
self.assertEqual(result.final_output, 'The MCP resource says mcp notes.')
|
||||
self.assertEqual(result.tool_calls, 1)
|
||||
tool_message = next(
|
||||
message
|
||||
for message in result.transcript
|
||||
if message.get('role') == 'tool'
|
||||
)
|
||||
self.assertIn('mcp notes', tool_message.get('content', ''))
|
||||
|
||||
def test_agent_can_use_transport_backed_mcp_call_tool_in_model_loop(self) -> None:
|
||||
responses = [
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'I will call the remote MCP tool.',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_1',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'mcp_call_tool',
|
||||
'arguments': '{"tool_name": "echo", "server": "remote", "arguments": {"text": "agent-call"}}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
'finish_reason': 'tool_calls',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 8, 'completion_tokens': 3},
|
||||
},
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'The remote MCP tool replied with echo:agent-call.',
|
||||
},
|
||||
'finish_reason': 'stop',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 6, 'completion_tokens': 3},
|
||||
},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
server_path = self._write_fake_stdio_server(workspace)
|
||||
(workspace / '.claw-mcp.json').write_text(
|
||||
json.dumps(
|
||||
{
|
||||
'mcpServers': {
|
||||
'remote': {
|
||||
'command': sys.executable,
|
||||
'args': ['-u', str(server_path)],
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(
|
||||
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
|
||||
base_url='http://127.0.0.1:8000/v1',
|
||||
),
|
||||
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||
)
|
||||
result = agent.run('Call the remote MCP echo tool')
|
||||
|
||||
self.assertEqual(result.final_output, 'The remote MCP tool replied with echo:agent-call.')
|
||||
self.assertEqual(result.tool_calls, 1)
|
||||
tool_message = next(
|
||||
message
|
||||
for message in result.transcript
|
||||
if message.get('role') == 'tool'
|
||||
)
|
||||
self.assertIn('echo:agent-call', tool_message.get('content', ''))
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Tests for discovery slash commands ported from the npm source.
|
||||
|
||||
Covers /version, /init, /ide, /plugin, /remote-env.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_slash_commands import preprocess_slash_command
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
|
||||
|
||||
def _make_agent(tmp_dir: str) -> LocalCodingAgent:
|
||||
return LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
|
||||
|
||||
def _local_settings(tmp_dir: str) -> dict:
|
||||
path = Path(tmp_dir) / '.claude' / 'settings.local.json'
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
class VersionCommandTest(unittest.TestCase):
|
||||
def test_prints_python_runtime_version(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/version').final_output
|
||||
self.assertIn('claw-code-agent', out)
|
||||
self.assertIn('Python', out)
|
||||
|
||||
|
||||
class InitCommandTest(unittest.TestCase):
|
||||
def test_returns_prompt_with_claude_md_instructions(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = preprocess_slash_command(agent, '/init')
|
||||
self.assertTrue(result.handled)
|
||||
self.assertTrue(result.should_query)
|
||||
self.assertIn('CLAUDE.md', result.prompt or '')
|
||||
self.assertIn('analyze this codebase', (result.prompt or '').lower())
|
||||
|
||||
|
||||
class IdeCommandTest(unittest.TestCase):
|
||||
def test_no_ide_when_env_clean(self) -> None:
|
||||
clean = {k: v for k, v in os.environ.items() if k not in {
|
||||
'TERM_PROGRAM', 'VSCODE_INJECTION', 'VSCODE_PID',
|
||||
'JETBRAINS_IDE', 'TERMINAL_EMULATOR', 'SSH_CONNECTION',
|
||||
}}
|
||||
with mock.patch.dict(os.environ, clean, clear=True):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/ide').final_output
|
||||
self.assertIn('No IDE detected', out)
|
||||
self.assertIn('IDE auto-connect', out)
|
||||
|
||||
def test_detects_vscode(self) -> None:
|
||||
env = {'VSCODE_PID': '1234', 'TERM_PROGRAM': 'vscode'}
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/ide').final_output
|
||||
self.assertIn('Visual Studio Code', out)
|
||||
self.assertIn('VSCODE_PID=1234', out)
|
||||
|
||||
|
||||
class PluginCommandTest(unittest.TestCase):
|
||||
def test_lists_no_plugins_when_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/plugin').final_output
|
||||
self.assertIn('No installed plugins', out)
|
||||
|
||||
def test_help_describes_usage(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/plugin help').final_output
|
||||
self.assertIn('Usage: /plugin', out)
|
||||
self.assertIn('list', out)
|
||||
|
||||
def test_unknown_subcommand(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/plugin bogus').final_output
|
||||
self.assertIn('Unknown plugin subcommand', out)
|
||||
|
||||
|
||||
class RemoteEnvCommandTest(unittest.TestCase):
|
||||
def test_lists_empty_profiles(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-env').final_output
|
||||
self.assertIn('Available remote environments', out)
|
||||
self.assertIn('no profiles found', out)
|
||||
self.assertIn('Usage:', out)
|
||||
|
||||
def test_clear_when_no_default_set(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-env clear').final_output
|
||||
self.assertIn('No default remote environment', out)
|
||||
|
||||
def test_unknown_profile_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-env nope').final_output
|
||||
self.assertIn('Unknown remote environment', out)
|
||||
self.assertIn('nope', out)
|
||||
|
||||
def test_set_then_clear_persists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.remote.json').write_text(json.dumps({
|
||||
'profiles': [
|
||||
{'name': 'sandbox', 'mode': 'ssh', 'target': 'user@host'},
|
||||
],
|
||||
}), encoding='utf-8')
|
||||
agent = _make_agent(tmp)
|
||||
set_out = agent.run('/remote-env sandbox').final_output
|
||||
self.assertIn('Default remote environment set to sandbox', set_out)
|
||||
self.assertEqual(_local_settings(tmp).get('defaultRemoteEnvironment'), 'sandbox')
|
||||
|
||||
agent2 = _make_agent(tmp)
|
||||
list_out = agent2.run('/remote-env').final_output
|
||||
self.assertIn('sandbox', list_out)
|
||||
self.assertIn('(default)', list_out)
|
||||
|
||||
clear_out = agent2.run('/remote-env clear').final_output
|
||||
self.assertIn('Cleared default remote environment', clear_out)
|
||||
self.assertIsNone(_local_settings(tmp).get('defaultRemoteEnvironment'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,167 +0,0 @@
|
||||
"""Tests for model pricing utilities ported from utils/modelCost.ts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.model_cost import (
|
||||
COST_HAIKU_35,
|
||||
COST_HAIKU_45,
|
||||
COST_TIER_3_15,
|
||||
COST_TIER_5_25,
|
||||
COST_TIER_15_75,
|
||||
COST_TIER_30_150,
|
||||
DEFAULT_UNKNOWN_MODEL_COST,
|
||||
calculate_cost_from_tokens,
|
||||
calculate_usd_cost,
|
||||
format_model_pricing,
|
||||
get_model_costs,
|
||||
get_model_pricing_string,
|
||||
get_opus_4_6_cost_tier,
|
||||
tokens_to_usd_cost,
|
||||
)
|
||||
|
||||
|
||||
class TierConstantsTest(unittest.TestCase):
|
||||
def test_sonnet_tier(self) -> None:
|
||||
self.assertEqual(COST_TIER_3_15.input_tokens, 3.0)
|
||||
self.assertEqual(COST_TIER_3_15.output_tokens, 15.0)
|
||||
|
||||
def test_opus_4_tier(self) -> None:
|
||||
self.assertEqual(COST_TIER_15_75.input_tokens, 15.0)
|
||||
|
||||
def test_opus_4_5_tier(self) -> None:
|
||||
self.assertEqual(COST_TIER_5_25.input_tokens, 5.0)
|
||||
|
||||
def test_fast_mode_tier(self) -> None:
|
||||
self.assertEqual(COST_TIER_30_150.input_tokens, 30.0)
|
||||
|
||||
def test_haiku_tiers(self) -> None:
|
||||
self.assertAlmostEqual(COST_HAIKU_35.input_tokens, 0.8)
|
||||
self.assertEqual(COST_HAIKU_45.input_tokens, 1.0)
|
||||
|
||||
|
||||
class GetModelCostsTest(unittest.TestCase):
|
||||
def test_opus_4_6_default(self) -> None:
|
||||
self.assertIs(get_model_costs('claude-opus-4-6'), COST_TIER_5_25)
|
||||
|
||||
def test_opus_4_6_fast_mode(self) -> None:
|
||||
self.assertIs(
|
||||
get_model_costs('claude-opus-4-6', fast_mode=True),
|
||||
COST_TIER_30_150,
|
||||
)
|
||||
|
||||
def test_versioned_model_name_resolves(self) -> None:
|
||||
self.assertIs(
|
||||
get_model_costs('claude-opus-4-6-20251015'),
|
||||
COST_TIER_5_25,
|
||||
)
|
||||
|
||||
def test_sonnet_models_use_3_15(self) -> None:
|
||||
for name in ('claude-sonnet-4-6', 'claude-sonnet-4-5', 'claude-sonnet-4'):
|
||||
self.assertIs(get_model_costs(name), COST_TIER_3_15)
|
||||
|
||||
def test_opus_4_and_4_1_use_15_75(self) -> None:
|
||||
self.assertIs(get_model_costs('claude-opus-4'), COST_TIER_15_75)
|
||||
self.assertIs(get_model_costs('claude-opus-4-1'), COST_TIER_15_75)
|
||||
|
||||
def test_haiku_4_5(self) -> None:
|
||||
self.assertIs(get_model_costs('claude-haiku-4-5-20251001'), COST_HAIKU_45)
|
||||
|
||||
def test_haiku_3_5(self) -> None:
|
||||
self.assertIs(get_model_costs('claude-3-5-haiku-20241022'), COST_HAIKU_35)
|
||||
|
||||
def test_unknown_falls_back_to_default(self) -> None:
|
||||
self.assertIs(get_model_costs('mystery-llm-3000'), DEFAULT_UNKNOWN_MODEL_COST)
|
||||
|
||||
def test_get_opus_4_6_helper_matches_fast_mode(self) -> None:
|
||||
self.assertIs(get_opus_4_6_cost_tier(False), COST_TIER_5_25)
|
||||
self.assertIs(get_opus_4_6_cost_tier(True), COST_TIER_30_150)
|
||||
|
||||
|
||||
class TokensToUsdCostTest(unittest.TestCase):
|
||||
def test_simple_input_output(self) -> None:
|
||||
cost = tokens_to_usd_cost(
|
||||
COST_TIER_3_15, input_tokens=1_000_000, output_tokens=500_000,
|
||||
)
|
||||
self.assertAlmostEqual(cost, 3.0 + 7.5)
|
||||
|
||||
def test_includes_cache_tokens(self) -> None:
|
||||
cost = tokens_to_usd_cost(
|
||||
COST_TIER_3_15,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
cache_read_input_tokens=1_000_000,
|
||||
cache_creation_input_tokens=1_000_000,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
cost,
|
||||
COST_TIER_3_15.prompt_cache_read_tokens
|
||||
+ COST_TIER_3_15.prompt_cache_write_tokens,
|
||||
)
|
||||
|
||||
def test_includes_web_search(self) -> None:
|
||||
cost = tokens_to_usd_cost(
|
||||
COST_TIER_3_15,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
web_search_requests=10,
|
||||
)
|
||||
self.assertAlmostEqual(cost, 0.10)
|
||||
|
||||
|
||||
class CalculateUsdCostTest(unittest.TestCase):
|
||||
def test_resolves_model_then_costs(self) -> None:
|
||||
cost = calculate_usd_cost(
|
||||
'claude-sonnet-4-6',
|
||||
input_tokens=1_000_000,
|
||||
output_tokens=500_000,
|
||||
)
|
||||
self.assertAlmostEqual(cost, 10.5)
|
||||
|
||||
def test_fast_mode_changes_opus_46_cost(self) -> None:
|
||||
normal = calculate_usd_cost(
|
||||
'claude-opus-4-6', input_tokens=1_000_000, output_tokens=0,
|
||||
)
|
||||
fast = calculate_usd_cost(
|
||||
'claude-opus-4-6', input_tokens=1_000_000, output_tokens=0,
|
||||
fast_mode=True,
|
||||
)
|
||||
self.assertGreater(fast, normal)
|
||||
self.assertAlmostEqual(normal, 5.0)
|
||||
self.assertAlmostEqual(fast, 30.0)
|
||||
|
||||
|
||||
class CalculateCostFromTokensTest(unittest.TestCase):
|
||||
def test_camel_case_dict_input(self) -> None:
|
||||
cost = calculate_cost_from_tokens(
|
||||
'claude-opus-4-1',
|
||||
{
|
||||
'inputTokens': 1_000_000,
|
||||
'outputTokens': 0,
|
||||
'cacheReadInputTokens': 0,
|
||||
'cacheCreationInputTokens': 0,
|
||||
},
|
||||
)
|
||||
self.assertAlmostEqual(cost, 15.0)
|
||||
|
||||
|
||||
class FormatPricingTest(unittest.TestCase):
|
||||
def test_integers_no_decimals(self) -> None:
|
||||
self.assertEqual(format_model_pricing(COST_TIER_3_15), '$3/$15 per Mtok')
|
||||
|
||||
def test_haiku_decimals(self) -> None:
|
||||
self.assertEqual(format_model_pricing(COST_HAIKU_35), '$0.80/$4 per Mtok')
|
||||
|
||||
def test_get_pricing_string_known(self) -> None:
|
||||
self.assertEqual(
|
||||
get_model_pricing_string('claude-opus-4-6'),
|
||||
'$5/$25 per Mtok',
|
||||
)
|
||||
|
||||
def test_get_pricing_string_unknown(self) -> None:
|
||||
self.assertIsNone(get_model_pricing_string('unknown-model'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,138 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api.server import AgentState, _normalize_model_list, create_app
|
||||
|
||||
|
||||
class ModelListTests(unittest.TestCase):
|
||||
def test_normalize_model_list_uses_provider_prefixed_llm_ids(self) -> None:
|
||||
payload = {
|
||||
'data': [
|
||||
{
|
||||
'id': 'ASR_NonStreaming',
|
||||
'object': 'model',
|
||||
'owned_by': 'xiaomi',
|
||||
'model_type': 'speech2text',
|
||||
},
|
||||
{
|
||||
'id': 'DeepSeek-R1-0528',
|
||||
'object': 'model',
|
||||
'owned_by': 'xiaomi',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
{
|
||||
'id': 'gpt-5',
|
||||
'object': 'model',
|
||||
'owned_by': 'azure_openai',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
{
|
||||
'id': 'gpt-5.5',
|
||||
'object': 'model',
|
||||
'owned_by': 'azure_openai',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
{
|
||||
'id': 'gpt-4o-audio-preview',
|
||||
'object': 'model',
|
||||
'owned_by': 'azure_openai',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
{
|
||||
'id': 'embedding-v1',
|
||||
'object': 'model',
|
||||
'owned_by': 'example',
|
||||
'model_type': 'text-embedding',
|
||||
},
|
||||
{
|
||||
'id': 'ernie-4.0-turbo-128k',
|
||||
'object': 'model',
|
||||
'owned_by': 'baidu_qianfan',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
{
|
||||
'id': 'Pro/deepseek-ai/DeepSeek-V3',
|
||||
'object': 'model',
|
||||
'owned_by': 'siliconflow',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
{
|
||||
'id': 'pa/claude-opus-4-7',
|
||||
'object': 'model',
|
||||
'owned_by': 'ppio',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
{
|
||||
'id': 'gemini-2.5-flash',
|
||||
'object': 'model',
|
||||
'owned_by': 'ppio',
|
||||
'model_type': 'llm',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
models = _normalize_model_list(payload)
|
||||
|
||||
self.assertEqual(
|
||||
[model['id'] for model in models],
|
||||
[
|
||||
'azure_openai/gpt-5',
|
||||
'ppio/gemini-2.5-flash',
|
||||
'ppio/pa/claude-opus-4-7',
|
||||
'siliconflow/Pro/deepseek-ai/DeepSeek-V3',
|
||||
'xiaomi/DeepSeek-R1-0528',
|
||||
],
|
||||
)
|
||||
self.assertEqual(models[0]['provider'], 'azure_openai')
|
||||
self.assertEqual(models[0]['model_type'], 'llm')
|
||||
|
||||
def test_models_endpoint_returns_provider_prefixed_llm_models(self) -> None:
|
||||
class FakeResponse:
|
||||
def __enter__(self) -> 'FakeResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self) -> bytes:
|
||||
return (
|
||||
b'{"object":"list","data":['
|
||||
b'{"id":"mimo-v2-flash","object":"model","owned_by":"xiaomi","model_type":"llm"},'
|
||||
b'{"id":"ASR_Streaming","object":"model","owned_by":"xiaomi","model_type":"speech2text"},'
|
||||
b'{"id":"gpt-5","object":"model","owned_by":"azure_openai","model_type":"llm"}'
|
||||
b']}'
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
state = AgentState(
|
||||
cwd=Path(tmp_dir),
|
||||
model='xiaomi/mimo-v2-flash',
|
||||
base_url='http://model.example/v1',
|
||||
api_key='token',
|
||||
timeout_seconds=120.0,
|
||||
allow_shell=False,
|
||||
allow_write=False,
|
||||
session_directory=Path(tmp_dir) / 'sessions',
|
||||
)
|
||||
client = TestClient(create_app(state))
|
||||
with patch('backend.api.server.request.urlopen', return_value=FakeResponse()):
|
||||
response = client.get('/api/models')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload['raw_count'], 3)
|
||||
self.assertEqual(payload['filtered_count'], 2)
|
||||
self.assertEqual(
|
||||
[model['id'] for model in payload['models']],
|
||||
['azure_openai/gpt-5', 'xiaomi/mimo-v2-flash'],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,16 @@
|
||||
from agent_platform.models import MODEL_SPECS, get_model_spec, openai_model_list
|
||||
|
||||
|
||||
def test_fixed_public_model_matrix() -> None:
|
||||
assert set(MODEL_SPECS) == {
|
||||
"chat-light",
|
||||
"chat-medium",
|
||||
"chat-high",
|
||||
"work-light",
|
||||
"work-medium",
|
||||
"work-high",
|
||||
}
|
||||
assert get_model_spec("chat-light").provider_model == "ChatGPT-5.6:Luna"
|
||||
assert get_model_spec("work-medium").provider_model == "ChatGPT-5.6:Terra"
|
||||
assert get_model_spec("work-high").provider_model == "ChatGPT-5.6:Sol"
|
||||
assert {item["id"] for item in openai_model_list()["data"]} == set(MODEL_SPECS)
|
||||
@@ -1,299 +0,0 @@
|
||||
"""Tests for /files, /copy, /export, /stats, /tag, /rename, /branch, /effort, /doctor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_session import AgentMessage, AgentSessionState
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig, UsageStats
|
||||
|
||||
|
||||
def _make_agent(tmp_dir: str) -> LocalCodingAgent:
|
||||
return LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
|
||||
|
||||
def _set_session(agent: LocalCodingAgent, messages: list[AgentMessage]) -> None:
|
||||
session = AgentSessionState(
|
||||
system_prompt_parts=('You are a helper.',),
|
||||
messages=messages,
|
||||
)
|
||||
agent.last_session = session
|
||||
|
||||
|
||||
class TestFilesCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/files')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_no_files_in_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
])
|
||||
result = agent.run('/files')
|
||||
self.assertIn('No files loaded', result.final_output)
|
||||
|
||||
def test_files_from_tool_calls(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Read main.py'),
|
||||
AgentMessage(
|
||||
role='assistant', content='',
|
||||
tool_calls=(
|
||||
{'id': 'tc1', 'type': 'function', 'function': {
|
||||
'name': 'Read',
|
||||
'arguments': json.dumps({'file_path': '/home/user/project/main.py'}),
|
||||
}},
|
||||
),
|
||||
),
|
||||
AgentMessage(
|
||||
role='tool', content='print("hello")',
|
||||
name='Read',
|
||||
tool_call_id='tc1',
|
||||
metadata={'path': '/home/user/project/main.py'},
|
||||
),
|
||||
])
|
||||
result = agent.run('/files')
|
||||
self.assertIn('Files in context', result.final_output)
|
||||
self.assertIn('main.py', result.final_output)
|
||||
|
||||
|
||||
class TestCopyCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/copy')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_no_assistant_messages(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
])
|
||||
result = agent.run('/copy')
|
||||
self.assertIn('No assistant responses', result.final_output)
|
||||
|
||||
def test_copies_latest_response(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='First response.'),
|
||||
AgentMessage(role='user', content='More'),
|
||||
AgentMessage(role='assistant', content='Second response with details.'),
|
||||
])
|
||||
result = agent.run('/copy')
|
||||
self.assertIn('Copied', result.final_output)
|
||||
self.assertIn('response.md', result.final_output)
|
||||
# Verify the file was written
|
||||
tmp_file = Path(tempfile.gettempdir()) / 'claw-code' / 'response.md'
|
||||
self.assertTrue(tmp_file.exists())
|
||||
content = tmp_file.read_text()
|
||||
self.assertEqual(content, 'Second response with details.')
|
||||
|
||||
def test_copies_nth_response(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='First.'),
|
||||
AgentMessage(role='user', content='More'),
|
||||
AgentMessage(role='assistant', content='Second.'),
|
||||
])
|
||||
result = agent.run('/copy 1')
|
||||
tmp_file = Path(tempfile.gettempdir()) / 'claw-code' / 'response.md'
|
||||
content = tmp_file.read_text()
|
||||
self.assertEqual(content, 'First.')
|
||||
|
||||
|
||||
class TestExportCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/export')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_exports_with_auto_filename(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi there'),
|
||||
])
|
||||
result = agent.run('/export')
|
||||
self.assertIn('Exported 2 messages', result.final_output)
|
||||
self.assertIn('.txt', result.final_output)
|
||||
|
||||
def test_exports_with_custom_filename(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
])
|
||||
result = agent.run('/export my_chat')
|
||||
self.assertIn('my_chat.txt', result.final_output)
|
||||
out_file = Path(tmp) / 'my_chat.txt'
|
||||
self.assertTrue(out_file.exists())
|
||||
content = out_file.read_text()
|
||||
self.assertIn('Hello', content)
|
||||
self.assertIn('Hi', content)
|
||||
|
||||
|
||||
class TestStatsCommand(unittest.TestCase):
|
||||
def test_shows_statistics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
AgentMessage(role='user', content='Question'),
|
||||
])
|
||||
agent.cumulative_usage = UsageStats(input_tokens=500, output_tokens=200)
|
||||
result = agent.run('/stats')
|
||||
self.assertIn('Session Statistics', result.final_output)
|
||||
self.assertIn('3 total', result.final_output)
|
||||
self.assertIn('2 user', result.final_output)
|
||||
self.assertIn('1 assistant', result.final_output)
|
||||
self.assertIn('500', result.final_output)
|
||||
self.assertIn('200', result.final_output)
|
||||
|
||||
|
||||
class TestTagCommand(unittest.TestCase):
|
||||
def test_no_tags_initially(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/tag')
|
||||
self.assertIn('No tags set', result.final_output)
|
||||
|
||||
def test_add_tag(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/tag important')
|
||||
self.assertIn('Added tag: important', result.final_output)
|
||||
|
||||
def test_toggle_tag(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/tag my-tag')
|
||||
result = agent.run('/tag my-tag')
|
||||
self.assertIn('Removed tag: my-tag', result.final_output)
|
||||
|
||||
def test_list_tags(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/tag alpha')
|
||||
agent.run('/tag beta')
|
||||
result = agent.run('/tag')
|
||||
self.assertIn('alpha', result.final_output)
|
||||
self.assertIn('beta', result.final_output)
|
||||
|
||||
|
||||
class TestRenameCommand(unittest.TestCase):
|
||||
def test_no_name(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/rename')
|
||||
self.assertIn('Usage', result.final_output)
|
||||
|
||||
def test_rename_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/rename My Cool Session')
|
||||
self.assertIn('renamed to: My Cool Session', result.final_output)
|
||||
|
||||
|
||||
class TestBranchCommand(unittest.TestCase):
|
||||
def test_no_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/branch')
|
||||
self.assertIn('No active session', result.final_output)
|
||||
|
||||
def test_branch_with_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
AgentMessage(role='assistant', content='Hi'),
|
||||
])
|
||||
result = agent.run('/branch my-feature')
|
||||
self.assertIn('Created branch "my-feature"', result.final_output)
|
||||
self.assertIn('Saved to:', result.final_output)
|
||||
|
||||
def test_branch_auto_name(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
_set_session(agent, [
|
||||
AgentMessage(role='user', content='Hello'),
|
||||
])
|
||||
result = agent.run('/branch')
|
||||
self.assertIn('Created branch "branch-', result.final_output)
|
||||
|
||||
|
||||
class TestEffortCommand(unittest.TestCase):
|
||||
def test_show_current_effort(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/effort')
|
||||
self.assertIn('Current effort level: auto', result.final_output)
|
||||
|
||||
def test_set_effort_level(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/effort high')
|
||||
self.assertIn('Set effort level to: high', result.final_output)
|
||||
|
||||
def test_invalid_effort_level(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/effort extreme')
|
||||
self.assertIn('Invalid effort level', result.final_output)
|
||||
|
||||
def test_all_valid_levels(self) -> None:
|
||||
for level in ('low', 'medium', 'high', 'max', 'auto'):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run(f'/effort {level}')
|
||||
self.assertIn(f'Set effort level to: {level}', result.final_output)
|
||||
|
||||
|
||||
class TestDoctorCommand(unittest.TestCase):
|
||||
def test_shows_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/doctor')
|
||||
output = result.final_output
|
||||
self.assertIn('Doctor Report', output)
|
||||
self.assertIn('Python version', output)
|
||||
self.assertIn('git', output)
|
||||
self.assertIn('Model', output)
|
||||
self.assertIn('Working directory', output)
|
||||
|
||||
def test_detects_claude_md(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / 'CLAUDE.md').write_text('memory file')
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/doctor')
|
||||
self.assertIn('CLAUDE.md', result.final_output)
|
||||
self.assertIn('found', result.final_output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,434 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.openai_compat import (
|
||||
OpenAICompatClient,
|
||||
OpenAICompatError,
|
||||
_anthropic_base_url,
|
||||
_build_response_format,
|
||||
_join_url,
|
||||
_normalize_content,
|
||||
_optional_int,
|
||||
_parse_tool_arguments,
|
||||
_parse_usage,
|
||||
_temperature_for_model,
|
||||
_uses_anthropic_messages_api,
|
||||
)
|
||||
from src.agent_types import ModelConfig, OutputSchemaConfig, UsageStats
|
||||
|
||||
|
||||
class FakeHTTPResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode('utf-8')
|
||||
|
||||
def __enter__(self) -> 'FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class FakeStreamingHTTPResponse:
|
||||
def __init__(self, payloads: list[dict[str, object]]) -> None:
|
||||
self.lines: list[bytes] = []
|
||||
for payload in payloads:
|
||||
self.lines.append(b'event: message\n')
|
||||
self.lines.append(f'data: {json.dumps(payload)}\n'.encode('utf-8'))
|
||||
self.lines.append(b'\n')
|
||||
|
||||
def readline(self) -> bytes:
|
||||
if not self.lines:
|
||||
return b''
|
||||
return self.lines.pop(0)
|
||||
|
||||
def __enter__(self) -> 'FakeStreamingHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class TestJoinUrl(unittest.TestCase):
|
||||
def test_base_with_trailing_slash(self):
|
||||
self.assertEqual(_join_url('http://localhost:8000/', 'v1/chat'), 'http://localhost:8000/v1/chat')
|
||||
|
||||
def test_base_without_trailing_slash(self):
|
||||
self.assertEqual(_join_url('http://localhost:8000', 'v1/chat'), 'http://localhost:8000/v1/chat')
|
||||
|
||||
def test_suffix_with_leading_slash(self):
|
||||
self.assertEqual(_join_url('http://localhost:8000', '/v1/chat'), 'http://localhost:8000/v1/chat')
|
||||
|
||||
|
||||
class TestNormalizeContent(unittest.TestCase):
|
||||
def test_string_passthrough(self):
|
||||
self.assertEqual(_normalize_content('hello'), 'hello')
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
self.assertEqual(_normalize_content(None), '')
|
||||
|
||||
def test_list_of_strings_joined(self):
|
||||
self.assertEqual(_normalize_content(['hello', ' ', 'world']), 'hello world')
|
||||
|
||||
def test_list_of_text_dicts(self):
|
||||
items = [{'type': 'text', 'text': 'hello'}, {'type': 'text', 'text': ' world'}]
|
||||
self.assertEqual(_normalize_content(items), 'hello world')
|
||||
|
||||
def test_list_of_mixed_items(self):
|
||||
items = ['start ', {'type': 'text', 'text': 'middle'}, ' end']
|
||||
self.assertEqual(_normalize_content(items), 'start middle end')
|
||||
|
||||
def test_non_string_non_list_returns_str(self):
|
||||
self.assertEqual(_normalize_content(42), '42')
|
||||
|
||||
|
||||
class TestParseToolArguments(unittest.TestCase):
|
||||
def test_dict_passthrough(self):
|
||||
d = {'key': 'value'}
|
||||
self.assertIs(_parse_tool_arguments(d), d)
|
||||
|
||||
def test_valid_json_string(self):
|
||||
self.assertEqual(_parse_tool_arguments('{"a": 1}'), {'a': 1})
|
||||
|
||||
def test_empty_string_returns_empty_dict(self):
|
||||
self.assertEqual(_parse_tool_arguments(''), {})
|
||||
|
||||
def test_none_returns_empty_dict(self):
|
||||
self.assertEqual(_parse_tool_arguments(None), {})
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
with self.assertRaises(OpenAICompatError):
|
||||
_parse_tool_arguments('{bad json}')
|
||||
|
||||
def test_json_non_dict_raises(self):
|
||||
with self.assertRaises(OpenAICompatError):
|
||||
_parse_tool_arguments('[1, 2, 3]')
|
||||
|
||||
def test_unsupported_type_raises(self):
|
||||
with self.assertRaises(OpenAICompatError):
|
||||
_parse_tool_arguments(12345)
|
||||
|
||||
|
||||
class TestParseUsage(unittest.TestCase):
|
||||
def test_standard_fields(self):
|
||||
usage = _parse_usage({'input_tokens': 10, 'output_tokens': 20})
|
||||
self.assertEqual(usage.input_tokens, 10)
|
||||
self.assertEqual(usage.output_tokens, 20)
|
||||
|
||||
def test_prompt_completion_aliases(self):
|
||||
usage = _parse_usage({'prompt_tokens': 15, 'completion_tokens': 25})
|
||||
self.assertEqual(usage.input_tokens, 15)
|
||||
self.assertEqual(usage.output_tokens, 25)
|
||||
|
||||
def test_ollama_aliases(self):
|
||||
usage = _parse_usage({'prompt_eval_count': 12, 'eval_count': 18})
|
||||
self.assertEqual(usage.input_tokens, 12)
|
||||
self.assertEqual(usage.output_tokens, 18)
|
||||
|
||||
def test_cache_tokens(self):
|
||||
usage = _parse_usage({
|
||||
'input_tokens': 1,
|
||||
'output_tokens': 1,
|
||||
'cache_creation_input_tokens': 100,
|
||||
'cache_read_input_tokens': 200,
|
||||
})
|
||||
self.assertEqual(usage.cache_creation_input_tokens, 100)
|
||||
self.assertEqual(usage.cache_read_input_tokens, 200)
|
||||
|
||||
def test_reasoning_tokens_top_level_and_details(self):
|
||||
usage_top = _parse_usage({'input_tokens': 1, 'output_tokens': 1, 'reasoning_tokens': 50})
|
||||
self.assertEqual(usage_top.reasoning_tokens, 50)
|
||||
|
||||
usage_details = _parse_usage({
|
||||
'input_tokens': 1,
|
||||
'output_tokens': 1,
|
||||
'completion_tokens_details': {'reasoning_tokens': 75},
|
||||
})
|
||||
self.assertEqual(usage_details.reasoning_tokens, 75)
|
||||
|
||||
def test_non_dict_returns_empty(self):
|
||||
usage = _parse_usage('not a dict')
|
||||
self.assertEqual(usage, UsageStats())
|
||||
|
||||
def test_string_number_coercion(self):
|
||||
usage = _parse_usage({'input_tokens': '10', 'output_tokens': '20'})
|
||||
self.assertEqual(usage.input_tokens, 10)
|
||||
self.assertEqual(usage.output_tokens, 20)
|
||||
|
||||
|
||||
class TestBuildResponseFormat(unittest.TestCase):
|
||||
def test_none_returns_none(self):
|
||||
self.assertIsNone(_build_response_format(None))
|
||||
|
||||
def test_valid_schema(self):
|
||||
schema = OutputSchemaConfig(
|
||||
name='test_schema',
|
||||
schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
||||
strict=True,
|
||||
)
|
||||
result = _build_response_format(schema)
|
||||
self.assertEqual(result, {
|
||||
'type': 'json_schema',
|
||||
'json_schema': {
|
||||
'name': 'test_schema',
|
||||
'schema': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
|
||||
'strict': True,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class TestOptionalInt(unittest.TestCase):
|
||||
def test_int_passthrough(self):
|
||||
self.assertEqual(_optional_int(42), 42)
|
||||
|
||||
def test_float_truncated(self):
|
||||
self.assertEqual(_optional_int(3.9), 3)
|
||||
|
||||
def test_string_parsed(self):
|
||||
self.assertEqual(_optional_int('7'), 7)
|
||||
|
||||
def test_bool_returns_zero(self):
|
||||
self.assertEqual(_optional_int(True), 0)
|
||||
self.assertEqual(_optional_int(False), 0)
|
||||
|
||||
def test_none_returns_zero(self):
|
||||
self.assertEqual(_optional_int(None), 0)
|
||||
|
||||
def test_invalid_string_returns_zero(self):
|
||||
self.assertEqual(_optional_int('abc'), 0)
|
||||
|
||||
|
||||
class TestProviderTemperatureFloor(unittest.TestCase):
|
||||
def test_minimax_temperature_is_clamped_to_provider_minimum(self):
|
||||
self.assertEqual(_temperature_for_model('minimax/MiniMax-M2.5', 0.0), 0.01)
|
||||
|
||||
def test_wenxin_temperature_is_clamped_to_provider_minimum(self):
|
||||
self.assertEqual(_temperature_for_model('wenxin/ernie-4.0-turbo-128k', 0.0), 0.1)
|
||||
|
||||
def test_regular_models_keep_configured_temperature(self):
|
||||
self.assertEqual(_temperature_for_model('xiaomi/mimo-v2-flash', 0.0), 0.0)
|
||||
|
||||
def test_payload_uses_temperature_floor(self):
|
||||
client = OpenAICompatClient(
|
||||
ModelConfig(model='minimax/MiniMax-M2.5', temperature=0.0)
|
||||
)
|
||||
payload = client._build_payload( # noqa: SLF001 - verify payload compatibility.
|
||||
messages=[{'role': 'user', 'content': 'hi'}],
|
||||
tools=[],
|
||||
stream=False,
|
||||
output_schema=None,
|
||||
)
|
||||
self.assertEqual(payload['temperature'], 0.01)
|
||||
|
||||
def test_payload_omits_tool_choice_when_no_tools_are_supplied(self):
|
||||
client = OpenAICompatClient(ModelConfig(model='azure_openai/gpt-4o-mini'))
|
||||
payload = client._build_payload( # noqa: SLF001 - verify provider compatibility.
|
||||
messages=[{'role': 'user', 'content': 'hi'}],
|
||||
tools=[],
|
||||
stream=False,
|
||||
output_schema=None,
|
||||
)
|
||||
self.assertNotIn('tools', payload)
|
||||
self.assertNotIn('tool_choice', payload)
|
||||
|
||||
def test_payload_includes_tool_choice_when_tools_are_supplied(self):
|
||||
client = OpenAICompatClient(ModelConfig(model='azure_openai/gpt-4o-mini'))
|
||||
payload = client._build_payload( # noqa: SLF001 - verify provider compatibility.
|
||||
messages=[{'role': 'user', 'content': 'hi'}],
|
||||
tools=[{'type': 'function', 'function': {'name': 'noop', 'parameters': {}}}],
|
||||
stream=False,
|
||||
output_schema=None,
|
||||
)
|
||||
self.assertIn('tools', payload)
|
||||
self.assertEqual(payload['tool_choice'], 'auto')
|
||||
|
||||
|
||||
class TestAnthropicMessagesRouting(unittest.TestCase):
|
||||
def test_ppio_pa_claude_uses_anthropic_messages_api(self):
|
||||
self.assertTrue(
|
||||
_uses_anthropic_messages_api(
|
||||
'ppio/pa/claude-opus-4-7',
|
||||
'http://model.mify.ai.srv/v1',
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
_uses_anthropic_messages_api(
|
||||
'ppio/gemini-2.5-pro',
|
||||
'http://model.mify.ai.srv/v1',
|
||||
)
|
||||
)
|
||||
|
||||
def test_anthropic_base_url_is_derived_from_openai_base(self):
|
||||
self.assertEqual(
|
||||
_anthropic_base_url('http://model.mify.ai.srv/v1'),
|
||||
'http://model.mify.ai.srv/anthropic',
|
||||
)
|
||||
self.assertEqual(
|
||||
_anthropic_base_url('http://model.mify.ai.srv/anthropic'),
|
||||
'http://model.mify.ai.srv/anthropic',
|
||||
)
|
||||
|
||||
def test_anthropic_complete_converts_tools_and_parses_tool_use(self):
|
||||
recorded: dict[str, object] = {}
|
||||
|
||||
def fake_urlopen(request_obj, timeout=None): # noqa: ANN001
|
||||
recorded['url'] = request_obj.full_url
|
||||
recorded['payload'] = json.loads(request_obj.data.decode('utf-8'))
|
||||
return FakeHTTPResponse(
|
||||
{
|
||||
'id': 'msg_1',
|
||||
'type': 'message',
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'text', 'text': '我来读取文件。'},
|
||||
{
|
||||
'type': 'tool_use',
|
||||
'id': 'toolu_1',
|
||||
'name': 'read_file',
|
||||
'input': {'path': 'hello.txt'},
|
||||
},
|
||||
],
|
||||
'stop_reason': 'tool_use',
|
||||
'usage': {'input_tokens': 10, 'output_tokens': 4},
|
||||
}
|
||||
)
|
||||
|
||||
client = OpenAICompatClient(
|
||||
ModelConfig(
|
||||
model='ppio/pa/claude-opus-4-7',
|
||||
base_url='http://model.mify.ai.srv/v1',
|
||||
api_key='token',
|
||||
)
|
||||
)
|
||||
with patch('src.openai_compat.request.urlopen', side_effect=fake_urlopen):
|
||||
turn = client.complete(
|
||||
messages=[
|
||||
{'role': 'system', 'content': '你是工具型助手。'},
|
||||
{'role': 'user', 'content': '读取 hello.txt'},
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'read_file',
|
||||
'description': '读取文件',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {'path': {'type': 'string'}},
|
||||
'required': ['path'],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
recorded['url'],
|
||||
'http://model.mify.ai.srv/anthropic/v1/messages',
|
||||
)
|
||||
payload = recorded['payload']
|
||||
self.assertEqual(payload['model'], 'ppio/pa/claude-opus-4-7')
|
||||
self.assertEqual(payload['system'], '你是工具型助手。')
|
||||
self.assertEqual(payload['tools'][0]['name'], 'read_file')
|
||||
self.assertEqual(payload['tools'][0]['input_schema']['required'], ['path'])
|
||||
self.assertEqual(turn.content, '我来读取文件。')
|
||||
self.assertEqual(turn.finish_reason, 'tool_use')
|
||||
self.assertEqual(turn.tool_calls[0].id, 'toolu_1')
|
||||
self.assertEqual(turn.tool_calls[0].arguments, {'path': 'hello.txt'})
|
||||
self.assertEqual(turn.usage.input_tokens, 10)
|
||||
|
||||
def test_anthropic_stream_parses_text_usage_and_tool_use(self):
|
||||
payloads = [
|
||||
{
|
||||
'type': 'message_start',
|
||||
'message': {'usage': {'input_tokens': 7, 'output_tokens': 1}},
|
||||
},
|
||||
{
|
||||
'type': 'content_block_start',
|
||||
'index': 0,
|
||||
'content_block': {'type': 'text', 'text': ''},
|
||||
},
|
||||
{
|
||||
'type': 'content_block_delta',
|
||||
'index': 0,
|
||||
'delta': {'type': 'text_delta', 'text': '读取'},
|
||||
},
|
||||
{
|
||||
'type': 'content_block_start',
|
||||
'index': 1,
|
||||
'content_block': {
|
||||
'type': 'tool_use',
|
||||
'id': 'toolu_1',
|
||||
'name': 'read_file',
|
||||
'input': {},
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'content_block_delta',
|
||||
'index': 1,
|
||||
'delta': {'type': 'input_json_delta', 'partial_json': '{"path":'},
|
||||
},
|
||||
{
|
||||
'type': 'content_block_delta',
|
||||
'index': 1,
|
||||
'delta': {'type': 'input_json_delta', 'partial_json': '"hello.txt"}'},
|
||||
},
|
||||
{
|
||||
'type': 'message_delta',
|
||||
'delta': {'stop_reason': 'tool_use'},
|
||||
'usage': {'output_tokens': 5},
|
||||
},
|
||||
{'type': 'message_stop'},
|
||||
]
|
||||
|
||||
def fake_urlopen(request_obj, timeout=None): # noqa: ANN001
|
||||
return FakeStreamingHTTPResponse(payloads)
|
||||
|
||||
client = OpenAICompatClient(
|
||||
ModelConfig(
|
||||
model='ppio/pa/claude-opus-4-7',
|
||||
base_url='http://model.mify.ai.srv/v1',
|
||||
api_key='token',
|
||||
)
|
||||
)
|
||||
with patch('src.openai_compat.request.urlopen', side_effect=fake_urlopen):
|
||||
events = list(
|
||||
client.stream(
|
||||
messages=[{'role': 'user', 'content': '读取 hello.txt'}],
|
||||
tools=[
|
||||
{
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'read_file',
|
||||
'parameters': {'type': 'object'},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(events[0].type, 'message_start')
|
||||
self.assertEqual(
|
||||
''.join(event.delta for event in events if event.type == 'content_delta'),
|
||||
'读取',
|
||||
)
|
||||
tool_events = [event for event in events if event.type == 'tool_call_delta']
|
||||
self.assertEqual(tool_events[0].tool_call_index, 0)
|
||||
self.assertEqual(tool_events[0].tool_call_id, 'toolu_1')
|
||||
self.assertEqual(tool_events[0].tool_name, 'read_file')
|
||||
self.assertEqual(
|
||||
''.join(event.arguments_delta for event in tool_events),
|
||||
'{"path":"hello.txt"}',
|
||||
)
|
||||
self.assertTrue(any(event.type == 'usage' for event in events))
|
||||
self.assertEqual(events[-1].type, 'message_stop')
|
||||
self.assertEqual(events[-1].finish_reason, 'tool_use')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,87 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.permissions import ToolPermissionContext
|
||||
|
||||
|
||||
class TestToolPermissionContext(unittest.TestCase):
|
||||
# 1. Empty context blocks nothing
|
||||
def test_empty_context_blocks_nothing(self) -> None:
|
||||
ctx = ToolPermissionContext()
|
||||
self.assertFalse(ctx.blocks("anything"))
|
||||
self.assertFalse(ctx.blocks(""))
|
||||
|
||||
# 2. Exact name blocking (case insensitive)
|
||||
def test_exact_name_blocking(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(deny_names=["dangerous_tool"])
|
||||
self.assertTrue(ctx.blocks("dangerous_tool"))
|
||||
self.assertTrue(ctx.blocks("Dangerous_Tool"))
|
||||
self.assertTrue(ctx.blocks("DANGEROUS_TOOL"))
|
||||
self.assertFalse(ctx.blocks("safe_tool"))
|
||||
|
||||
# 3. Prefix blocking (case insensitive)
|
||||
def test_prefix_blocking(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(deny_prefixes=["admin_"])
|
||||
self.assertTrue(ctx.blocks("admin_delete"))
|
||||
self.assertTrue(ctx.blocks("Admin_Delete"))
|
||||
self.assertTrue(ctx.blocks("ADMIN_CREATE"))
|
||||
self.assertFalse(ctx.blocks("user_admin"))
|
||||
|
||||
# 4. Combined name + prefix blocking
|
||||
def test_combined_name_and_prefix_blocking(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(
|
||||
deny_names=["rm"],
|
||||
deny_prefixes=["sudo_"],
|
||||
)
|
||||
self.assertTrue(ctx.blocks("rm"))
|
||||
self.assertTrue(ctx.blocks("sudo_restart"))
|
||||
self.assertFalse(ctx.blocks("ls"))
|
||||
|
||||
# 5. Non-matching names are allowed
|
||||
def test_non_matching_names_allowed(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(
|
||||
deny_names=["blocked"],
|
||||
deny_prefixes=["bad_"],
|
||||
)
|
||||
self.assertFalse(ctx.blocks("allowed"))
|
||||
self.assertFalse(ctx.blocks("good_tool"))
|
||||
self.assertFalse(ctx.blocks("not_bad"))
|
||||
|
||||
# 6. from_iterables with None args
|
||||
def test_from_iterables_none_args(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(deny_names=None, deny_prefixes=None)
|
||||
self.assertEqual(ctx.deny_names, frozenset())
|
||||
self.assertEqual(ctx.deny_prefixes, ())
|
||||
self.assertFalse(ctx.blocks("anything"))
|
||||
|
||||
def test_from_iterables_default_args(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables()
|
||||
self.assertEqual(ctx.deny_names, frozenset())
|
||||
self.assertEqual(ctx.deny_prefixes, ())
|
||||
|
||||
# 7. from_iterables normalizes to lowercase
|
||||
def test_from_iterables_normalizes_to_lowercase(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(
|
||||
deny_names=["FooBar"],
|
||||
deny_prefixes=["PFX_"],
|
||||
)
|
||||
self.assertIn("foobar", ctx.deny_names)
|
||||
self.assertNotIn("FooBar", ctx.deny_names)
|
||||
self.assertEqual(ctx.deny_prefixes, ("pfx_",))
|
||||
self.assertTrue(ctx.blocks("FOOBAR"))
|
||||
self.assertTrue(ctx.blocks("pfx_something"))
|
||||
|
||||
# 8. Multiple deny_names
|
||||
def test_multiple_deny_names(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(
|
||||
deny_names=["tool_a", "tool_b", "tool_c"],
|
||||
)
|
||||
self.assertTrue(ctx.blocks("tool_a"))
|
||||
self.assertTrue(ctx.blocks("tool_b"))
|
||||
self.assertTrue(ctx.blocks("tool_c"))
|
||||
self.assertFalse(ctx.blocks("tool_d"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,197 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig
|
||||
from src.plan_runtime import PlanRuntime
|
||||
from src.task_runtime import TaskRuntime
|
||||
|
||||
|
||||
class FakeHTTPResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode('utf-8')
|
||||
|
||||
def __enter__(self) -> 'FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_urlopen_side_effect(responses: list[dict[str, object]]):
|
||||
queued = [FakeHTTPResponse(payload) for payload in responses]
|
||||
|
||||
def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001
|
||||
return queued.pop(0)
|
||||
|
||||
return _fake_urlopen
|
||||
|
||||
|
||||
class PlanRuntimeTests(unittest.TestCase):
|
||||
def test_runtime_persists_and_syncs_plan_to_tasks(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
task_runtime = TaskRuntime.from_workspace(workspace)
|
||||
plan_runtime = PlanRuntime.from_workspace(workspace)
|
||||
mutation = plan_runtime.update_plan(
|
||||
[
|
||||
{
|
||||
'step': 'Inspect the runtime loop',
|
||||
'status': 'in_progress',
|
||||
'description': 'Read the core agent files first.',
|
||||
},
|
||||
{
|
||||
'step': 'Patch the tool registry',
|
||||
'status': 'blocked',
|
||||
'depends_on': ['plan_1'],
|
||||
},
|
||||
],
|
||||
explanation='Work through the runtime in two phases.',
|
||||
task_runtime=task_runtime,
|
||||
)
|
||||
rendered_plan = plan_runtime.render_plan()
|
||||
rendered_tasks = task_runtime.render_tasks()
|
||||
rendered_task = task_runtime.render_task('plan_2')
|
||||
|
||||
self.assertEqual(mutation.after_count, 2)
|
||||
self.assertEqual(mutation.synced_tasks, 2)
|
||||
self.assertIn('Inspect the runtime loop', rendered_plan)
|
||||
self.assertIn('Work through the runtime in two phases.', rendered_plan)
|
||||
self.assertIn('depends_on: plan_1', rendered_plan)
|
||||
self.assertIn('Inspect the runtime loop', rendered_tasks)
|
||||
self.assertIn('Blocked By: plan_1', rendered_task)
|
||||
|
||||
def test_plan_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
task_runtime = TaskRuntime.from_workspace(workspace)
|
||||
plan_runtime = PlanRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
plan_runtime=plan_runtime,
|
||||
task_runtime=task_runtime,
|
||||
)
|
||||
update_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'update_plan',
|
||||
{
|
||||
'explanation': 'Follow the current plan.',
|
||||
'items': [
|
||||
{'step': 'Inspect the workspace', 'status': 'completed'},
|
||||
{'step': 'Implement the fix', 'status': 'in_progress'},
|
||||
],
|
||||
},
|
||||
context,
|
||||
)
|
||||
get_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'plan_get',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
clear_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'plan_clear',
|
||||
{'sync_tasks': True},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(update_result.ok)
|
||||
self.assertEqual(update_result.metadata.get('total_steps'), 2)
|
||||
self.assertEqual(update_result.metadata.get('synced_tasks'), 2)
|
||||
self.assertIn('# Plan', get_result.content)
|
||||
self.assertTrue(clear_result.ok)
|
||||
self.assertEqual(clear_result.metadata.get('total_steps'), 0)
|
||||
|
||||
def test_agent_can_use_update_plan_tool_in_model_loop(self) -> None:
|
||||
responses = [
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'I will store the plan first.',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_1',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'update_plan',
|
||||
'arguments': json.dumps(
|
||||
{
|
||||
'explanation': 'Start with a plan.',
|
||||
'items': [
|
||||
{
|
||||
'step': 'Inspect the current files',
|
||||
'status': 'in_progress',
|
||||
},
|
||||
{
|
||||
'step': 'Apply the code changes',
|
||||
'status': 'pending',
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
'finish_reason': 'tool_calls',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 8, 'completion_tokens': 3},
|
||||
},
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'The plan was stored successfully.',
|
||||
},
|
||||
'finish_reason': 'stop',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 6, 'completion_tokens': 3},
|
||||
},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
with patch(
|
||||
'src.openai_compat.request.urlopen',
|
||||
side_effect=make_urlopen_side_effect(responses),
|
||||
):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(
|
||||
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
|
||||
base_url='http://127.0.0.1:8000/v1',
|
||||
),
|
||||
runtime_config=AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
)
|
||||
result = agent.run('Store the current plan')
|
||||
self.assertIsNotNone(result.scratchpad_directory)
|
||||
self.assertTrue(
|
||||
(Path(result.scratchpad_directory) / 'plan_runtime.json').exists()
|
||||
)
|
||||
self.assertFalse((workspace / '.port_sessions' / 'plan_runtime.json').exists())
|
||||
|
||||
self.assertEqual(result.final_output, 'The plan was stored successfully.')
|
||||
self.assertEqual(result.tool_calls, 1)
|
||||
tool_message = next(
|
||||
message for message in result.transcript if message.get('role') == 'tool'
|
||||
)
|
||||
self.assertIn('update_plan', tool_message.get('content', ''))
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Tests for ``src/platform_info.py`` — platform detection and system dirs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from src import platform_info
|
||||
from src.platform_info import (
|
||||
SUPPORTED_PLATFORMS,
|
||||
LinuxDistroInfo,
|
||||
SystemDirectories,
|
||||
detect_vcs,
|
||||
get_linux_distro_info,
|
||||
get_platform,
|
||||
get_system_directories,
|
||||
get_wsl_version,
|
||||
)
|
||||
|
||||
|
||||
class GetPlatformTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
platform_info._reset_cache()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
platform_info._reset_cache()
|
||||
|
||||
def test_macos(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'darwin'):
|
||||
self.assertEqual(get_platform(), 'macos')
|
||||
|
||||
def test_windows(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'win32'):
|
||||
self.assertEqual(get_platform(), 'windows')
|
||||
|
||||
def test_linux_no_wsl(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'linux'), \
|
||||
mock.patch(
|
||||
'src.platform_info._read_proc_version',
|
||||
return_value='Linux version 5.10 (gcc)',
|
||||
):
|
||||
self.assertEqual(get_platform(), 'linux')
|
||||
|
||||
def test_linux_wsl_microsoft_marker(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'linux'), \
|
||||
mock.patch(
|
||||
'src.platform_info._read_proc_version',
|
||||
return_value='Linux version 5.10 microsoft-standard-WSL2',
|
||||
):
|
||||
self.assertEqual(get_platform(), 'wsl')
|
||||
|
||||
def test_linux_proc_version_unreadable(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'linux'), \
|
||||
mock.patch(
|
||||
'src.platform_info._read_proc_version',
|
||||
side_effect=FileNotFoundError(),
|
||||
):
|
||||
self.assertEqual(get_platform(), 'linux')
|
||||
|
||||
def test_unknown_platform(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'sunos5'):
|
||||
self.assertEqual(get_platform(), 'unknown')
|
||||
|
||||
def test_memoized(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'darwin'):
|
||||
self.assertEqual(get_platform(), 'macos')
|
||||
# Second call should hit cache, not re-evaluate sys.platform
|
||||
with mock.patch('src.platform_info.sys.platform', 'win32'):
|
||||
self.assertEqual(get_platform(), 'macos')
|
||||
|
||||
def test_supported_platforms_contains_expected(self) -> None:
|
||||
self.assertIn('macos', SUPPORTED_PLATFORMS)
|
||||
self.assertIn('wsl', SUPPORTED_PLATFORMS)
|
||||
|
||||
|
||||
class GetWslVersionTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
platform_info._reset_cache()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
platform_info._reset_cache()
|
||||
|
||||
def test_explicit_wsl2(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'linux'), \
|
||||
mock.patch(
|
||||
'src.platform_info._read_proc_version',
|
||||
return_value='5.15.123-microsoft-standard-WSL2',
|
||||
):
|
||||
self.assertEqual(get_wsl_version(), '2')
|
||||
|
||||
def test_wsl1_fallback(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'linux'), \
|
||||
mock.patch(
|
||||
'src.platform_info._read_proc_version',
|
||||
return_value='4.4.0-19041-Microsoft (Microsoft@Microsoft.com)',
|
||||
):
|
||||
self.assertEqual(get_wsl_version(), '1')
|
||||
|
||||
def test_non_linux(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'darwin'):
|
||||
self.assertIsNone(get_wsl_version())
|
||||
|
||||
def test_linux_no_microsoft_marker(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'linux'), \
|
||||
mock.patch(
|
||||
'src.platform_info._read_proc_version',
|
||||
return_value='Linux version 6.5.0 (gcc)',
|
||||
):
|
||||
self.assertIsNone(get_wsl_version())
|
||||
|
||||
|
||||
class GetLinuxDistroInfoTest(unittest.TestCase):
|
||||
def test_non_linux_returns_none(self) -> None:
|
||||
with mock.patch('src.platform_info.sys.platform', 'darwin'):
|
||||
self.assertIsNone(get_linux_distro_info())
|
||||
|
||||
def test_parses_id_and_version(self) -> None:
|
||||
os_release = 'NAME="Ubuntu"\nID=ubuntu\nVERSION_ID="22.04"\n'
|
||||
with mock.patch('src.platform_info.sys.platform', 'linux'), \
|
||||
mock.patch(
|
||||
'src.platform_info.Path.read_text', return_value=os_release,
|
||||
):
|
||||
info = get_linux_distro_info()
|
||||
assert info is not None
|
||||
self.assertEqual(info.linux_distro_id, 'ubuntu')
|
||||
self.assertEqual(info.linux_distro_version, '22.04')
|
||||
self.assertIsNotNone(info.linux_kernel)
|
||||
|
||||
def test_to_dict_skips_none(self) -> None:
|
||||
info = LinuxDistroInfo(linux_distro_id='fedora')
|
||||
self.assertEqual(info.to_dict(), {'linuxDistroId': 'fedora'})
|
||||
|
||||
|
||||
class DetectVcsTest(unittest.TestCase):
|
||||
def test_detects_git(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.git').mkdir()
|
||||
self.assertEqual(detect_vcs(tmp), ['git'])
|
||||
|
||||
def test_detects_multiple(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.git').mkdir()
|
||||
(Path(tmp) / '.hg').mkdir()
|
||||
self.assertEqual(detect_vcs(tmp), ['git', 'mercurial'])
|
||||
|
||||
def test_perforce_via_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp, \
|
||||
mock.patch.dict(os.environ, {'P4PORT': '1666'}):
|
||||
self.assertIn('perforce', detect_vcs(tmp))
|
||||
|
||||
def test_unreadable_directory_returns_empty(self) -> None:
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(detect_vcs('/nonexistent/path/abc/xyz'), [])
|
||||
|
||||
|
||||
class GetSystemDirectoriesTest(unittest.TestCase):
|
||||
def test_macos_defaults(self) -> None:
|
||||
dirs = get_system_directories(
|
||||
home_dir='/Users/x', platform='macos', env={},
|
||||
)
|
||||
self.assertEqual(dirs.HOME, '/Users/x')
|
||||
self.assertEqual(dirs.DESKTOP, '/Users/x/Desktop')
|
||||
self.assertEqual(dirs.DOCUMENTS, '/Users/x/Documents')
|
||||
self.assertEqual(dirs.DOWNLOADS, '/Users/x/Downloads')
|
||||
|
||||
def test_windows_uses_userprofile(self) -> None:
|
||||
dirs = get_system_directories(
|
||||
home_dir='C:/Users/old',
|
||||
platform='windows',
|
||||
env={'USERPROFILE': 'C:/Users/new'},
|
||||
)
|
||||
# Path normalizes to forward slashes on linux test runs; just check
|
||||
# USERPROFILE was used as the base, not home_dir.
|
||||
self.assertIn('Users/new', dirs.DESKTOP.replace('\\', '/'))
|
||||
self.assertIn('Users/new', dirs.DOWNLOADS.replace('\\', '/'))
|
||||
# HOME stays as the explicit home_dir
|
||||
self.assertEqual(dirs.HOME, 'C:/Users/old')
|
||||
|
||||
def test_linux_xdg_overrides(self) -> None:
|
||||
dirs = get_system_directories(
|
||||
home_dir='/home/u',
|
||||
platform='linux',
|
||||
env={'XDG_DOWNLOAD_DIR': '/data/dl'},
|
||||
)
|
||||
self.assertEqual(dirs.DOWNLOADS, '/data/dl')
|
||||
self.assertEqual(dirs.DESKTOP, '/home/u/Desktop')
|
||||
|
||||
def test_wsl_xdg_overrides(self) -> None:
|
||||
dirs = get_system_directories(
|
||||
home_dir='/home/u',
|
||||
platform='wsl',
|
||||
env={'XDG_DESKTOP_DIR': '/mnt/c/Users/x/Desktop'},
|
||||
)
|
||||
self.assertEqual(dirs.DESKTOP, '/mnt/c/Users/x/Desktop')
|
||||
|
||||
def test_to_dict_round_trip(self) -> None:
|
||||
dirs = SystemDirectories(
|
||||
HOME='/h', DESKTOP='/h/D', DOCUMENTS='/h/Doc', DOWNLOADS='/h/Dn',
|
||||
)
|
||||
self.assertEqual(
|
||||
dirs.to_dict(),
|
||||
{'HOME': '/h', 'DESKTOP': '/h/D', 'DOCUMENTS': '/h/Doc', 'DOWNLOADS': '/h/Dn'},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,240 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.commands import PORTED_COMMANDS
|
||||
from src.parity_audit import run_parity_audit
|
||||
from src.port_manifest import build_port_manifest
|
||||
from src.query_engine import QueryEnginePort
|
||||
from src.tools import PORTED_TOOLS
|
||||
|
||||
|
||||
class PortingWorkspaceTests(unittest.TestCase):
|
||||
def test_manifest_counts_python_files(self) -> None:
|
||||
manifest = build_port_manifest()
|
||||
self.assertGreaterEqual(manifest.total_python_files, 20)
|
||||
self.assertTrue(manifest.top_level_modules)
|
||||
|
||||
def test_query_engine_summary_mentions_workspace(self) -> None:
|
||||
summary = QueryEnginePort.from_workspace().render_summary()
|
||||
self.assertIn('Python Porting Workspace Summary', summary)
|
||||
self.assertIn('Command surface:', summary)
|
||||
self.assertIn('Tool surface:', summary)
|
||||
|
||||
def test_cli_summary_runs(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'summary'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Python Porting Workspace Summary', result.stdout)
|
||||
|
||||
def test_parity_audit_runs(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'parity-audit'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Parity Audit', result.stdout)
|
||||
|
||||
def test_root_file_coverage_is_complete_when_local_archive_exists(self) -> None:
|
||||
audit = run_parity_audit()
|
||||
if audit.archive_present:
|
||||
self.assertGreaterEqual(audit.root_file_coverage[0], 8)
|
||||
self.assertGreaterEqual(audit.directory_coverage[0], 3)
|
||||
self.assertGreaterEqual(audit.command_entry_ratio[0], 150)
|
||||
self.assertGreaterEqual(audit.tool_entry_ratio[0], 100)
|
||||
|
||||
def test_command_and_tool_snapshots_are_nontrivial(self) -> None:
|
||||
self.assertGreaterEqual(len(PORTED_COMMANDS), 150)
|
||||
self.assertGreaterEqual(len(PORTED_TOOLS), 100)
|
||||
|
||||
def test_commands_and_tools_cli_run(self) -> None:
|
||||
commands_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'commands', '--limit', '5', '--query', 'review'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
tools_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'tools', '--limit', '5', '--query', 'MCP'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Command entries:', commands_result.stdout)
|
||||
self.assertIn('Tool entries:', tools_result.stdout)
|
||||
|
||||
def test_route_and_show_entry_cli_run(self) -> None:
|
||||
route_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'route', 'review MCP tool', '--limit', '5'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
show_command = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'show-command', 'review'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
show_tool = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'show-tool', 'MCPTool'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('review', route_result.stdout.lower())
|
||||
self.assertIn('review', show_command.stdout.lower())
|
||||
self.assertIn('mcptool', show_tool.stdout.lower())
|
||||
|
||||
def test_bootstrap_cli_runs(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'bootstrap', 'review MCP tool', '--limit', '5'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Runtime Session', result.stdout)
|
||||
self.assertIn('Startup Steps', result.stdout)
|
||||
self.assertIn('Routed Matches', result.stdout)
|
||||
|
||||
def test_bootstrap_session_tracks_turn_state(self) -> None:
|
||||
from src.runtime import PortRuntime
|
||||
|
||||
session = PortRuntime().bootstrap_session('review MCP tool', limit=5)
|
||||
self.assertGreaterEqual(len(session.turn_result.matched_tools), 1)
|
||||
self.assertIn('Prompt:', session.turn_result.output)
|
||||
self.assertGreaterEqual(session.turn_result.usage.input_tokens, 1)
|
||||
|
||||
def test_exec_command_and_tool_cli_run(self) -> None:
|
||||
command_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'exec-command', 'review', 'inspect security review'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
tool_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'exec-tool', 'MCPTool', 'fetch resource list'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn("Mirrored command 'review'", command_result.stdout)
|
||||
self.assertIn("Mirrored tool 'MCPTool'", tool_result.stdout)
|
||||
|
||||
def test_setup_report_and_registry_filters_run(self) -> None:
|
||||
setup_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'setup-report'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
command_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'commands', '--limit', '5', '--no-plugin-commands'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
tool_result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'tools', '--limit', '5', '--simple-mode', '--no-mcp'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Setup Report', setup_result.stdout)
|
||||
self.assertIn('Command entries:', command_result.stdout)
|
||||
self.assertIn('Tool entries:', tool_result.stdout)
|
||||
|
||||
def test_load_session_cli_runs(self) -> None:
|
||||
from src.runtime import PortRuntime
|
||||
|
||||
session = PortRuntime().bootstrap_session('review MCP tool', limit=5)
|
||||
session_id = Path(session.persisted_session_path).stem
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'load-session', session_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn(session_id, result.stdout)
|
||||
self.assertIn('messages', result.stdout)
|
||||
|
||||
def test_tool_permission_filtering_cli_runs(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'tools', '--limit', '10', '--deny-prefix', 'mcp'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Tool entries:', result.stdout)
|
||||
self.assertNotIn('MCPTool', result.stdout)
|
||||
|
||||
def test_turn_loop_cli_runs(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'turn-loop', 'review MCP tool', '--max-turns', '2', '--structured-output'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('## Turn 1', result.stdout)
|
||||
self.assertIn('stop_reason=', result.stdout)
|
||||
|
||||
def test_remote_mode_clis_run(self) -> None:
|
||||
remote_result = subprocess.run([sys.executable, '-m', 'src.main', 'remote-mode', 'workspace'], check=True, capture_output=True, text=True)
|
||||
ssh_result = subprocess.run([sys.executable, '-m', 'src.main', 'ssh-mode', 'workspace'], check=True, capture_output=True, text=True)
|
||||
teleport_result = subprocess.run([sys.executable, '-m', 'src.main', 'teleport-mode', 'workspace'], check=True, capture_output=True, text=True)
|
||||
self.assertIn('mode=remote', remote_result.stdout)
|
||||
self.assertIn('mode=ssh', ssh_result.stdout)
|
||||
self.assertIn('mode=teleport', teleport_result.stdout)
|
||||
|
||||
def test_flush_transcript_cli_runs(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'flush-transcript', 'review MCP tool'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('flushed=True', result.stdout)
|
||||
|
||||
def test_command_graph_and_tool_pool_cli_run(self) -> None:
|
||||
command_graph = subprocess.run([sys.executable, '-m', 'src.main', 'command-graph'], check=True, capture_output=True, text=True)
|
||||
tool_pool = subprocess.run([sys.executable, '-m', 'src.main', 'tool-pool'], check=True, capture_output=True, text=True)
|
||||
self.assertIn('Command Graph', command_graph.stdout)
|
||||
self.assertIn('Tool Pool', tool_pool.stdout)
|
||||
|
||||
def test_setup_report_mentions_deferred_init(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'setup-report'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Deferred init:', result.stdout)
|
||||
self.assertIn('plugin_init=True', result.stdout)
|
||||
|
||||
def test_execution_registry_runs(self) -> None:
|
||||
from src.execution_registry import build_execution_registry
|
||||
|
||||
registry = build_execution_registry()
|
||||
self.assertGreaterEqual(len(registry.commands), 150)
|
||||
self.assertGreaterEqual(len(registry.tools), 100)
|
||||
self.assertIn('Mirrored command', registry.command('review').execute('review security'))
|
||||
self.assertIn('Mirrored tool', registry.tool('MCPTool').execute('fetch mcp resources'))
|
||||
|
||||
def test_bootstrap_graph_and_direct_modes_run(self) -> None:
|
||||
graph_result = subprocess.run([sys.executable, '-m', 'src.main', 'bootstrap-graph'], check=True, capture_output=True, text=True)
|
||||
direct_result = subprocess.run([sys.executable, '-m', 'src.main', 'direct-connect-mode', 'workspace'], check=True, capture_output=True, text=True)
|
||||
deep_link_result = subprocess.run([sys.executable, '-m', 'src.main', 'deep-link-mode', 'workspace'], check=True, capture_output=True, text=True)
|
||||
self.assertIn('Bootstrap Graph', graph_result.stdout)
|
||||
self.assertIn('mode=direct-connect', direct_result.stdout)
|
||||
self.assertIn('mode=deep-link', deep_link_result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,643 +0,0 @@
|
||||
"""Tests for prompt_constants module.
|
||||
|
||||
Validates that all constants ported from npm src/constants/ are present,
|
||||
correctly typed, and that helper functions behave as expected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.prompt_constants import (
|
||||
# Product metadata
|
||||
PRODUCT_URL,
|
||||
CLAUDE_AI_BASE_URL,
|
||||
# System prompt prefixes
|
||||
DEFAULT_SYSPROMPT_PREFIX,
|
||||
AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX,
|
||||
AGENT_SDK_PREFIX,
|
||||
CLI_SYSPROMPT_PREFIXES,
|
||||
# Cyber risk
|
||||
CYBER_RISK_INSTRUCTION,
|
||||
# API limits
|
||||
API_IMAGE_MAX_BASE64_SIZE,
|
||||
IMAGE_TARGET_RAW_SIZE,
|
||||
IMAGE_MAX_WIDTH,
|
||||
IMAGE_MAX_HEIGHT,
|
||||
PDF_TARGET_RAW_SIZE,
|
||||
API_PDF_MAX_PAGES,
|
||||
PDF_EXTRACT_SIZE_THRESHOLD,
|
||||
PDF_MAX_EXTRACT_SIZE,
|
||||
PDF_MAX_PAGES_PER_READ,
|
||||
PDF_AT_MENTION_INLINE_THRESHOLD,
|
||||
API_MAX_MEDIA_PER_REQUEST,
|
||||
# Tool limits
|
||||
DEFAULT_MAX_RESULT_SIZE_CHARS,
|
||||
MAX_TOOL_RESULT_TOKENS,
|
||||
BYTES_PER_TOKEN,
|
||||
MAX_TOOL_RESULT_BYTES,
|
||||
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS,
|
||||
TOOL_SUMMARY_MAX_LENGTH,
|
||||
# Spinner verbs
|
||||
SPINNER_VERBS,
|
||||
# Turn completion verbs
|
||||
TURN_COMPLETION_VERBS,
|
||||
# Figures
|
||||
BLACK_CIRCLE,
|
||||
BULLET_OPERATOR,
|
||||
TEARDROP_ASTERISK,
|
||||
UP_ARROW,
|
||||
DOWN_ARROW,
|
||||
LIGHTNING_BOLT,
|
||||
EFFORT_LOW,
|
||||
EFFORT_MEDIUM,
|
||||
EFFORT_HIGH,
|
||||
EFFORT_MAX,
|
||||
PLAY_ICON,
|
||||
PAUSE_ICON,
|
||||
REFRESH_ARROW,
|
||||
CHANNEL_ARROW,
|
||||
INJECTED_ARROW,
|
||||
FORK_GLYPH,
|
||||
DIAMOND_OPEN,
|
||||
DIAMOND_FILLED,
|
||||
REFERENCE_MARK,
|
||||
FLAG_ICON,
|
||||
BLOCKQUOTE_BAR,
|
||||
HEAVY_HORIZONTAL,
|
||||
BRIDGE_SPINNER_FRAMES,
|
||||
BRIDGE_READY_INDICATOR,
|
||||
BRIDGE_FAILED_INDICATOR,
|
||||
# XML tags
|
||||
COMMAND_NAME_TAG,
|
||||
COMMAND_MESSAGE_TAG,
|
||||
COMMAND_ARGS_TAG,
|
||||
BASH_INPUT_TAG,
|
||||
BASH_STDOUT_TAG,
|
||||
BASH_STDERR_TAG,
|
||||
LOCAL_COMMAND_STDOUT_TAG,
|
||||
LOCAL_COMMAND_STDERR_TAG,
|
||||
LOCAL_COMMAND_CAVEAT_TAG,
|
||||
TERMINAL_OUTPUT_TAGS,
|
||||
TICK_TAG,
|
||||
TASK_NOTIFICATION_TAG,
|
||||
TASK_ID_TAG,
|
||||
TOOL_USE_ID_TAG,
|
||||
TASK_TYPE_TAG,
|
||||
OUTPUT_FILE_TAG,
|
||||
STATUS_TAG,
|
||||
SUMMARY_TAG,
|
||||
REASON_TAG,
|
||||
WORKTREE_TAG,
|
||||
WORKTREE_PATH_TAG,
|
||||
WORKTREE_BRANCH_TAG,
|
||||
ULTRAPLAN_TAG,
|
||||
REMOTE_REVIEW_TAG,
|
||||
REMOTE_REVIEW_PROGRESS_TAG,
|
||||
TEAMMATE_MESSAGE_TAG,
|
||||
CHANNEL_MESSAGE_TAG,
|
||||
CHANNEL_TAG,
|
||||
CROSS_SESSION_MESSAGE_TAG,
|
||||
FORK_BOILERPLATE_TAG,
|
||||
FORK_DIRECTIVE_PREFIX,
|
||||
COMMON_HELP_ARGS,
|
||||
COMMON_INFO_ARGS,
|
||||
# Messages
|
||||
NO_CONTENT_MESSAGE,
|
||||
# Date utilities
|
||||
get_local_iso_date,
|
||||
get_session_start_date,
|
||||
reset_session_start_date,
|
||||
get_local_month_year,
|
||||
# System prompt section caching
|
||||
SystemPromptSection,
|
||||
system_prompt_section,
|
||||
dangerous_uncached_system_prompt_section,
|
||||
resolve_system_prompt_sections,
|
||||
clear_system_prompt_sections,
|
||||
# Output styles
|
||||
DEFAULT_OUTPUT_STYLE_NAME,
|
||||
OutputStyleConfig,
|
||||
OUTPUT_STYLE_CONFIGS,
|
||||
# Knowledge cutoff
|
||||
FRONTIER_MODEL_NAME,
|
||||
get_knowledge_cutoff,
|
||||
CLAUDE_MODEL_IDS,
|
||||
# Prompt sections
|
||||
HOOKS_SECTION,
|
||||
SYSTEM_REMINDERS_SECTION,
|
||||
SUMMARIZE_TOOL_RESULTS_SECTION,
|
||||
DEFAULT_AGENT_PROMPT,
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
get_language_section,
|
||||
get_output_style_section,
|
||||
get_scratchpad_instructions,
|
||||
# Error IDs
|
||||
E_TOOL_USE_SUMMARY_GENERATION_FAILED,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Product metadata
|
||||
# =========================================================================
|
||||
|
||||
class TestProductMetadata:
|
||||
def test_product_url(self):
|
||||
assert PRODUCT_URL == "https://claude.com/claude-code"
|
||||
|
||||
def test_claude_ai_base_url(self):
|
||||
assert CLAUDE_AI_BASE_URL == "https://claude.ai"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# System prompt prefixes
|
||||
# =========================================================================
|
||||
|
||||
class TestSystemPromptPrefixes:
|
||||
def test_default_prefix_content(self):
|
||||
assert "Claude Code" in DEFAULT_SYSPROMPT_PREFIX
|
||||
assert "Anthropic" in DEFAULT_SYSPROMPT_PREFIX
|
||||
|
||||
def test_agent_sdk_prefix_content(self):
|
||||
assert "Agent SDK" in AGENT_SDK_PREFIX
|
||||
|
||||
def test_cli_sysprompt_prefixes_is_frozenset(self):
|
||||
assert isinstance(CLI_SYSPROMPT_PREFIXES, frozenset)
|
||||
assert len(CLI_SYSPROMPT_PREFIXES) == 3
|
||||
|
||||
def test_all_prefixes_in_set(self):
|
||||
assert DEFAULT_SYSPROMPT_PREFIX in CLI_SYSPROMPT_PREFIXES
|
||||
assert AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX in CLI_SYSPROMPT_PREFIXES
|
||||
assert AGENT_SDK_PREFIX in CLI_SYSPROMPT_PREFIXES
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cyber risk
|
||||
# =========================================================================
|
||||
|
||||
class TestCyberRisk:
|
||||
def test_instruction_mentions_ctf(self):
|
||||
assert "CTF" in CYBER_RISK_INSTRUCTION
|
||||
|
||||
def test_instruction_mentions_dos(self):
|
||||
assert "DoS" in CYBER_RISK_INSTRUCTION
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API limits
|
||||
# =========================================================================
|
||||
|
||||
class TestAPILimits:
|
||||
def test_image_base64_size(self):
|
||||
assert API_IMAGE_MAX_BASE64_SIZE == 5 * 1024 * 1024
|
||||
|
||||
def test_image_target_raw_size(self):
|
||||
assert IMAGE_TARGET_RAW_SIZE == (API_IMAGE_MAX_BASE64_SIZE * 3) // 4
|
||||
|
||||
def test_image_dimensions(self):
|
||||
assert IMAGE_MAX_WIDTH == 2000
|
||||
assert IMAGE_MAX_HEIGHT == 2000
|
||||
|
||||
def test_pdf_target_raw_size(self):
|
||||
assert PDF_TARGET_RAW_SIZE == 20 * 1024 * 1024
|
||||
|
||||
def test_pdf_max_pages(self):
|
||||
assert API_PDF_MAX_PAGES == 100
|
||||
|
||||
def test_pdf_extract_threshold(self):
|
||||
assert PDF_EXTRACT_SIZE_THRESHOLD == 3 * 1024 * 1024
|
||||
|
||||
def test_pdf_max_extract_size(self):
|
||||
assert PDF_MAX_EXTRACT_SIZE == 100 * 1024 * 1024
|
||||
|
||||
def test_pdf_pages_per_read(self):
|
||||
assert PDF_MAX_PAGES_PER_READ == 20
|
||||
|
||||
def test_pdf_inline_threshold(self):
|
||||
assert PDF_AT_MENTION_INLINE_THRESHOLD == 10
|
||||
|
||||
def test_media_per_request(self):
|
||||
assert API_MAX_MEDIA_PER_REQUEST == 100
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Tool limits
|
||||
# =========================================================================
|
||||
|
||||
class TestToolLimits:
|
||||
def test_default_max_result_size(self):
|
||||
assert DEFAULT_MAX_RESULT_SIZE_CHARS == 50_000
|
||||
|
||||
def test_max_tool_result_tokens(self):
|
||||
assert MAX_TOOL_RESULT_TOKENS == 100_000
|
||||
|
||||
def test_bytes_per_token(self):
|
||||
assert BYTES_PER_TOKEN == 4
|
||||
|
||||
def test_max_tool_result_bytes_derived(self):
|
||||
assert MAX_TOOL_RESULT_BYTES == MAX_TOOL_RESULT_TOKENS * BYTES_PER_TOKEN
|
||||
assert MAX_TOOL_RESULT_BYTES == 400_000
|
||||
|
||||
def test_max_per_message_chars(self):
|
||||
assert MAX_TOOL_RESULTS_PER_MESSAGE_CHARS == 200_000
|
||||
|
||||
def test_tool_summary_max_length(self):
|
||||
assert TOOL_SUMMARY_MAX_LENGTH == 50
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Spinner verbs
|
||||
# =========================================================================
|
||||
|
||||
class TestSpinnerVerbs:
|
||||
def test_is_tuple(self):
|
||||
assert isinstance(SPINNER_VERBS, tuple)
|
||||
|
||||
def test_count(self):
|
||||
assert len(SPINNER_VERBS) == 187
|
||||
|
||||
def test_first_verb(self):
|
||||
assert SPINNER_VERBS[0] == "Accomplishing"
|
||||
|
||||
def test_last_verb(self):
|
||||
assert SPINNER_VERBS[-1] == "Zigzagging"
|
||||
|
||||
def test_all_strings(self):
|
||||
for verb in SPINNER_VERBS:
|
||||
assert isinstance(verb, str)
|
||||
|
||||
def test_contains_clauding(self):
|
||||
assert "Clauding" in SPINNER_VERBS
|
||||
|
||||
def test_contains_thinking(self):
|
||||
assert "Thinking" in SPINNER_VERBS
|
||||
|
||||
def test_no_duplicates(self):
|
||||
assert len(SPINNER_VERBS) == len(set(SPINNER_VERBS))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Turn completion verbs
|
||||
# =========================================================================
|
||||
|
||||
class TestTurnCompletionVerbs:
|
||||
def test_is_tuple(self):
|
||||
assert isinstance(TURN_COMPLETION_VERBS, tuple)
|
||||
|
||||
def test_count(self):
|
||||
assert len(TURN_COMPLETION_VERBS) == 8
|
||||
|
||||
def test_contains_worked(self):
|
||||
assert "Worked" in TURN_COMPLETION_VERBS
|
||||
|
||||
def test_contains_baked(self):
|
||||
assert "Baked" in TURN_COMPLETION_VERBS
|
||||
|
||||
def test_all_past_tense(self):
|
||||
# All end in 'd' (past tense)
|
||||
for verb in TURN_COMPLETION_VERBS:
|
||||
assert verb[-1] == "d", f"{verb} doesn't end with 'd'"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Figures / UI symbols
|
||||
# =========================================================================
|
||||
|
||||
class TestFigures:
|
||||
def test_black_circle_is_string(self):
|
||||
assert isinstance(BLACK_CIRCLE, str)
|
||||
assert len(BLACK_CIRCLE) == 1
|
||||
|
||||
def test_effort_symbols_are_distinct(self):
|
||||
symbols = {EFFORT_LOW, EFFORT_MEDIUM, EFFORT_HIGH, EFFORT_MAX}
|
||||
assert len(symbols) == 4
|
||||
|
||||
def test_arrows(self):
|
||||
assert UP_ARROW == "\u2191"
|
||||
assert DOWN_ARROW == "\u2193"
|
||||
|
||||
def test_bridge_spinner_frames(self):
|
||||
assert isinstance(BRIDGE_SPINNER_FRAMES, tuple)
|
||||
assert len(BRIDGE_SPINNER_FRAMES) == 4
|
||||
|
||||
def test_play_pause_icons(self):
|
||||
assert PLAY_ICON == "\u25b6"
|
||||
assert PAUSE_ICON == "\u23f8"
|
||||
|
||||
def test_diamond_symbols(self):
|
||||
assert DIAMOND_OPEN == "\u25c7"
|
||||
assert DIAMOND_FILLED == "\u25c6"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# XML tag constants
|
||||
# =========================================================================
|
||||
|
||||
class TestXMLTags:
|
||||
def test_command_tags(self):
|
||||
assert COMMAND_NAME_TAG == "command-name"
|
||||
assert COMMAND_MESSAGE_TAG == "command-message"
|
||||
assert COMMAND_ARGS_TAG == "command-args"
|
||||
|
||||
def test_bash_tags(self):
|
||||
assert BASH_INPUT_TAG == "bash-input"
|
||||
assert BASH_STDOUT_TAG == "bash-stdout"
|
||||
assert BASH_STDERR_TAG == "bash-stderr"
|
||||
|
||||
def test_terminal_output_tags_tuple(self):
|
||||
assert isinstance(TERMINAL_OUTPUT_TAGS, tuple)
|
||||
assert len(TERMINAL_OUTPUT_TAGS) == 6
|
||||
assert BASH_INPUT_TAG in TERMINAL_OUTPUT_TAGS
|
||||
assert LOCAL_COMMAND_STDOUT_TAG in TERMINAL_OUTPUT_TAGS
|
||||
|
||||
def test_tick_tag(self):
|
||||
assert TICK_TAG == "tick"
|
||||
|
||||
def test_task_tags(self):
|
||||
assert TASK_NOTIFICATION_TAG == "task-notification"
|
||||
assert TASK_ID_TAG == "task-id"
|
||||
assert TOOL_USE_ID_TAG == "tool-use-id"
|
||||
|
||||
def test_worktree_tags(self):
|
||||
assert WORKTREE_TAG == "worktree"
|
||||
assert WORKTREE_PATH_TAG == "worktreePath"
|
||||
|
||||
def test_fork_tags(self):
|
||||
assert FORK_BOILERPLATE_TAG == "fork-boilerplate"
|
||||
assert FORK_DIRECTIVE_PREFIX == "Your directive: "
|
||||
|
||||
def test_common_help_args(self):
|
||||
assert isinstance(COMMON_HELP_ARGS, tuple)
|
||||
assert "help" in COMMON_HELP_ARGS
|
||||
assert "-h" in COMMON_HELP_ARGS
|
||||
assert "--help" in COMMON_HELP_ARGS
|
||||
|
||||
def test_common_info_args(self):
|
||||
assert isinstance(COMMON_INFO_ARGS, tuple)
|
||||
assert "list" in COMMON_INFO_ARGS
|
||||
assert "status" in COMMON_INFO_ARGS
|
||||
assert "?" in COMMON_INFO_ARGS
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Message constants
|
||||
# =========================================================================
|
||||
|
||||
class TestMessages:
|
||||
def test_no_content_message(self):
|
||||
assert NO_CONTENT_MESSAGE == "(no content)"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Date utilities
|
||||
# =========================================================================
|
||||
|
||||
class TestDateUtilities:
|
||||
def test_get_local_iso_date_format(self):
|
||||
d = get_local_iso_date()
|
||||
parts = d.split("-")
|
||||
assert len(parts) == 3
|
||||
assert len(parts[0]) == 4 # year
|
||||
assert len(parts[1]) == 2 # month
|
||||
assert len(parts[2]) == 2 # day
|
||||
|
||||
def test_get_local_iso_date_override(self):
|
||||
with patch.dict(os.environ, {"CLAUDE_CODE_OVERRIDE_DATE": "2025-01-15"}):
|
||||
assert get_local_iso_date() == "2025-01-15"
|
||||
|
||||
def test_get_session_start_date_memoised(self):
|
||||
reset_session_start_date()
|
||||
d1 = get_session_start_date()
|
||||
d2 = get_session_start_date()
|
||||
assert d1 == d2
|
||||
|
||||
def test_reset_session_start_date(self):
|
||||
reset_session_start_date()
|
||||
d = get_session_start_date()
|
||||
assert isinstance(d, str)
|
||||
reset_session_start_date()
|
||||
# After reset, should still return valid date
|
||||
d2 = get_session_start_date()
|
||||
assert isinstance(d2, str)
|
||||
|
||||
def test_get_local_month_year_format(self):
|
||||
result = get_local_month_year()
|
||||
parts = result.split()
|
||||
assert len(parts) == 2
|
||||
assert parts[1].isdigit()
|
||||
assert len(parts[1]) == 4
|
||||
|
||||
def test_get_local_month_year_override(self):
|
||||
with patch.dict(os.environ, {"CLAUDE_CODE_OVERRIDE_DATE": "2026-02-15"}):
|
||||
assert get_local_month_year() == "February 2026"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# System prompt section caching
|
||||
# =========================================================================
|
||||
|
||||
class TestSystemPromptSections:
|
||||
def setup_method(self):
|
||||
clear_system_prompt_sections()
|
||||
|
||||
def test_system_prompt_section_creates_cached(self):
|
||||
s = system_prompt_section("test", lambda: "hello")
|
||||
assert s.name == "test"
|
||||
assert s.cache_break is False
|
||||
|
||||
def test_dangerous_uncached_creates_volatile(self):
|
||||
s = dangerous_uncached_system_prompt_section("test", lambda: "hello", "reason")
|
||||
assert s.name == "test"
|
||||
assert s.cache_break is True
|
||||
|
||||
def test_resolve_caches_sections(self):
|
||||
call_count = 0
|
||||
|
||||
def compute():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"value-{call_count}"
|
||||
|
||||
sections = [system_prompt_section("s1", compute)]
|
||||
r1 = resolve_system_prompt_sections(sections)
|
||||
r2 = resolve_system_prompt_sections(sections)
|
||||
assert r1 == ["value-1"]
|
||||
assert r2 == ["value-1"] # cached
|
||||
assert call_count == 1
|
||||
|
||||
def test_uncached_recomputes(self):
|
||||
call_count = 0
|
||||
|
||||
def compute():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"value-{call_count}"
|
||||
|
||||
sections = [dangerous_uncached_system_prompt_section("s2", compute, "test")]
|
||||
r1 = resolve_system_prompt_sections(sections)
|
||||
r2 = resolve_system_prompt_sections(sections)
|
||||
assert r1 == ["value-1"]
|
||||
assert r2 == ["value-2"] # recomputed
|
||||
assert call_count == 2
|
||||
|
||||
def test_clear_resets_cache(self):
|
||||
call_count = 0
|
||||
|
||||
def compute():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"value-{call_count}"
|
||||
|
||||
sections = [system_prompt_section("s3", compute)]
|
||||
resolve_system_prompt_sections(sections)
|
||||
clear_system_prompt_sections()
|
||||
r = resolve_system_prompt_sections(sections)
|
||||
assert r == ["value-2"]
|
||||
assert call_count == 2
|
||||
|
||||
def test_resolve_handles_none(self):
|
||||
sections = [system_prompt_section("nil", lambda: None)]
|
||||
r = resolve_system_prompt_sections(sections)
|
||||
assert r == [None]
|
||||
|
||||
def test_multiple_sections(self):
|
||||
sections = [
|
||||
system_prompt_section("a", lambda: "alpha"),
|
||||
system_prompt_section("b", lambda: "beta"),
|
||||
system_prompt_section("c", lambda: None),
|
||||
]
|
||||
r = resolve_system_prompt_sections(sections)
|
||||
assert r == ["alpha", "beta", None]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Output styles
|
||||
# =========================================================================
|
||||
|
||||
class TestOutputStyles:
|
||||
def test_default_style_name(self):
|
||||
assert DEFAULT_OUTPUT_STYLE_NAME == "default"
|
||||
|
||||
def test_default_style_is_none(self):
|
||||
assert OUTPUT_STYLE_CONFIGS[DEFAULT_OUTPUT_STYLE_NAME] is None
|
||||
|
||||
def test_explanatory_exists(self):
|
||||
style = OUTPUT_STYLE_CONFIGS["Explanatory"]
|
||||
assert style is not None
|
||||
assert style.name == "Explanatory"
|
||||
assert "explains" in style.description
|
||||
|
||||
def test_learning_exists(self):
|
||||
style = OUTPUT_STYLE_CONFIGS["Learning"]
|
||||
assert style is not None
|
||||
assert style.name == "Learning"
|
||||
assert "hands-on" in style.description
|
||||
|
||||
def test_output_style_config_frozen(self):
|
||||
style = OutputStyleConfig(
|
||||
name="Test", description="desc", prompt="prompt"
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
style.name = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Knowledge cutoff
|
||||
# =========================================================================
|
||||
|
||||
class TestKnowledgeCutoff:
|
||||
def test_frontier_model_name(self):
|
||||
assert FRONTIER_MODEL_NAME == "Claude Opus 4.6"
|
||||
|
||||
def test_opus_46_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-opus-4-6-20250601") == "May 2025"
|
||||
|
||||
def test_sonnet_46_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-sonnet-4-6-20250801") == "August 2025"
|
||||
|
||||
def test_opus_45_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-opus-4-5-20250601") == "May 2025"
|
||||
|
||||
def test_haiku_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-haiku-4-20250201") == "February 2025"
|
||||
|
||||
def test_sonnet_4_cutoff(self):
|
||||
assert get_knowledge_cutoff("claude-sonnet-4-20250114") == "January 2025"
|
||||
|
||||
def test_unknown_model_returns_none(self):
|
||||
assert get_knowledge_cutoff("gpt-4-turbo") is None
|
||||
|
||||
def test_claude_model_ids(self):
|
||||
assert "opus" in CLAUDE_MODEL_IDS
|
||||
assert "sonnet" in CLAUDE_MODEL_IDS
|
||||
assert "haiku" in CLAUDE_MODEL_IDS
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Prompt section helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestPromptSectionHelpers:
|
||||
def test_hooks_section_content(self):
|
||||
assert "hooks" in HOOKS_SECTION
|
||||
assert "user-prompt-submit-hook" in HOOKS_SECTION
|
||||
|
||||
def test_system_reminders_section(self):
|
||||
assert "system-reminder" in SYSTEM_REMINDERS_SECTION
|
||||
|
||||
def test_summarize_tool_results(self):
|
||||
assert "tool results" in SUMMARIZE_TOOL_RESULTS_SECTION
|
||||
|
||||
def test_default_agent_prompt(self):
|
||||
assert "agent for Claude Code" in DEFAULT_AGENT_PROMPT
|
||||
|
||||
def test_dynamic_boundary(self):
|
||||
assert SYSTEM_PROMPT_DYNAMIC_BOUNDARY == "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"
|
||||
|
||||
def test_language_section_none_when_no_preference(self):
|
||||
assert get_language_section(None) is None
|
||||
assert get_language_section("") is None
|
||||
|
||||
def test_language_section_with_preference(self):
|
||||
result = get_language_section("Spanish")
|
||||
assert result is not None
|
||||
assert "Spanish" in result
|
||||
assert "# Language" in result
|
||||
|
||||
def test_output_style_section_none_when_no_config(self):
|
||||
assert get_output_style_section(None) is None
|
||||
|
||||
def test_output_style_section_with_config(self):
|
||||
config = OutputStyleConfig(
|
||||
name="TestStyle",
|
||||
description="A test style",
|
||||
prompt="Be concise.",
|
||||
)
|
||||
result = get_output_style_section(config)
|
||||
assert result is not None
|
||||
assert "# Output Style: TestStyle" in result
|
||||
assert "Be concise." in result
|
||||
|
||||
def test_scratchpad_none_when_no_dir(self):
|
||||
assert get_scratchpad_instructions(None) is None
|
||||
assert get_scratchpad_instructions("") is None
|
||||
|
||||
def test_scratchpad_with_dir(self):
|
||||
result = get_scratchpad_instructions("/tmp/session-123")
|
||||
assert result is not None
|
||||
assert "/tmp/session-123" in result
|
||||
assert "# Scratchpad Directory" in result
|
||||
assert "temporary files" in result
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Error IDs
|
||||
# =========================================================================
|
||||
|
||||
class TestErrorIDs:
|
||||
def test_tool_use_summary_error(self):
|
||||
assert E_TOOL_USE_SUMMARY_GENERATION_FAILED == 344
|
||||
@@ -1,237 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||
|
||||
|
||||
class PythonExecToolTests(TestCase):
|
||||
def test_python_exec_runs_inline_code_with_shell_permission(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=Path(tmp_dir),
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
)
|
||||
context = build_tool_context(config)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_exec',
|
||||
{'code': 'print("hello from python")'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('exit_code=0', result.content)
|
||||
self.assertIn('hello from python', result.content)
|
||||
self.assertEqual(result.metadata.get('action'), 'python_exec')
|
||||
self.assertEqual(result.metadata.get('mode'), 'code')
|
||||
|
||||
def test_python_exec_exposes_session_scratchpad(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
scratchpad = (root / 'session' / 'scratchpad').resolve()
|
||||
scratchpad.mkdir(parents=True)
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=root,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
)
|
||||
context = build_tool_context(config, scratchpad_directory=scratchpad)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_exec',
|
||||
{'code': 'import os\nprint(os.environ["PYTHON_EXEC_SCRATCHPAD"])'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn(str(scratchpad), result.content)
|
||||
self.assertEqual(result.metadata.get('scratchpad_directory'), str(scratchpad))
|
||||
|
||||
def test_python_exec_runs_from_session_scratchpad(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
scratchpad = (root / 'session' / 'scratchpad').resolve()
|
||||
scratchpad.mkdir(parents=True)
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=root,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
)
|
||||
context = build_tool_context(config, scratchpad_directory=scratchpad)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_exec',
|
||||
{'code': 'import os\nprint(os.getcwd())'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn(str(scratchpad), result.content)
|
||||
|
||||
def test_python_exec_prefers_user_python_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
user_env = (root / 'account' / 'python' / '.venv').resolve()
|
||||
bin_dir = user_env / 'bin'
|
||||
bin_dir.mkdir(parents=True)
|
||||
python_bin = bin_dir / 'python'
|
||||
try:
|
||||
python_bin.symlink_to(sys.executable)
|
||||
except OSError:
|
||||
python_bin.write_text(
|
||||
'#!/bin/sh\nexec "$PYTHON_EXEC_TEST_REAL_PYTHON" "$@"\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
python_bin.chmod(0o755)
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=root,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
python_env_dir=user_env,
|
||||
)
|
||||
context = build_tool_context(
|
||||
config,
|
||||
extra_env={'PYTHON_EXEC_TEST_REAL_PYTHON': sys.executable},
|
||||
)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_exec',
|
||||
{'code': 'import os, sys\nprint(sys.executable)\nprint(os.environ["PYTHON_EXEC_VENV"])'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn(str(user_env), result.content)
|
||||
self.assertEqual(result.metadata.get('python_env_dir'), str(user_env))
|
||||
|
||||
def test_python_package_show_uses_user_python_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
user_env = (root / 'account' / 'python' / '.venv').resolve()
|
||||
bin_dir = user_env / 'bin'
|
||||
bin_dir.mkdir(parents=True)
|
||||
python_bin = bin_dir / 'python'
|
||||
try:
|
||||
python_bin.symlink_to(sys.executable)
|
||||
except OSError:
|
||||
python_bin.write_text(
|
||||
'#!/bin/sh\nexec "$PYTHON_EXEC_TEST_REAL_PYTHON" "$@"\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
python_bin.chmod(0o755)
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=root,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
python_env_dir=user_env,
|
||||
)
|
||||
context = build_tool_context(
|
||||
config,
|
||||
extra_env={'PYTHON_EXEC_TEST_REAL_PYTHON': sys.executable},
|
||||
)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_package',
|
||||
{'action': 'show'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn(str(user_env), result.content)
|
||||
self.assertEqual(result.metadata.get('python_env_dir'), str(user_env))
|
||||
|
||||
def test_python_exec_is_blocked_without_shell_permission(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = AgentRuntimeConfig(cwd=Path(tmp_dir))
|
||||
context = build_tool_context(config)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_exec',
|
||||
{'code': 'print("blocked")'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn('--allow-shell', result.content)
|
||||
self.assertEqual(result.metadata.get('error_kind'), 'permission_denied')
|
||||
|
||||
def test_python_exec_runs_workspace_script_with_args(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
script = root / 'tool_script.py'
|
||||
script.write_text(
|
||||
'import sys\nprint("|".join(sys.argv[1:]))\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=root,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
)
|
||||
context = build_tool_context(config)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_exec',
|
||||
{'script_path': 'tool_script.py', 'args': ['a', 'b']},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('a|b', result.content)
|
||||
self.assertEqual(result.metadata.get('mode'), 'script')
|
||||
|
||||
def test_python_exec_timeout_kills_spawned_children(self) -> None:
|
||||
if os.name != 'posix':
|
||||
self.skipTest('process group cleanup test is POSIX-only')
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=Path(tmp_dir),
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
)
|
||||
context = build_tool_context(config)
|
||||
child_code = (
|
||||
'import subprocess, sys, time\n'
|
||||
'child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])\n'
|
||||
'print(child.pid, flush=True)\n'
|
||||
'time.sleep(60)\n'
|
||||
)
|
||||
result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'python_exec',
|
||||
{
|
||||
'code': child_code,
|
||||
'timeout_seconds': 1,
|
||||
'max_output_chars': 2000,
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertTrue(result.metadata.get('timed_out'))
|
||||
match = re.search(r'\[stdout\]\s*(\d+)', result.content)
|
||||
self.assertIsNotNone(match, result.content)
|
||||
child_pid = int(match.group(1))
|
||||
time.sleep(0.3)
|
||||
if self._pid_is_running(child_pid):
|
||||
try:
|
||||
os.kill(child_pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self.fail(f'spawned child process is still running: {child_pid}')
|
||||
|
||||
@staticmethod
|
||||
def _pid_is_running(pid: int) -> bool:
|
||||
status = subprocess.run(
|
||||
['ps', '-p', str(pid), '-o', 'stat='],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
state = status.stdout.strip()
|
||||
return bool(state) and not state.startswith('Z')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,101 +0,0 @@
|
||||
"""Tests for the local release-notes parser ported from utils/releaseNotes.ts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.release_notes import (
|
||||
MAX_RELEASE_NOTES_SHOWN,
|
||||
check_for_release_notes,
|
||||
get_all_release_notes,
|
||||
get_recent_release_notes,
|
||||
parse_changelog,
|
||||
read_local_changelog,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE = (
|
||||
'# Changelog\n\n'
|
||||
'## 1.3.0 - 2026-04-15\n'
|
||||
'- new shiny\n'
|
||||
'- another bullet\n\n'
|
||||
'## 1.2.0\n'
|
||||
'- mid bullet\n\n'
|
||||
'## 1.1.0\n'
|
||||
'- oldest bullet\n'
|
||||
)
|
||||
|
||||
|
||||
class ParseChangelogTest(unittest.TestCase):
|
||||
def test_returns_empty_for_blank(self) -> None:
|
||||
self.assertEqual(parse_changelog(''), {})
|
||||
|
||||
def test_extracts_versions_and_bullets(self) -> None:
|
||||
parsed = parse_changelog(SAMPLE)
|
||||
self.assertEqual(set(parsed.keys()), {'1.3.0', '1.2.0', '1.1.0'})
|
||||
self.assertEqual(parsed['1.3.0'], ['new shiny', 'another bullet'])
|
||||
self.assertEqual(parsed['1.2.0'], ['mid bullet'])
|
||||
|
||||
def test_skips_versions_without_bullets(self) -> None:
|
||||
parsed = parse_changelog('# X\n\n## 1.0.0\nplain text\n')
|
||||
self.assertEqual(parsed, {})
|
||||
|
||||
|
||||
class RecentNotesTest(unittest.TestCase):
|
||||
def test_returns_only_newer_versions(self) -> None:
|
||||
notes = get_recent_release_notes('1.3.0', '1.2.0', SAMPLE)
|
||||
self.assertEqual(notes, ['new shiny', 'another bullet'])
|
||||
|
||||
def test_first_run_returns_all(self) -> None:
|
||||
notes = get_recent_release_notes('1.3.0', None, SAMPLE)
|
||||
self.assertEqual(notes[0], 'new shiny')
|
||||
self.assertIn('oldest bullet', notes)
|
||||
|
||||
def test_no_new_when_at_or_below_previous(self) -> None:
|
||||
self.assertEqual(get_recent_release_notes('1.1.0', '1.3.0', SAMPLE), [])
|
||||
|
||||
def test_caps_at_max_shown(self) -> None:
|
||||
big_changelog = '# Changelog\n\n' + ''.join(
|
||||
f'## 9.9.{i}\n- bullet {i}\n\n' for i in range(20)
|
||||
)
|
||||
notes = get_recent_release_notes('9.9.19', '0.0.1', big_changelog)
|
||||
self.assertEqual(len(notes), MAX_RELEASE_NOTES_SHOWN)
|
||||
|
||||
|
||||
class AllNotesTest(unittest.TestCase):
|
||||
def test_sorted_oldest_first(self) -> None:
|
||||
all_notes = get_all_release_notes(SAMPLE)
|
||||
versions = [version for version, _ in all_notes]
|
||||
self.assertEqual(versions, ['1.1.0', '1.2.0', '1.3.0'])
|
||||
|
||||
|
||||
class ReadLocalChangelogTest(unittest.TestCase):
|
||||
def test_reads_when_present(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / 'CHANGELOG.md').write_text(SAMPLE, encoding='utf-8')
|
||||
self.assertIn('## 1.3.0', read_local_changelog(Path(tmp)))
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(read_local_changelog(Path(tmp)), '')
|
||||
|
||||
|
||||
class CheckForReleaseNotesTest(unittest.TestCase):
|
||||
def test_signals_when_changelog_present_and_newer(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / 'CHANGELOG.md').write_text(SAMPLE, encoding='utf-8')
|
||||
payload = check_for_release_notes('1.3.0', '1.2.0', cwd=Path(tmp))
|
||||
self.assertTrue(payload['hasReleaseNotes'])
|
||||
self.assertEqual(payload['releaseNotes'][0], 'new shiny')
|
||||
|
||||
def test_silent_when_missing_changelog(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
payload = check_for_release_notes('1.3.0', None, cwd=Path(tmp))
|
||||
self.assertFalse(payload['hasReleaseNotes'])
|
||||
self.assertEqual(payload['releaseNotes'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,123 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
from src.remote_runtime import (
|
||||
RemoteRuntime,
|
||||
run_deep_link_mode,
|
||||
run_direct_connect_mode,
|
||||
run_remote_mode,
|
||||
run_ssh_mode,
|
||||
run_teleport_mode,
|
||||
)
|
||||
|
||||
|
||||
class RemoteRuntimeTests(unittest.TestCase):
|
||||
def test_remote_runtime_discovers_profiles_and_persists_connection(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-remote.json').write_text(
|
||||
(
|
||||
'{"profiles":['
|
||||
'{"name":"staging","mode":"ssh","target":"dev@staging","workspaceCwd":"/srv/app"},'
|
||||
'{"name":"preview","mode":"deep-link","target":"preview://session"}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = RemoteRuntime.from_workspace(workspace)
|
||||
report = runtime.connect('staging')
|
||||
restored = RemoteRuntime.from_workspace(workspace)
|
||||
|
||||
self.assertEqual(len(runtime.profiles), 2)
|
||||
self.assertTrue(report.connected)
|
||||
self.assertEqual(report.profile_name, 'staging')
|
||||
self.assertIsNotNone(restored.active_connection)
|
||||
self.assertEqual(restored.active_connection.profile_name, 'staging')
|
||||
self.assertIn('Configured remote profiles: 2', restored.render_summary())
|
||||
|
||||
def test_remote_runtime_disconnect_clears_active_connection(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-remote.json').write_text(
|
||||
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = RemoteRuntime.from_workspace(workspace)
|
||||
runtime.connect('staging')
|
||||
report = runtime.disconnect()
|
||||
|
||||
self.assertFalse(report.connected)
|
||||
self.assertIn('Disconnected ssh target dev@staging', report.detail)
|
||||
|
||||
def test_remote_mode_helpers_use_manifest_backed_profiles(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-remote.json').write_text(
|
||||
(
|
||||
'{"profiles":['
|
||||
'{"name":"workspace","mode":"remote","target":"remote://workspace"},'
|
||||
'{"name":"sshbox","mode":"ssh","target":"dev@sshbox"},'
|
||||
'{"name":"tele","mode":"teleport","target":"teleport://workspace"},'
|
||||
'{"name":"direct","mode":"direct-connect","target":"direct://workspace"},'
|
||||
'{"name":"link","mode":"deep-link","target":"deep://workspace"}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
remote_report = run_remote_mode('workspace', cwd=workspace)
|
||||
ssh_report = run_ssh_mode('sshbox', cwd=workspace)
|
||||
teleport_report = run_teleport_mode('tele', cwd=workspace)
|
||||
direct_report = run_direct_connect_mode('direct', cwd=workspace)
|
||||
deep_link_report = run_deep_link_mode('link', cwd=workspace)
|
||||
|
||||
self.assertEqual(remote_report.profile_name, 'workspace')
|
||||
self.assertEqual(ssh_report.mode, 'ssh')
|
||||
self.assertEqual(teleport_report.mode, 'teleport')
|
||||
self.assertEqual(direct_report.mode, 'direct-connect')
|
||||
self.assertEqual(deep_link_report.mode, 'deep-link')
|
||||
|
||||
def test_remote_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-remote.json').write_text(
|
||||
(
|
||||
'{"profiles":[{"name":"staging","mode":"ssh","target":"dev@staging",'
|
||||
'"workspaceCwd":"/srv/app","sessionUrl":"wss://remote/session"}]}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = RemoteRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
remote_runtime=runtime,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'remote_list_profiles',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
connect_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'remote_connect',
|
||||
{'target': 'staging'},
|
||||
context,
|
||||
)
|
||||
status_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'remote_status',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(list_result.ok)
|
||||
self.assertIn('staging', list_result.content)
|
||||
self.assertTrue(connect_result.ok)
|
||||
self.assertIn('profile=staging', connect_result.content)
|
||||
self.assertTrue(status_result.ok)
|
||||
self.assertIn('Configured remote profiles: 1', status_result.content)
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Tests for remote/bridge slash commands ported from the npm source.
|
||||
|
||||
Covers /bridge (aliases /remote-control, /rc) and /remote-setup
|
||||
(alias /web-setup).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
|
||||
|
||||
def _make_agent(tmp_dir: str) -> LocalCodingAgent:
|
||||
return LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
|
||||
|
||||
class BridgeCommandTest(unittest.TestCase):
|
||||
def test_reports_unsupported_status(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/bridge').final_output
|
||||
self.assertIn('not implemented', out.lower())
|
||||
self.assertIn('No active local remote connection', out)
|
||||
|
||||
def test_remote_control_alias(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-control').final_output
|
||||
self.assertIn('Remote-control bridge', out)
|
||||
|
||||
def test_rc_alias(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/rc').final_output
|
||||
self.assertIn('Remote-control bridge', out)
|
||||
|
||||
def test_named_lookup_misses_unknown_profile(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/bridge nope').final_output
|
||||
self.assertIn('No matching remote profile for "nope"', out)
|
||||
|
||||
def test_named_lookup_matches_known_profile(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.remote.json').write_text(json.dumps({
|
||||
'profiles': [
|
||||
{'name': 'edge', 'mode': 'ssh', 'target': 'user@edge.example'},
|
||||
],
|
||||
}), encoding='utf-8')
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/bridge edge').final_output
|
||||
self.assertIn('Matched remote profile "edge"', out)
|
||||
self.assertIn('user@edge.example', out)
|
||||
|
||||
|
||||
class RemoteSetupCommandTest(unittest.TestCase):
|
||||
def test_includes_web_url(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-setup').final_output
|
||||
self.assertIn('https://claude.ai/code', out)
|
||||
self.assertIn('GitHub CLI', out)
|
||||
|
||||
def test_web_setup_alias(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/web-setup').final_output
|
||||
self.assertIn('https://claude.ai/code', out)
|
||||
|
||||
def test_handles_missing_gh(self) -> None:
|
||||
with mock.patch('shutil.which', return_value=None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-setup').final_output
|
||||
self.assertIn('not_installed', out)
|
||||
self.assertIn('cli.github.com', out)
|
||||
|
||||
def test_handles_authenticated_gh(self) -> None:
|
||||
fake = mock.Mock(returncode=0, stdout='Logged in to github.com as octo', stderr='')
|
||||
with mock.patch('shutil.which', return_value='/usr/bin/gh'), \
|
||||
mock.patch('subprocess.run', return_value=fake):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-setup').final_output
|
||||
self.assertIn('authenticated', out)
|
||||
self.assertIn('gh auth token', out)
|
||||
|
||||
def test_handles_unauthenticated_gh(self) -> None:
|
||||
fake = mock.Mock(returncode=1, stdout='', stderr='You are not logged into any GitHub hosts')
|
||||
with mock.patch('shutil.which', return_value='/usr/bin/gh'), \
|
||||
mock.patch('subprocess.run', return_value=fake):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/remote-setup').final_output
|
||||
self.assertIn('not_authenticated', out)
|
||||
self.assertIn('gh auth login', out)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,84 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||
from src.remote_trigger_runtime import RemoteTriggerRuntime
|
||||
|
||||
|
||||
class RemoteTriggerRuntimeTests(unittest.TestCase):
|
||||
def test_remote_trigger_runtime_discovers_and_runs_trigger(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-triggers.json').write_text(
|
||||
(
|
||||
'{"triggers":['
|
||||
'{"trigger_id":"nightly","name":"Nightly","workflow":"review",'
|
||||
'"schedule":"0 0 * * *","body":{"depth":"full"}}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = RemoteTriggerRuntime.from_workspace(workspace)
|
||||
trigger_report = runtime.render_trigger('nightly')
|
||||
run_report = runtime.render_run_report('nightly', body={'depth': 'quick'})
|
||||
|
||||
self.assertIn('trigger_id=nightly', trigger_report)
|
||||
self.assertIn('workflow=review', run_report)
|
||||
self.assertIn('"depth": "quick"', run_report)
|
||||
|
||||
def test_remote_trigger_tool_supports_create_update_run(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = RemoteTriggerRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
remote_trigger_runtime=runtime,
|
||||
)
|
||||
create_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'remote_trigger',
|
||||
{
|
||||
'action': 'create',
|
||||
'body': {
|
||||
'trigger_id': 'nightly',
|
||||
'name': 'Nightly',
|
||||
'workflow': 'review',
|
||||
'body': {'depth': 'full'},
|
||||
},
|
||||
},
|
||||
context,
|
||||
)
|
||||
update_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'remote_trigger',
|
||||
{
|
||||
'action': 'update',
|
||||
'trigger_id': 'nightly',
|
||||
'body': {'schedule': '0 0 * * *'},
|
||||
},
|
||||
context,
|
||||
)
|
||||
run_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'remote_trigger',
|
||||
{
|
||||
'action': 'run',
|
||||
'trigger_id': 'nightly',
|
||||
'body': {'depth': 'quick'},
|
||||
},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(create_result.ok)
|
||||
self.assertEqual(create_result.metadata.get('trigger_id'), 'nightly')
|
||||
self.assertTrue(update_result.ok)
|
||||
self.assertEqual(update_result.metadata.get('remote_trigger_action'), 'update')
|
||||
self.assertTrue(run_result.ok)
|
||||
self.assertIn('# Remote Trigger Run', run_result.content)
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent_platform.runtime.app import create_app
|
||||
from agent_platform.store import RuntimeStore
|
||||
|
||||
|
||||
class FakeModelProvider:
|
||||
def __init__(self) -> None:
|
||||
self.completed = []
|
||||
self.forwarded = []
|
||||
|
||||
async def complete(self, **kwargs):
|
||||
self.completed.append(kwargs)
|
||||
return {
|
||||
"choices": [{"message": {"role": "assistant", "content": "work complete"}, "finish_reason": "stop"}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
|
||||
async def forward(self, payload):
|
||||
self.forwarded.append(payload)
|
||||
request = httpx.Request("POST", "https://model.example.test/v1/chat/completions")
|
||||
if payload.get("stream"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=(
|
||||
'data: {"model":"ChatGPT-5.6:Luna","choices":[{"index":0,"delta":'
|
||||
'{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":'
|
||||
'{"name":"read_file","arguments":"{}"}}]},"finish_reason":null}]}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
),
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
headers={"content-type": "application/json"},
|
||||
json={
|
||||
"model": payload["model"],
|
||||
"choices": [{"message": {"role": "assistant", "content": "chat reply"}, "finish_reason": "stop"}],
|
||||
},
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeTools:
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
def specs(self, *, read_only=False, allow_delegate=True):
|
||||
return []
|
||||
|
||||
async def execute(self, name, arguments, context):
|
||||
raise AssertionError("No tool should be called")
|
||||
|
||||
|
||||
def headers(settings, identity_jwt, chat_id="chat-1"):
|
||||
return {
|
||||
"Authorization": f"Bearer {settings.internal_provider_key}",
|
||||
"X-OpenWebUI-User-Jwt": identity_jwt,
|
||||
"X-OpenWebUI-Chat-Id": chat_id,
|
||||
}
|
||||
|
||||
|
||||
def test_model_list_requires_internal_key(settings) -> None:
|
||||
app = create_app(
|
||||
settings,
|
||||
store=RuntimeStore(settings.database_url),
|
||||
provider=FakeModelProvider(),
|
||||
tools=FakeTools(),
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/v1/models").status_code == 401
|
||||
response = client.get(
|
||||
"/v1/models",
|
||||
headers={"Authorization": f"Bearer {settings.internal_provider_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["data"]) == 6
|
||||
|
||||
|
||||
def test_chat_maps_provider_model_and_work_upgrade_is_one_way(settings, identity_jwt) -> None:
|
||||
provider = FakeModelProvider()
|
||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
||||
with TestClient(app) as client:
|
||||
chat = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt),
|
||||
json={"model": "chat-light", "messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
assert chat.status_code == 200
|
||||
assert provider.forwarded[0]["model"] == "ChatGPT-5.6:Luna"
|
||||
assert chat.json()["model"] == "chat-light"
|
||||
|
||||
work = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt),
|
||||
json={"model": "work-high", "messages": [{"role": "user", "content": "do it"}]},
|
||||
)
|
||||
assert work.status_code == 200
|
||||
assert provider.completed[-1]["model"] == "ChatGPT-5.6:Sol"
|
||||
|
||||
downgrade = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt),
|
||||
json={"model": "chat-medium", "messages": [{"role": "user", "content": "back"}]},
|
||||
)
|
||||
assert downgrade.status_code == 409
|
||||
|
||||
|
||||
def test_chat_stream_hides_provider_model_and_preserves_native_tool_calls(settings, identity_jwt) -> None:
|
||||
provider = FakeModelProvider()
|
||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "chat-stream"),
|
||||
json={
|
||||
"model": "chat-light",
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": "inspect"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert '"model": "chat-light"' in response.text
|
||||
assert "ChatGPT-5.6:Luna" not in response.text
|
||||
assert '"tool_calls"' in response.text
|
||||
assert '"name": "read_file"' in response.text
|
||||
assert "data: [DONE]" in response.text
|
||||
|
||||
|
||||
def test_work_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> None:
|
||||
app = create_app(
|
||||
settings,
|
||||
store=RuntimeStore(settings.database_url),
|
||||
provider=FakeModelProvider(),
|
||||
tools=FakeTools(),
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "stream-chat"),
|
||||
json={
|
||||
"model": "work-medium",
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": "do it"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "work complete" in response.text
|
||||
assert "tool_calls" in response.text # rendered Work status details
|
||||
for line in response.text.splitlines():
|
||||
if line.startswith("data: {"):
|
||||
chunk = json.loads(line.removeprefix("data: "))
|
||||
assert "tool_calls" not in chunk["choices"][0]["delta"]
|
||||
assert '"finish_reason": "stop"' in response.text
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Tests for sandbox configuration types ported from sandboxTypes.ts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.sandbox_types import (
|
||||
SandboxFilesystemConfig,
|
||||
SandboxNetworkConfig,
|
||||
SandboxRipgrepConfig,
|
||||
SandboxSettings,
|
||||
)
|
||||
|
||||
|
||||
class SandboxNetworkConfigTest(unittest.TestCase):
|
||||
def test_round_trip(self) -> None:
|
||||
raw = {
|
||||
'allowedDomains': ['example.com'],
|
||||
'allowManagedDomainsOnly': True,
|
||||
'allowUnixSockets': ['/tmp/sock'],
|
||||
'allowAllUnixSockets': False,
|
||||
'allowLocalBinding': True,
|
||||
'httpProxyPort': 8080,
|
||||
'socksProxyPort': 1080,
|
||||
}
|
||||
parsed = SandboxNetworkConfig.from_dict(raw)
|
||||
self.assertEqual(parsed.allowed_domains, ['example.com'])
|
||||
self.assertEqual(parsed.http_proxy_port, 8080)
|
||||
self.assertEqual(parsed.to_dict(), raw)
|
||||
|
||||
def test_strips_none(self) -> None:
|
||||
parsed = SandboxNetworkConfig.from_dict({'allowedDomains': ['a']})
|
||||
self.assertEqual(parsed.to_dict(), {'allowedDomains': ['a']})
|
||||
|
||||
def test_rejects_wrong_type(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
SandboxNetworkConfig.from_dict({'allowedDomains': 'nope'})
|
||||
|
||||
|
||||
class SandboxFilesystemConfigTest(unittest.TestCase):
|
||||
def test_round_trip(self) -> None:
|
||||
raw = {
|
||||
'allowWrite': ['/tmp'],
|
||||
'denyWrite': ['/etc'],
|
||||
'denyRead': ['/etc/secrets'],
|
||||
'allowRead': ['/etc/secrets/public'],
|
||||
'allowManagedReadPathsOnly': False,
|
||||
}
|
||||
parsed = SandboxFilesystemConfig.from_dict(raw)
|
||||
self.assertEqual(parsed.allow_write, ['/tmp'])
|
||||
self.assertEqual(parsed.to_dict(), raw)
|
||||
|
||||
|
||||
class SandboxRipgrepConfigTest(unittest.TestCase):
|
||||
def test_requires_command(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
SandboxRipgrepConfig.from_dict({'args': ['-i']})
|
||||
|
||||
def test_round_trip(self) -> None:
|
||||
parsed = SandboxRipgrepConfig.from_dict({'command': 'rg', 'args': ['--no-ignore']})
|
||||
self.assertEqual(parsed.command, 'rg')
|
||||
self.assertEqual(parsed.to_dict(), {'command': 'rg', 'args': ['--no-ignore']})
|
||||
|
||||
|
||||
class SandboxSettingsTest(unittest.TestCase):
|
||||
def test_full_round_trip_preserves_passthrough(self) -> None:
|
||||
raw = {
|
||||
'enabled': True,
|
||||
'failIfUnavailable': False,
|
||||
'autoAllowBashIfSandboxed': True,
|
||||
'allowUnsandboxedCommands': True,
|
||||
'network': {'allowedDomains': ['x.com']},
|
||||
'filesystem': {'allowWrite': ['/tmp']},
|
||||
'ignoreViolations': {'NetworkViolation': ['y.com']},
|
||||
'enableWeakerNestedSandbox': False,
|
||||
'enableWeakerNetworkIsolation': False,
|
||||
'excludedCommands': ['rm -rf /'],
|
||||
'ripgrep': {'command': 'rg'},
|
||||
'enabledPlatforms': ['macos'],
|
||||
'somethingFuture': 42,
|
||||
}
|
||||
parsed = SandboxSettings.from_dict(raw)
|
||||
self.assertTrue(parsed.enabled)
|
||||
self.assertEqual(parsed.network.allowed_domains, ['x.com'])
|
||||
self.assertEqual(parsed.ignore_violations, {'NetworkViolation': ['y.com']})
|
||||
self.assertEqual(parsed.extra['enabledPlatforms'], ['macos'])
|
||||
self.assertEqual(parsed.extra['somethingFuture'], 42)
|
||||
|
||||
back = parsed.to_dict()
|
||||
self.assertEqual(back['enabled'], True)
|
||||
self.assertEqual(back['enabledPlatforms'], ['macos'])
|
||||
self.assertEqual(back['somethingFuture'], 42)
|
||||
|
||||
def test_empty_returns_defaults(self) -> None:
|
||||
parsed = SandboxSettings.from_dict({})
|
||||
self.assertIsNone(parsed.enabled)
|
||||
self.assertIsNone(parsed.network)
|
||||
self.assertEqual(parsed.to_dict(), {})
|
||||
|
||||
def test_rejects_non_mapping(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
SandboxSettings.from_dict('nope') # type: ignore[arg-type]
|
||||
|
||||
def test_ignore_violations_must_be_mapping(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
SandboxSettings.from_dict({'ignoreViolations': ['nope']})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Tests for SDK core types ported from entrypoints/sdk/coreTypes.ts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.sdk_core_types import (
|
||||
API_KEY_SOURCES,
|
||||
CONFIG_SCOPES,
|
||||
EXIT_REASONS,
|
||||
HOOK_EVENTS,
|
||||
SDK_BETAS,
|
||||
JsonSchemaOutputFormat,
|
||||
McpClaudeAIProxyServerConfig,
|
||||
McpHttpServerConfig,
|
||||
McpSdkServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpStdioServerConfig,
|
||||
ModelUsage,
|
||||
ThinkingAdaptive,
|
||||
ThinkingDisabled,
|
||||
ThinkingEnabled,
|
||||
mcp_server_config_from_dict,
|
||||
thinking_config_from_dict,
|
||||
)
|
||||
|
||||
|
||||
class HookEventsTest(unittest.TestCase):
|
||||
def test_includes_known_events(self) -> None:
|
||||
for required in (
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'UserPromptSubmit',
|
||||
'SessionStart',
|
||||
'PreCompact',
|
||||
'WorktreeCreate',
|
||||
'CwdChanged',
|
||||
'FileChanged',
|
||||
):
|
||||
self.assertIn(required, HOOK_EVENTS)
|
||||
|
||||
def test_no_duplicates(self) -> None:
|
||||
self.assertEqual(len(HOOK_EVENTS), len(set(HOOK_EVENTS)))
|
||||
|
||||
|
||||
class ExitReasonsTest(unittest.TestCase):
|
||||
def test_known_exit_reasons(self) -> None:
|
||||
for required in (
|
||||
'clear', 'resume', 'logout', 'prompt_input_exit',
|
||||
'other', 'bypass_permissions_disabled',
|
||||
):
|
||||
self.assertIn(required, EXIT_REASONS)
|
||||
|
||||
|
||||
class EnumLiteralsTest(unittest.TestCase):
|
||||
def test_api_key_sources(self) -> None:
|
||||
self.assertEqual(set(API_KEY_SOURCES), {'user', 'project', 'org', 'temporary', 'oauth'})
|
||||
|
||||
def test_config_scopes(self) -> None:
|
||||
self.assertEqual(set(CONFIG_SCOPES), {'local', 'user', 'project'})
|
||||
|
||||
def test_sdk_betas_known(self) -> None:
|
||||
self.assertIn('context-1m-2025-08-07', SDK_BETAS)
|
||||
|
||||
|
||||
class ModelUsageTest(unittest.TestCase):
|
||||
def test_round_trip(self) -> None:
|
||||
raw = {
|
||||
'inputTokens': 100, 'outputTokens': 50,
|
||||
'cacheReadInputTokens': 10, 'cacheCreationInputTokens': 5,
|
||||
'webSearchRequests': 0, 'costUSD': 0.001,
|
||||
'contextWindow': 200000, 'maxOutputTokens': 8192,
|
||||
}
|
||||
usage = ModelUsage.from_dict(raw)
|
||||
self.assertEqual(usage.input_tokens, 100)
|
||||
self.assertEqual(usage.cost_usd, 0.001)
|
||||
self.assertEqual(usage.to_dict(), raw)
|
||||
|
||||
|
||||
class ThinkingConfigTest(unittest.TestCase):
|
||||
def test_adaptive(self) -> None:
|
||||
cfg = thinking_config_from_dict({'type': 'adaptive'})
|
||||
self.assertIsInstance(cfg, ThinkingAdaptive)
|
||||
self.assertEqual(cfg.to_dict(), {'type': 'adaptive'})
|
||||
|
||||
def test_enabled_with_budget(self) -> None:
|
||||
cfg = thinking_config_from_dict({'type': 'enabled', 'budgetTokens': 1024})
|
||||
self.assertIsInstance(cfg, ThinkingEnabled)
|
||||
self.assertEqual(cfg.to_dict(), {'type': 'enabled', 'budgetTokens': 1024})
|
||||
|
||||
def test_enabled_without_budget(self) -> None:
|
||||
cfg = thinking_config_from_dict({'type': 'enabled'})
|
||||
self.assertEqual(cfg.to_dict(), {'type': 'enabled'})
|
||||
|
||||
def test_disabled(self) -> None:
|
||||
cfg = thinking_config_from_dict({'type': 'disabled'})
|
||||
self.assertIsInstance(cfg, ThinkingDisabled)
|
||||
|
||||
def test_unknown_type_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
thinking_config_from_dict({'type': 'something-else'})
|
||||
|
||||
|
||||
class McpServerConfigTest(unittest.TestCase):
|
||||
def test_stdio_default_type(self) -> None:
|
||||
cfg = mcp_server_config_from_dict({'command': 'npx'})
|
||||
self.assertIsInstance(cfg, McpStdioServerConfig)
|
||||
self.assertEqual(cfg.command, 'npx')
|
||||
|
||||
def test_stdio_with_args_env(self) -> None:
|
||||
cfg = mcp_server_config_from_dict({
|
||||
'type': 'stdio',
|
||||
'command': 'node',
|
||||
'args': ['./mcp.js'],
|
||||
'env': {'TOKEN': 'xyz'},
|
||||
})
|
||||
out = cfg.to_dict()
|
||||
self.assertEqual(out['command'], 'node')
|
||||
self.assertEqual(out['args'], ['./mcp.js'])
|
||||
self.assertEqual(out['env'], {'TOKEN': 'xyz'})
|
||||
|
||||
def test_sse(self) -> None:
|
||||
cfg = mcp_server_config_from_dict({'type': 'sse', 'url': 'https://x.example'})
|
||||
self.assertIsInstance(cfg, McpSSEServerConfig)
|
||||
|
||||
def test_http_with_headers(self) -> None:
|
||||
cfg = mcp_server_config_from_dict({
|
||||
'type': 'http',
|
||||
'url': 'https://x.example',
|
||||
'headers': {'Auth': 'Bearer 1'},
|
||||
})
|
||||
self.assertIsInstance(cfg, McpHttpServerConfig)
|
||||
self.assertEqual(cfg.headers, {'Auth': 'Bearer 1'})
|
||||
|
||||
def test_sdk(self) -> None:
|
||||
cfg = mcp_server_config_from_dict({'type': 'sdk', 'name': 'my-sdk'})
|
||||
self.assertIsInstance(cfg, McpSdkServerConfig)
|
||||
|
||||
def test_claudeai_proxy(self) -> None:
|
||||
cfg = mcp_server_config_from_dict({
|
||||
'type': 'claudeai-proxy',
|
||||
'url': 'https://claude.ai/p',
|
||||
'id': 'abc',
|
||||
})
|
||||
self.assertIsInstance(cfg, McpClaudeAIProxyServerConfig)
|
||||
|
||||
def test_unknown_type_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server_config_from_dict({'type': 'mystery'})
|
||||
|
||||
def test_stdio_requires_command(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server_config_from_dict({'type': 'stdio'})
|
||||
|
||||
|
||||
class JsonSchemaOutputFormatTest(unittest.TestCase):
|
||||
def test_round_trip(self) -> None:
|
||||
fmt = JsonSchemaOutputFormat.from_dict({
|
||||
'type': 'json_schema',
|
||||
'schema': {'type': 'object', 'properties': {}},
|
||||
})
|
||||
self.assertEqual(fmt.schema['type'], 'object')
|
||||
self.assertEqual(fmt.to_dict()['type'], 'json_schema')
|
||||
|
||||
def test_rejects_wrong_type(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
JsonSchemaOutputFormat.from_dict({'type': 'text', 'schema': {}})
|
||||
|
||||
def test_requires_schema_mapping(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
JsonSchemaOutputFormat.from_dict({'type': 'json_schema'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,132 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
from src.search_runtime import SearchRuntime
|
||||
|
||||
|
||||
class FakeHTTPResponse:
|
||||
def __init__(self, payload: str) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.payload.encode('utf-8')
|
||||
|
||||
def __enter__(self) -> 'FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class SearchRuntimeTests(unittest.TestCase):
|
||||
def test_provider_activation_persists_across_reload(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-search.json').write_text(
|
||||
(
|
||||
'{"providers":['
|
||||
'{"name":"primary","provider":"searxng","baseUrl":"http://127.0.0.1:8080"},'
|
||||
'{"name":"backup","provider":"searxng","baseUrl":"http://127.0.0.2:8080"}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = SearchRuntime.from_workspace(workspace)
|
||||
report = runtime.activate_provider('backup')
|
||||
reloaded = SearchRuntime.from_workspace(workspace)
|
||||
|
||||
self.assertEqual(report.provider_name, 'backup')
|
||||
self.assertIsNotNone(reloaded.current_provider())
|
||||
self.assertEqual(reloaded.current_provider().name, 'backup')
|
||||
|
||||
def test_search_runtime_loads_searxng_provider_from_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
with patch.dict('os.environ', {'SEARXNG_BASE_URL': 'http://127.0.0.1:8888'}, clear=False):
|
||||
runtime = SearchRuntime.from_workspace(workspace)
|
||||
|
||||
provider = runtime.current_provider()
|
||||
self.assertIsNotNone(provider)
|
||||
self.assertEqual(provider.name, 'searxng')
|
||||
self.assertEqual(provider.base_url, 'http://127.0.0.1:8888')
|
||||
|
||||
def test_search_runtime_parses_searxng_results(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-search.json').write_text(
|
||||
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = SearchRuntime.from_workspace(workspace)
|
||||
with patch(
|
||||
'src.search_runtime.request.urlopen',
|
||||
return_value=FakeHTTPResponse(
|
||||
'{"results":[{"title":"Alpha","url":"https://example.com/alpha","content":"Snippet"}]}'
|
||||
),
|
||||
):
|
||||
provider, results = runtime.search('alpha')
|
||||
|
||||
self.assertEqual(provider.name, 'local-search')
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].title, 'Alpha')
|
||||
self.assertEqual(results[0].url, 'https://example.com/alpha')
|
||||
|
||||
def test_search_runtime_parses_brave_results(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-search.json').write_text(
|
||||
'{"providers":[{"name":"brave-local","provider":"brave","baseUrl":"https://api.search.brave.com/res/v1/web/search","apiKeyEnv":"BRAVE_SEARCH_API_KEY"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
with patch.dict('os.environ', {'BRAVE_SEARCH_API_KEY': 'demo-key'}, clear=False):
|
||||
runtime = SearchRuntime.from_workspace(workspace)
|
||||
with patch(
|
||||
'src.search_runtime.request.urlopen',
|
||||
return_value=FakeHTTPResponse(
|
||||
'{"web":{"results":[{"title":"Alpha","url":"https://example.com/alpha","description":"Snippet"}]}}'
|
||||
),
|
||||
):
|
||||
provider, results = runtime.search('alpha', provider_name='brave-local')
|
||||
|
||||
self.assertEqual(provider.provider, 'brave')
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].snippet, 'Snippet')
|
||||
|
||||
def test_web_search_tool_uses_search_runtime(self) -> None:
|
||||
registry = default_tool_registry()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-search.json').write_text(
|
||||
'{"providers":[{"name":"local-search","provider":"searxng","baseUrl":"http://127.0.0.1:8080"}]}',
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = SearchRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
tool_registry=registry,
|
||||
search_runtime=runtime,
|
||||
)
|
||||
with patch(
|
||||
'src.search_runtime.request.urlopen',
|
||||
return_value=FakeHTTPResponse(
|
||||
'{"results":[{"title":"Alpha","url":"https://example.com/alpha","content":"Snippet"}]}'
|
||||
),
|
||||
):
|
||||
result = execute_tool(
|
||||
registry,
|
||||
'web_search',
|
||||
{'query': 'alpha'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn('# Web Search', result.content)
|
||||
self.assertEqual(result.metadata.get('action'), 'web_search')
|
||||
self.assertEqual(result.metadata.get('provider'), 'local-search')
|
||||
self.assertEqual(result.metadata.get('result_count'), 1)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Tests for ``src/session_env_vars.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.session_env_vars import (
|
||||
clear_session_env_vars,
|
||||
delete_session_env_var,
|
||||
get_session_env_vars,
|
||||
set_session_env_var,
|
||||
)
|
||||
|
||||
|
||||
class SessionEnvVarsTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
clear_session_env_vars()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
clear_session_env_vars()
|
||||
|
||||
def test_starts_empty(self) -> None:
|
||||
self.assertEqual(dict(get_session_env_vars()), {})
|
||||
|
||||
def test_set_and_get(self) -> None:
|
||||
set_session_env_var('FOO', 'bar')
|
||||
self.assertEqual(get_session_env_vars()['FOO'], 'bar')
|
||||
|
||||
def test_set_overwrites_existing(self) -> None:
|
||||
set_session_env_var('FOO', 'one')
|
||||
set_session_env_var('FOO', 'two')
|
||||
self.assertEqual(get_session_env_vars()['FOO'], 'two')
|
||||
|
||||
def test_delete_removes(self) -> None:
|
||||
set_session_env_var('FOO', 'bar')
|
||||
delete_session_env_var('FOO')
|
||||
self.assertNotIn('FOO', get_session_env_vars())
|
||||
|
||||
def test_delete_missing_is_noop(self) -> None:
|
||||
delete_session_env_var('NEVER_SET')
|
||||
self.assertEqual(dict(get_session_env_vars()), {})
|
||||
|
||||
def test_clear_drops_everything(self) -> None:
|
||||
set_session_env_var('A', '1')
|
||||
set_session_env_var('B', '2')
|
||||
clear_session_env_vars()
|
||||
self.assertEqual(dict(get_session_env_vars()), {})
|
||||
|
||||
def test_returned_mapping_is_read_only(self) -> None:
|
||||
set_session_env_var('FOO', 'bar')
|
||||
view = get_session_env_vars()
|
||||
with self.assertRaises(TypeError):
|
||||
view['FOO'] = 'mutated' # type: ignore[index]
|
||||
|
||||
def test_view_reflects_subsequent_mutations(self) -> None:
|
||||
view = get_session_env_vars()
|
||||
self.assertNotIn('FOO', view)
|
||||
set_session_env_var('FOO', 'bar')
|
||||
self.assertEqual(view['FOO'], 'bar')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,639 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_types import (
|
||||
AgentPermissions,
|
||||
AgentRuntimeConfig,
|
||||
BudgetConfig,
|
||||
ModelConfig,
|
||||
ModelPricing,
|
||||
OutputSchemaConfig,
|
||||
UsageStats,
|
||||
)
|
||||
from src.session_store import (
|
||||
StoredAgentSession,
|
||||
StoredSession,
|
||||
_deserialize_output_schema,
|
||||
_optional_float,
|
||||
_optional_int,
|
||||
consume_next_session_input,
|
||||
deserialize_model_config,
|
||||
deserialize_runtime_config,
|
||||
enqueue_session_input,
|
||||
list_session_input_queue,
|
||||
load_agent_session,
|
||||
load_session,
|
||||
save_agent_session,
|
||||
save_session,
|
||||
serialize_model_config,
|
||||
serialize_runtime_config,
|
||||
usage_from_payload,
|
||||
)
|
||||
|
||||
|
||||
class TestStoredSessionRoundTrip(unittest.TestCase):
|
||||
"""save_session then load_session preserves all fields."""
|
||||
|
||||
def test_round_trip(self) -> None:
|
||||
session = StoredSession(
|
||||
session_id='abc-123',
|
||||
messages=('hello', 'world', 'foo'),
|
||||
input_tokens=100,
|
||||
output_tokens=200,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
save_session(session, directory=directory)
|
||||
loaded = load_session('abc-123', directory=directory)
|
||||
|
||||
self.assertEqual(loaded.session_id, session.session_id)
|
||||
self.assertEqual(loaded.messages, session.messages)
|
||||
self.assertEqual(loaded.input_tokens, session.input_tokens)
|
||||
self.assertEqual(loaded.output_tokens, session.output_tokens)
|
||||
|
||||
def test_round_trip_empty_messages(self) -> None:
|
||||
session = StoredSession(
|
||||
session_id='empty',
|
||||
messages=(),
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
save_session(session, directory=directory)
|
||||
loaded = load_session('empty', directory=directory)
|
||||
|
||||
self.assertEqual(loaded.messages, ())
|
||||
self.assertEqual(loaded.input_tokens, 0)
|
||||
|
||||
|
||||
class TestStoredAgentSessionRoundTrip(unittest.TestCase):
|
||||
"""save_agent_session then load_agent_session preserves all fields."""
|
||||
|
||||
def _make_session(self, **overrides: object) -> StoredAgentSession:
|
||||
defaults: dict = {
|
||||
'session_id': 'agent-001',
|
||||
'model_config': {'model': 'gpt-4', 'temperature': 0.5},
|
||||
'runtime_config': {'cwd': '/home/user', 'max_turns': 20},
|
||||
'system_prompt_parts': ('You are helpful.',),
|
||||
'user_context': {'lang': 'en'},
|
||||
'system_context': {'os': 'linux'},
|
||||
'messages': ({'role': 'user', 'content': 'hi'},),
|
||||
'display_messages': ({'role': 'user', 'content': 'visible hi'},),
|
||||
'turns': 3,
|
||||
'tool_calls': 7,
|
||||
'usage': {'input_tokens': 500, 'output_tokens': 300},
|
||||
'total_cost_usd': 0.05,
|
||||
'file_history': ({'file': 'a.py', 'action': 'edit'},),
|
||||
'budget_state': {'remaining': 100},
|
||||
'plugin_state': {'key': 'value'},
|
||||
'scratchpad_directory': '/scratch/pad',
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return StoredAgentSession(**defaults)
|
||||
|
||||
def test_round_trip_all_fields(self) -> None:
|
||||
session = self._make_session()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
save_agent_session(session, directory=directory)
|
||||
loaded = load_agent_session('agent-001', directory=directory)
|
||||
|
||||
self.assertEqual(loaded.session_id, session.session_id)
|
||||
self.assertEqual(loaded.model_config, session.model_config)
|
||||
self.assertEqual(loaded.runtime_config, session.runtime_config)
|
||||
self.assertEqual(loaded.system_prompt_parts, session.system_prompt_parts)
|
||||
self.assertEqual(loaded.user_context, session.user_context)
|
||||
self.assertEqual(loaded.system_context, session.system_context)
|
||||
self.assertEqual(loaded.messages, session.messages)
|
||||
self.assertEqual(loaded.display_messages, session.display_messages)
|
||||
self.assertEqual(loaded.turns, session.turns)
|
||||
self.assertEqual(loaded.tool_calls, session.tool_calls)
|
||||
self.assertEqual(loaded.usage, session.usage)
|
||||
self.assertAlmostEqual(loaded.total_cost_usd, session.total_cost_usd)
|
||||
self.assertEqual(loaded.file_history, session.file_history)
|
||||
self.assertEqual(loaded.budget_state, session.budget_state)
|
||||
self.assertEqual(loaded.plugin_state, session.plugin_state)
|
||||
self.assertEqual(loaded.scratchpad_directory, session.scratchpad_directory)
|
||||
|
||||
def test_round_trip_no_scratchpad(self) -> None:
|
||||
session = self._make_session(scratchpad_directory=None)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
save_agent_session(session, directory=directory)
|
||||
loaded = load_agent_session('agent-001', directory=directory)
|
||||
|
||||
self.assertIsNone(loaded.scratchpad_directory)
|
||||
|
||||
def test_load_filters_non_dict_messages(self) -> None:
|
||||
"""Non-dict entries in messages list are filtered out on load."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
path = directory / 'mixed.json'
|
||||
data = {
|
||||
'session_id': 'mixed',
|
||||
'model_config': {},
|
||||
'runtime_config': {'cwd': '/'},
|
||||
'system_prompt_parts': [],
|
||||
'user_context': {},
|
||||
'system_context': {},
|
||||
'messages': [
|
||||
{'role': 'user', 'content': 'hi'},
|
||||
'not a dict',
|
||||
42,
|
||||
None,
|
||||
{'role': 'assistant', 'content': 'hey'},
|
||||
],
|
||||
'turns': 0,
|
||||
'tool_calls': 0,
|
||||
'usage': {},
|
||||
'total_cost_usd': 0.0,
|
||||
'file_history': [],
|
||||
'budget_state': {},
|
||||
'plugin_state': {},
|
||||
}
|
||||
path.write_text(json.dumps(data))
|
||||
loaded = load_agent_session('mixed', directory=directory)
|
||||
|
||||
self.assertEqual(len(loaded.messages), 2)
|
||||
self.assertEqual(loaded.messages[0]['role'], 'user')
|
||||
self.assertEqual(loaded.messages[1]['role'], 'assistant')
|
||||
self.assertEqual(loaded.display_messages, loaded.messages)
|
||||
|
||||
def test_load_agent_session_falls_back_to_messages_for_display(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
path = directory / 'legacy.json'
|
||||
data = {
|
||||
'session_id': 'legacy',
|
||||
'model_config': {},
|
||||
'runtime_config': {'cwd': '/'},
|
||||
'system_prompt_parts': [],
|
||||
'user_context': {},
|
||||
'system_context': {},
|
||||
'messages': [{'role': 'user', 'content': 'legacy hi'}],
|
||||
'turns': 0,
|
||||
'tool_calls': 0,
|
||||
'usage': {},
|
||||
'total_cost_usd': 0.0,
|
||||
'file_history': [],
|
||||
}
|
||||
path.write_text(json.dumps(data))
|
||||
loaded = load_agent_session('legacy', directory=directory)
|
||||
|
||||
self.assertEqual(loaded.display_messages, loaded.messages)
|
||||
|
||||
def test_load_defaults_for_missing_optional_fields(self) -> None:
|
||||
"""Missing optional fields get sensible defaults."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
path = directory / 'minimal.json'
|
||||
data = {
|
||||
'session_id': 'minimal',
|
||||
'model_config': {},
|
||||
'runtime_config': {'cwd': '/'},
|
||||
'system_prompt_parts': [],
|
||||
'user_context': {},
|
||||
'system_context': {},
|
||||
'messages': [],
|
||||
'turns': 1,
|
||||
'tool_calls': 2,
|
||||
}
|
||||
path.write_text(json.dumps(data))
|
||||
loaded = load_agent_session('minimal', directory=directory)
|
||||
|
||||
self.assertEqual(loaded.usage, {})
|
||||
self.assertAlmostEqual(loaded.total_cost_usd, 0.0)
|
||||
self.assertEqual(loaded.file_history, ())
|
||||
self.assertEqual(loaded.budget_state, {})
|
||||
self.assertEqual(loaded.plugin_state, {})
|
||||
self.assertIsNone(loaded.scratchpad_directory)
|
||||
|
||||
def test_load_non_dict_budget_state_defaults_to_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td)
|
||||
path = directory / 'bad-budget.json'
|
||||
data = {
|
||||
'session_id': 'bad-budget',
|
||||
'model_config': {},
|
||||
'runtime_config': {'cwd': '/'},
|
||||
'system_prompt_parts': [],
|
||||
'user_context': {},
|
||||
'system_context': {},
|
||||
'messages': [],
|
||||
'turns': 0,
|
||||
'tool_calls': 0,
|
||||
'budget_state': 'not-a-dict',
|
||||
'plugin_state': 123,
|
||||
}
|
||||
path.write_text(json.dumps(data))
|
||||
loaded = load_agent_session('bad-budget', directory=directory)
|
||||
|
||||
self.assertEqual(loaded.budget_state, {})
|
||||
self.assertEqual(loaded.plugin_state, {})
|
||||
|
||||
|
||||
class TestSessionInputQueue(unittest.TestCase):
|
||||
def test_consume_next_turn_uses_fifo_and_preserves_guidance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
directory = Path(td) / 'sessions'
|
||||
directory.mkdir()
|
||||
first = enqueue_session_input(
|
||||
'session-1',
|
||||
directory=directory,
|
||||
content='first queued turn',
|
||||
kind='next_turn',
|
||||
)
|
||||
guidance = enqueue_session_input(
|
||||
'session-1',
|
||||
directory=directory,
|
||||
content='guide current run',
|
||||
kind='guidance',
|
||||
)
|
||||
second = enqueue_session_input(
|
||||
'session-1',
|
||||
directory=directory,
|
||||
content='second queued turn',
|
||||
kind='next_turn',
|
||||
)
|
||||
|
||||
consumed_first = consume_next_session_input(
|
||||
'session-1',
|
||||
directory=directory,
|
||||
)
|
||||
consumed_second = consume_next_session_input(
|
||||
'session-1',
|
||||
directory=directory,
|
||||
)
|
||||
consumed_none = consume_next_session_input(
|
||||
'session-1',
|
||||
directory=directory,
|
||||
)
|
||||
pending = list_session_input_queue('session-1', directory=directory)
|
||||
|
||||
self.assertIsNotNone(consumed_first)
|
||||
self.assertIsNotNone(consumed_second)
|
||||
assert consumed_first is not None
|
||||
assert consumed_second is not None
|
||||
self.assertEqual(consumed_first['id'], first['id'])
|
||||
self.assertEqual(consumed_first['content'], 'first queued turn')
|
||||
self.assertEqual(consumed_first['status'], 'consumed')
|
||||
self.assertEqual(consumed_second['id'], second['id'])
|
||||
self.assertEqual(consumed_second['content'], 'second queued turn')
|
||||
self.assertIsNone(consumed_none)
|
||||
self.assertEqual([item['id'] for item in pending], [guidance['id']])
|
||||
self.assertEqual(pending[0]['kind'], 'guidance')
|
||||
|
||||
|
||||
class TestModelConfigSerialization(unittest.TestCase):
|
||||
"""serialize_model_config + deserialize_model_config round-trip."""
|
||||
|
||||
def test_round_trip_preserves_pricing(self) -> None:
|
||||
pricing = ModelPricing(
|
||||
input_cost_per_million_tokens_usd=3.0,
|
||||
output_cost_per_million_tokens_usd=15.0,
|
||||
cache_creation_input_cost_per_million_tokens_usd=1.5,
|
||||
cache_read_input_cost_per_million_tokens_usd=0.5,
|
||||
)
|
||||
config = ModelConfig(
|
||||
model='claude-3-sonnet',
|
||||
base_url='https://api.example.com/v1',
|
||||
api_key='sk-test-key',
|
||||
temperature=0.7,
|
||||
timeout_seconds=60.0,
|
||||
pricing=pricing,
|
||||
)
|
||||
payload = serialize_model_config(config)
|
||||
restored = deserialize_model_config(payload)
|
||||
|
||||
self.assertEqual(restored.model, config.model)
|
||||
self.assertEqual(restored.base_url, config.base_url)
|
||||
self.assertEqual(restored.api_key, config.api_key)
|
||||
self.assertAlmostEqual(restored.temperature, config.temperature)
|
||||
self.assertAlmostEqual(restored.timeout_seconds, config.timeout_seconds)
|
||||
self.assertAlmostEqual(
|
||||
restored.pricing.input_cost_per_million_tokens_usd,
|
||||
pricing.input_cost_per_million_tokens_usd,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
restored.pricing.output_cost_per_million_tokens_usd,
|
||||
pricing.output_cost_per_million_tokens_usd,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
restored.pricing.cache_creation_input_cost_per_million_tokens_usd,
|
||||
pricing.cache_creation_input_cost_per_million_tokens_usd,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
restored.pricing.cache_read_input_cost_per_million_tokens_usd,
|
||||
pricing.cache_read_input_cost_per_million_tokens_usd,
|
||||
)
|
||||
|
||||
def test_deserialize_defaults_for_missing_fields(self) -> None:
|
||||
payload = {'model': 'gpt-4'}
|
||||
config = deserialize_model_config(payload)
|
||||
|
||||
self.assertEqual(config.model, 'gpt-4')
|
||||
self.assertEqual(config.base_url, 'http://127.0.0.1:8000/v1')
|
||||
self.assertEqual(config.api_key, 'local-token')
|
||||
self.assertAlmostEqual(config.temperature, 0.0)
|
||||
self.assertAlmostEqual(config.timeout_seconds, 120.0)
|
||||
self.assertAlmostEqual(config.pricing.input_cost_per_million_tokens_usd, 0.0)
|
||||
self.assertAlmostEqual(config.pricing.output_cost_per_million_tokens_usd, 0.0)
|
||||
|
||||
def test_deserialize_with_non_dict_pricing(self) -> None:
|
||||
payload = {'model': 'test', 'pricing': 'invalid'}
|
||||
config = deserialize_model_config(payload)
|
||||
self.assertAlmostEqual(config.pricing.input_cost_per_million_tokens_usd, 0.0)
|
||||
|
||||
def test_deserialize_with_none_pricing(self) -> None:
|
||||
payload = {'model': 'test', 'pricing': None}
|
||||
config = deserialize_model_config(payload)
|
||||
self.assertEqual(config.pricing, ModelPricing())
|
||||
|
||||
|
||||
class TestRuntimeConfigSerialization(unittest.TestCase):
|
||||
"""serialize_runtime_config + deserialize_runtime_config round-trip."""
|
||||
|
||||
def test_round_trip_preserves_all(self) -> None:
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=Path('/home/user/project'),
|
||||
max_turns=25,
|
||||
command_timeout_seconds=45.0,
|
||||
max_output_chars=8000,
|
||||
stream_model_responses=True,
|
||||
auto_snip_threshold_tokens=5000,
|
||||
auto_compact_threshold_tokens=10000,
|
||||
compact_preserve_messages=6,
|
||||
permissions=AgentPermissions(
|
||||
allow_file_write=True,
|
||||
allow_shell_commands=True,
|
||||
allow_destructive_shell_commands=False,
|
||||
),
|
||||
additional_working_directories=(Path('/extra/dir'),),
|
||||
disable_claude_md_discovery=True,
|
||||
budget_config=BudgetConfig(
|
||||
max_total_tokens=100000,
|
||||
max_input_tokens=50000,
|
||||
max_output_tokens=30000,
|
||||
max_reasoning_tokens=20000,
|
||||
max_total_cost_usd=5.0,
|
||||
max_tool_calls=100,
|
||||
max_delegated_tasks=10,
|
||||
max_model_calls=200,
|
||||
max_session_turns=50,
|
||||
),
|
||||
output_schema=OutputSchemaConfig(
|
||||
name='test_schema',
|
||||
schema={'type': 'object', 'properties': {'answer': {'type': 'string'}}},
|
||||
strict=True,
|
||||
),
|
||||
session_directory=Path('/sessions'),
|
||||
scratchpad_root=Path('/scratch'),
|
||||
python_env_dir=Path('/python/.venv'),
|
||||
enabled_skill_names=('verify', 'product-data'),
|
||||
)
|
||||
payload = serialize_runtime_config(config)
|
||||
restored = deserialize_runtime_config(payload)
|
||||
|
||||
self.assertEqual(restored.cwd, config.cwd.resolve())
|
||||
self.assertEqual(restored.max_turns, 25)
|
||||
self.assertAlmostEqual(restored.command_timeout_seconds, 45.0)
|
||||
self.assertEqual(restored.max_output_chars, 8000)
|
||||
self.assertTrue(restored.stream_model_responses)
|
||||
self.assertEqual(restored.auto_snip_threshold_tokens, 5000)
|
||||
self.assertEqual(restored.auto_compact_threshold_tokens, 10000)
|
||||
self.assertEqual(restored.compact_preserve_messages, 6)
|
||||
self.assertTrue(restored.permissions.allow_file_write)
|
||||
self.assertTrue(restored.permissions.allow_shell_commands)
|
||||
self.assertFalse(restored.permissions.allow_destructive_shell_commands)
|
||||
self.assertTrue(restored.disable_claude_md_discovery)
|
||||
|
||||
self.assertEqual(restored.budget_config.max_total_tokens, 100000)
|
||||
self.assertEqual(restored.budget_config.max_input_tokens, 50000)
|
||||
self.assertEqual(restored.budget_config.max_output_tokens, 30000)
|
||||
self.assertEqual(restored.budget_config.max_reasoning_tokens, 20000)
|
||||
self.assertAlmostEqual(restored.budget_config.max_total_cost_usd, 5.0)
|
||||
self.assertEqual(restored.budget_config.max_tool_calls, 100)
|
||||
self.assertEqual(restored.budget_config.max_delegated_tasks, 10)
|
||||
self.assertEqual(restored.budget_config.max_model_calls, 200)
|
||||
self.assertEqual(restored.budget_config.max_session_turns, 50)
|
||||
|
||||
self.assertIsNotNone(restored.output_schema)
|
||||
assert restored.output_schema is not None
|
||||
self.assertEqual(restored.output_schema.name, 'test_schema')
|
||||
self.assertEqual(restored.output_schema.schema, config.output_schema.schema)
|
||||
self.assertTrue(restored.output_schema.strict)
|
||||
self.assertEqual(restored.python_env_dir, Path('/python/.venv'))
|
||||
self.assertEqual(restored.enabled_skill_names, ('verify', 'product-data'))
|
||||
|
||||
def test_round_trip_none_output_schema(self) -> None:
|
||||
config = AgentRuntimeConfig(
|
||||
cwd=Path('/home/user'),
|
||||
output_schema=None,
|
||||
)
|
||||
payload = serialize_runtime_config(config)
|
||||
restored = deserialize_runtime_config(payload)
|
||||
self.assertIsNone(restored.output_schema)
|
||||
|
||||
def test_deserialize_defaults_for_missing_fields(self) -> None:
|
||||
payload = {'cwd': '/home/user'}
|
||||
config = deserialize_runtime_config(payload)
|
||||
|
||||
self.assertEqual(config.max_turns, 50)
|
||||
self.assertAlmostEqual(config.command_timeout_seconds, 300.0)
|
||||
self.assertEqual(config.max_output_chars, 50000)
|
||||
self.assertFalse(config.stream_model_responses)
|
||||
self.assertIsNone(config.auto_snip_threshold_tokens)
|
||||
self.assertIsNone(config.auto_compact_threshold_tokens)
|
||||
self.assertEqual(config.compact_preserve_messages, 4)
|
||||
self.assertFalse(config.permissions.allow_file_write)
|
||||
self.assertFalse(config.permissions.allow_shell_commands)
|
||||
self.assertFalse(config.permissions.allow_destructive_shell_commands)
|
||||
self.assertEqual(config.additional_working_directories, ())
|
||||
self.assertFalse(config.disable_claude_md_discovery)
|
||||
self.assertIsNone(config.budget_config.max_total_tokens)
|
||||
self.assertIsNone(config.output_schema)
|
||||
self.assertIsNone(config.enabled_skill_names)
|
||||
|
||||
def test_deserialize_non_dict_permissions(self) -> None:
|
||||
payload = {'cwd': '/home', 'permissions': 'invalid'}
|
||||
config = deserialize_runtime_config(payload)
|
||||
self.assertFalse(config.permissions.allow_file_write)
|
||||
|
||||
def test_deserialize_non_dict_budget_config(self) -> None:
|
||||
payload = {'cwd': '/home', 'budget_config': 42}
|
||||
config = deserialize_runtime_config(payload)
|
||||
self.assertIsNone(config.budget_config.max_total_tokens)
|
||||
|
||||
|
||||
class TestUsageFromPayload(unittest.TestCase):
|
||||
"""usage_from_payload correctly maps fields including defaults."""
|
||||
|
||||
def test_full_payload(self) -> None:
|
||||
payload = {
|
||||
'input_tokens': 1000,
|
||||
'output_tokens': 500,
|
||||
'cache_creation_input_tokens': 200,
|
||||
'cache_read_input_tokens': 100,
|
||||
'reasoning_tokens': 50,
|
||||
}
|
||||
usage = usage_from_payload(payload)
|
||||
self.assertEqual(usage.input_tokens, 1000)
|
||||
self.assertEqual(usage.output_tokens, 500)
|
||||
self.assertEqual(usage.cache_creation_input_tokens, 200)
|
||||
self.assertEqual(usage.cache_read_input_tokens, 100)
|
||||
self.assertEqual(usage.reasoning_tokens, 50)
|
||||
|
||||
def test_partial_payload_uses_defaults(self) -> None:
|
||||
payload = {'input_tokens': 42}
|
||||
usage = usage_from_payload(payload)
|
||||
self.assertEqual(usage.input_tokens, 42)
|
||||
self.assertEqual(usage.output_tokens, 0)
|
||||
self.assertEqual(usage.cache_creation_input_tokens, 0)
|
||||
self.assertEqual(usage.cache_read_input_tokens, 0)
|
||||
self.assertEqual(usage.reasoning_tokens, 0)
|
||||
|
||||
def test_none_returns_empty(self) -> None:
|
||||
usage = usage_from_payload(None)
|
||||
self.assertEqual(usage, UsageStats())
|
||||
|
||||
def test_empty_dict_returns_defaults(self) -> None:
|
||||
usage = usage_from_payload({})
|
||||
self.assertEqual(usage, UsageStats())
|
||||
|
||||
def test_non_dict_returns_empty(self) -> None:
|
||||
usage = usage_from_payload('not a dict') # type: ignore[arg-type]
|
||||
self.assertEqual(usage, UsageStats())
|
||||
|
||||
def test_string_token_values_parsed(self) -> None:
|
||||
payload = {'input_tokens': '99', 'output_tokens': '77'}
|
||||
usage = usage_from_payload(payload)
|
||||
self.assertEqual(usage.input_tokens, 99)
|
||||
self.assertEqual(usage.output_tokens, 77)
|
||||
|
||||
|
||||
class TestOptionalInt(unittest.TestCase):
|
||||
"""_optional_int handles int, str, float, None, bool correctly."""
|
||||
|
||||
def test_int_value(self) -> None:
|
||||
self.assertEqual(_optional_int(42), 42)
|
||||
|
||||
def test_zero(self) -> None:
|
||||
self.assertEqual(_optional_int(0), 0)
|
||||
|
||||
def test_negative(self) -> None:
|
||||
self.assertEqual(_optional_int(-5), -5)
|
||||
|
||||
def test_str_numeric(self) -> None:
|
||||
self.assertEqual(_optional_int('123'), 123)
|
||||
|
||||
def test_float_value(self) -> None:
|
||||
self.assertEqual(_optional_int(3.9), 3)
|
||||
|
||||
def test_none_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_int(None))
|
||||
|
||||
def test_bool_true_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_int(True))
|
||||
|
||||
def test_bool_false_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_int(False))
|
||||
|
||||
def test_non_numeric_string_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_int('hello'))
|
||||
|
||||
def test_empty_string_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_int(''))
|
||||
|
||||
|
||||
class TestOptionalFloat(unittest.TestCase):
|
||||
"""_optional_float handles int, str, float, None, bool correctly."""
|
||||
|
||||
def test_float_value(self) -> None:
|
||||
self.assertAlmostEqual(_optional_float(3.14), 3.14)
|
||||
|
||||
def test_int_value(self) -> None:
|
||||
self.assertAlmostEqual(_optional_float(42), 42.0)
|
||||
|
||||
def test_zero(self) -> None:
|
||||
self.assertAlmostEqual(_optional_float(0), 0.0)
|
||||
|
||||
def test_str_numeric(self) -> None:
|
||||
self.assertAlmostEqual(_optional_float('2.5'), 2.5)
|
||||
|
||||
def test_none_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_float(None))
|
||||
|
||||
def test_bool_true_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_float(True))
|
||||
|
||||
def test_bool_false_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_float(False))
|
||||
|
||||
def test_non_numeric_string_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_float('abc'))
|
||||
|
||||
def test_empty_string_returns_none(self) -> None:
|
||||
self.assertIsNone(_optional_float(''))
|
||||
|
||||
|
||||
class TestDeserializeOutputSchema(unittest.TestCase):
|
||||
"""_deserialize_output_schema with valid, None, invalid data."""
|
||||
|
||||
def test_valid_payload(self) -> None:
|
||||
payload = {
|
||||
'name': 'my_schema',
|
||||
'schema': {'type': 'object'},
|
||||
'strict': True,
|
||||
}
|
||||
result = _deserialize_output_schema(payload)
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(result.name, 'my_schema')
|
||||
self.assertEqual(result.schema, {'type': 'object'})
|
||||
self.assertTrue(result.strict)
|
||||
|
||||
def test_strict_defaults_false(self) -> None:
|
||||
payload = {
|
||||
'name': 'basic',
|
||||
'schema': {'type': 'string'},
|
||||
}
|
||||
result = _deserialize_output_schema(payload)
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertFalse(result.strict)
|
||||
|
||||
def test_none_payload(self) -> None:
|
||||
self.assertIsNone(_deserialize_output_schema(None))
|
||||
|
||||
def test_non_dict_payload(self) -> None:
|
||||
self.assertIsNone(_deserialize_output_schema('not a dict'))
|
||||
self.assertIsNone(_deserialize_output_schema(42))
|
||||
self.assertIsNone(_deserialize_output_schema([]))
|
||||
|
||||
def test_missing_schema_key(self) -> None:
|
||||
self.assertIsNone(_deserialize_output_schema({'name': 'test'}))
|
||||
|
||||
def test_non_dict_schema(self) -> None:
|
||||
self.assertIsNone(_deserialize_output_schema({'name': 'test', 'schema': 'bad'}))
|
||||
|
||||
def test_missing_name(self) -> None:
|
||||
self.assertIsNone(_deserialize_output_schema({'schema': {'type': 'object'}}))
|
||||
|
||||
def test_empty_name(self) -> None:
|
||||
self.assertIsNone(
|
||||
_deserialize_output_schema({'name': '', 'schema': {'type': 'object'}})
|
||||
)
|
||||
|
||||
def test_non_string_name(self) -> None:
|
||||
self.assertIsNone(
|
||||
_deserialize_output_schema({'name': 123, 'schema': {'type': 'object'}})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,179 +0,0 @@
|
||||
"""Tests for settings-touching slash commands ported from the npm source.
|
||||
|
||||
Covers /theme, /voice, /sandbox-toggle (alias /sandbox), /keybindings, /btw.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_types import AgentRuntimeConfig, ModelConfig
|
||||
|
||||
|
||||
def _make_agent(tmp_dir: str) -> LocalCodingAgent:
|
||||
return LocalCodingAgent(
|
||||
model_config=ModelConfig(model='test-model'),
|
||||
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
|
||||
)
|
||||
|
||||
|
||||
def _local_settings(tmp_dir: str) -> dict:
|
||||
path = Path(tmp_dir) / '.claude' / 'settings.local.json'
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
class ThemeCommandTest(unittest.TestCase):
|
||||
def test_lists_themes_when_no_arg(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/theme').final_output
|
||||
self.assertIn('Available themes', out)
|
||||
self.assertIn('light', out)
|
||||
self.assertIn('dark', out)
|
||||
self.assertIn('Usage: /theme <name>', out)
|
||||
|
||||
def test_rejects_unknown_theme(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/theme neon').final_output
|
||||
self.assertIn('Unknown theme', out)
|
||||
self.assertIn('neon', out)
|
||||
|
||||
def test_sets_theme_and_persists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/theme dark').final_output
|
||||
self.assertIn('Theme set to dark', out)
|
||||
settings = _local_settings(tmp)
|
||||
self.assertEqual(settings.get('theme'), 'dark')
|
||||
|
||||
def test_marks_current_theme(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/theme dark')
|
||||
out = agent.run('/theme').final_output
|
||||
self.assertIn('dark (current)', out)
|
||||
|
||||
|
||||
class VoiceCommandTest(unittest.TestCase):
|
||||
def test_toggle_enables_when_unset(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/voice').final_output
|
||||
self.assertIn('Voice mode enabled', out)
|
||||
self.assertEqual(_local_settings(tmp).get('voiceEnabled'), True)
|
||||
|
||||
def test_toggle_disables_when_enabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/voice on')
|
||||
out = agent.run('/voice').final_output
|
||||
self.assertIn('Voice mode disabled', out)
|
||||
self.assertEqual(_local_settings(tmp).get('voiceEnabled'), False)
|
||||
|
||||
def test_explicit_on_off(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
self.assertIn('enabled', agent.run('/voice on').final_output)
|
||||
self.assertIn('disabled', agent.run('/voice off').final_output)
|
||||
|
||||
def test_rejects_unknown_arg(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/voice maybe').final_output
|
||||
self.assertIn('Usage', out)
|
||||
|
||||
|
||||
class SandboxToggleCommandTest(unittest.TestCase):
|
||||
def test_status_with_no_args(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/sandbox-toggle').final_output
|
||||
self.assertIn('Sandbox:', out)
|
||||
self.assertIn('Excluded commands', out)
|
||||
self.assertIn('Usage:', out)
|
||||
|
||||
def test_alias_sandbox(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/sandbox').final_output
|
||||
self.assertIn('Sandbox:', out)
|
||||
|
||||
def test_exclude_appends_pattern(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/sandbox-toggle exclude "npm run test:*"').final_output
|
||||
self.assertIn('Added "npm run test:*"', out)
|
||||
settings = _local_settings(tmp)
|
||||
excluded = settings.get('sandbox', {}).get('excludedCommands', [])
|
||||
self.assertIn('npm run test:*', excluded)
|
||||
|
||||
def test_exclude_dedupes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/sandbox-toggle exclude "rm -rf /"')
|
||||
out = agent.run('/sandbox-toggle exclude "rm -rf /"').final_output
|
||||
self.assertIn('already in', out)
|
||||
|
||||
def test_exclude_requires_pattern(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/sandbox-toggle exclude').final_output
|
||||
self.assertIn('Usage', out)
|
||||
|
||||
def test_unknown_subcommand(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/sandbox-toggle wat').final_output
|
||||
self.assertIn('Unknown subcommand', out)
|
||||
|
||||
|
||||
class KeybindingsCommandTest(unittest.TestCase):
|
||||
def test_creates_template_when_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
out = agent.run('/keybindings').final_output
|
||||
path = Path(tmp) / '.claude' / 'keybindings.json'
|
||||
self.assertTrue(path.exists())
|
||||
self.assertIn('Created', out)
|
||||
self.assertIn(str(path), out)
|
||||
# Template is valid JSON-ish (has braces); strict json.loads would
|
||||
# choke on the "//" comment, so just sanity-check structure.
|
||||
text = path.read_text(encoding='utf-8')
|
||||
self.assertIn('"bindings"', text)
|
||||
|
||||
def test_reports_existing_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
agent.run('/keybindings')
|
||||
out = agent.run('/keybindings').final_output
|
||||
self.assertIn('Found', out)
|
||||
|
||||
|
||||
class BtwCommandTest(unittest.TestCase):
|
||||
def test_no_question_shows_usage(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = agent.run('/btw').final_output
|
||||
self.assertIn('Usage: /btw', result)
|
||||
|
||||
def test_question_returns_prompt_result(self) -> None:
|
||||
from src.agent_slash_commands import preprocess_slash_command
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent = _make_agent(tmp)
|
||||
result = preprocess_slash_command(agent, '/btw what does this codebase do?')
|
||||
self.assertTrue(result.handled)
|
||||
self.assertTrue(result.should_query)
|
||||
self.assertIn('side question', (result.prompt or '').lower())
|
||||
self.assertIn('what does this codebase do?', result.prompt or '')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for setup-time runtime checks ported from setup.ts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from src.setup import (
|
||||
MIN_PYTHON_VERSION,
|
||||
SetupReport,
|
||||
check_runtime_requirements,
|
||||
run_setup,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeRequirementCheckTest(unittest.TestCase):
|
||||
def test_python_check_passes_on_current_runtime(self) -> None:
|
||||
checks = {check.name: check for check in check_runtime_requirements()}
|
||||
self.assertTrue(checks['python_version'].ok)
|
||||
self.assertGreaterEqual(sys.version_info[:2], MIN_PYTHON_VERSION)
|
||||
|
||||
def test_python_check_fails_when_below_minimum(self) -> None:
|
||||
# Force a lower version_info via patching to verify the failure branch.
|
||||
fake_version = mock.Mock()
|
||||
fake_version.__getitem__ = lambda self, idx: (3, 8)[idx] if isinstance(idx, int) else (3, 8)[idx]
|
||||
with mock.patch('src.setup.sys') as fake_sys:
|
||||
fake_sys.version_info = (3, 8, 0)
|
||||
checks = {check.name: check for check in check_runtime_requirements()}
|
||||
self.assertFalse(checks['python_version'].ok)
|
||||
self.assertIn('below required', checks['python_version'].detail)
|
||||
|
||||
def test_includes_platform_and_implementation(self) -> None:
|
||||
names = {check.name for check in check_runtime_requirements()}
|
||||
self.assertIn('python_implementation', names)
|
||||
self.assertIn('platform', names)
|
||||
|
||||
|
||||
class SetupReportTest(unittest.TestCase):
|
||||
def test_run_setup_reports_runtime_and_release_notes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / 'CHANGELOG.md').write_text(
|
||||
'# Changelog\n\n## 9.9.9\n- big release\n', encoding='utf-8',
|
||||
)
|
||||
report = run_setup(cwd=Path(tmp), trusted=True, last_seen_version='0.0.1')
|
||||
self.assertIsInstance(report, SetupReport)
|
||||
self.assertGreaterEqual(len(report.runtime_checks), 3)
|
||||
self.assertIn('big release', report.release_notes)
|
||||
markdown = report.as_markdown()
|
||||
self.assertIn('Runtime checks', markdown)
|
||||
self.assertIn('Release notes', markdown)
|
||||
self.assertFalse(report.has_blocking_issues())
|
||||
|
||||
def test_no_release_notes_when_changelog_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
report = run_setup(cwd=Path(tmp), trusted=True)
|
||||
self.assertEqual(report.release_notes, ())
|
||||
self.assertNotIn('Release notes', report.as_markdown())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Tests for the bundled small utilities ported in ``src/small_utils.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.small_utils import (
|
||||
count,
|
||||
create_agent_id,
|
||||
difference,
|
||||
escape_xml,
|
||||
escape_xml_attr,
|
||||
every_in,
|
||||
intersects,
|
||||
intersperse,
|
||||
object_group_by,
|
||||
union,
|
||||
uniq,
|
||||
validate_uuid,
|
||||
)
|
||||
|
||||
|
||||
class IntersperseTest(unittest.TestCase):
|
||||
def test_empty(self) -> None:
|
||||
self.assertEqual(intersperse([], lambda i: ','), [])
|
||||
|
||||
def test_single_item_no_separator(self) -> None:
|
||||
self.assertEqual(intersperse(['a'], lambda i: ','), ['a'])
|
||||
|
||||
def test_separator_receives_index_starting_at_one(self) -> None:
|
||||
seen: list[int] = []
|
||||
|
||||
def sep(i: int) -> str:
|
||||
seen.append(i)
|
||||
return f'-{i}-'
|
||||
|
||||
out = intersperse(['a', 'b', 'c'], sep)
|
||||
self.assertEqual(out, ['a', '-1-', 'b', '-2-', 'c'])
|
||||
self.assertEqual(seen, [1, 2])
|
||||
|
||||
|
||||
class CountTest(unittest.TestCase):
|
||||
def test_counts_truthy(self) -> None:
|
||||
self.assertEqual(count([1, 2, 3, 4], lambda x: x % 2 == 0), 2)
|
||||
|
||||
def test_predicate_returning_objects_treated_as_truthy(self) -> None:
|
||||
self.assertEqual(count(['', 'x', 'y'], lambda s: s), 2)
|
||||
|
||||
|
||||
class UniqTest(unittest.TestCase):
|
||||
def test_preserves_first_seen_order(self) -> None:
|
||||
self.assertEqual(uniq([3, 1, 2, 1, 3, 4]), [3, 1, 2, 4])
|
||||
|
||||
|
||||
class ObjectGroupByTest(unittest.TestCase):
|
||||
def test_groups_by_key(self) -> None:
|
||||
out = object_group_by(['apple', 'banana', 'avocado'], lambda s, _i: s[0])
|
||||
self.assertEqual(out, {'a': ['apple', 'avocado'], 'b': ['banana']})
|
||||
|
||||
def test_passes_index_to_selector(self) -> None:
|
||||
out = object_group_by(
|
||||
['x', 'y', 'z'], lambda _s, i: 'even' if i % 2 == 0 else 'odd',
|
||||
)
|
||||
self.assertEqual(out, {'even': ['x', 'z'], 'odd': ['y']})
|
||||
|
||||
|
||||
class SetOpsTest(unittest.TestCase):
|
||||
def test_difference(self) -> None:
|
||||
self.assertEqual(difference({1, 2, 3}, {2}), {1, 3})
|
||||
|
||||
def test_intersects_true(self) -> None:
|
||||
self.assertTrue(intersects({1, 2}, {2, 3}))
|
||||
|
||||
def test_intersects_false(self) -> None:
|
||||
self.assertFalse(intersects({1, 2}, {3, 4}))
|
||||
|
||||
def test_intersects_empty_short_circuits(self) -> None:
|
||||
self.assertFalse(intersects(set(), {1}))
|
||||
self.assertFalse(intersects({1}, set()))
|
||||
|
||||
def test_every_in(self) -> None:
|
||||
self.assertTrue(every_in({1, 2}, {1, 2, 3}))
|
||||
self.assertFalse(every_in({1, 4}, {1, 2, 3}))
|
||||
self.assertTrue(every_in(set(), {1, 2}))
|
||||
|
||||
def test_union(self) -> None:
|
||||
self.assertEqual(union({1, 2}, {2, 3}), {1, 2, 3})
|
||||
|
||||
|
||||
class XmlEscapeTest(unittest.TestCase):
|
||||
def test_escape_xml(self) -> None:
|
||||
self.assertEqual(
|
||||
escape_xml('a & b < c > d'), 'a & b < c > d',
|
||||
)
|
||||
|
||||
def test_escape_xml_amp_first_no_double_escape(self) -> None:
|
||||
self.assertEqual(escape_xml('<&>'), '<&>')
|
||||
|
||||
def test_escape_xml_attr_includes_quotes(self) -> None:
|
||||
self.assertEqual(
|
||||
escape_xml_attr('he said "hi" & \'bye\''),
|
||||
'he said "hi" & 'bye'',
|
||||
)
|
||||
|
||||
|
||||
class ValidateUuidTest(unittest.TestCase):
|
||||
def test_valid_lowercase(self) -> None:
|
||||
u = '12345678-1234-1234-1234-123456789012'
|
||||
self.assertEqual(validate_uuid(u), u)
|
||||
|
||||
def test_valid_uppercase(self) -> None:
|
||||
u = 'ABCDEF12-1234-5678-90AB-CDEF12345678'
|
||||
self.assertEqual(validate_uuid(u), u)
|
||||
|
||||
def test_invalid_format(self) -> None:
|
||||
self.assertIsNone(validate_uuid('not-a-uuid'))
|
||||
|
||||
def test_non_string_returns_none(self) -> None:
|
||||
self.assertIsNone(validate_uuid(123))
|
||||
self.assertIsNone(validate_uuid(None))
|
||||
|
||||
|
||||
class CreateAgentIdTest(unittest.TestCase):
|
||||
def test_no_label(self) -> None:
|
||||
agent_id = create_agent_id()
|
||||
self.assertRegex(agent_id, r'^a[0-9a-f]{16}$')
|
||||
|
||||
def test_with_label(self) -> None:
|
||||
agent_id = create_agent_id('compact')
|
||||
self.assertRegex(agent_id, r'^acompact-[0-9a-f]{16}$')
|
||||
|
||||
def test_unique_across_calls(self) -> None:
|
||||
ids = {create_agent_id() for _ in range(50)}
|
||||
self.assertEqual(len(ids), 50)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_platform.store import RuntimeStore
|
||||
|
||||
|
||||
async def test_conversation_mode_upgrade_is_one_way(settings) -> None:
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
try:
|
||||
assert await store.select_mode("u1", "c1", "chat") == "chat"
|
||||
assert await store.select_mode("u1", "c1", "work") == "work"
|
||||
assert await store.select_mode("u1", "c1", "chat") == "work"
|
||||
assert await store.select_mode("u2", "c1", "chat") == "chat"
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_memory_and_events_are_user_scoped(settings) -> None:
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
try:
|
||||
memory_id = await store.remember("u1", "prefers compact answers")
|
||||
assert [item["id"] for item in await store.recall("u1", "compact")] == [memory_id]
|
||||
assert await store.recall("u2", "compact") == []
|
||||
|
||||
await store.append_event("r1", "u1", "c1", 1, "run.created", {"x": 1})
|
||||
await store.append_event("r2", "u2", "c1", 1, "run.created", {"x": 2})
|
||||
assert [event["payload"]["x"] for event in await store.events_for_chat("u1", "c1")] == [1]
|
||||
finally:
|
||||
await store.close()
|
||||
@@ -1,249 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig
|
||||
from src.task_runtime import TaskRuntime
|
||||
|
||||
|
||||
class FakeHTTPResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode('utf-8')
|
||||
|
||||
def __enter__(self) -> 'FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_urlopen_side_effect(responses: list[dict[str, object]]):
|
||||
queued = [FakeHTTPResponse(payload) for payload in responses]
|
||||
|
||||
def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001
|
||||
return queued.pop(0)
|
||||
|
||||
return _fake_urlopen
|
||||
|
||||
|
||||
class TaskRuntimeTests(unittest.TestCase):
|
||||
def test_runtime_persists_and_renders_tasks(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = TaskRuntime.from_workspace(workspace)
|
||||
created = runtime.create_task(
|
||||
title='Implement task runtime',
|
||||
description='Add persistent tasks.',
|
||||
status='in_progress',
|
||||
)
|
||||
assert created.task is not None
|
||||
runtime.update_task(created.task.task_id, status='completed')
|
||||
rendered_tasks = runtime.render_tasks()
|
||||
rendered_task = runtime.render_task(created.task.task_id)
|
||||
|
||||
self.assertIn('Implement task runtime', rendered_tasks)
|
||||
self.assertIn('completed', rendered_task)
|
||||
|
||||
def test_task_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = TaskRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
task_runtime=runtime,
|
||||
)
|
||||
create_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_create',
|
||||
{'title': 'Review task tools', 'status': 'pending'},
|
||||
context,
|
||||
)
|
||||
self.assertTrue(create_result.ok)
|
||||
task_id = str(create_result.metadata.get('task_id'))
|
||||
next_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_next',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_list',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
get_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_get',
|
||||
{'task_id': task_id},
|
||||
context,
|
||||
)
|
||||
update_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_update',
|
||||
{'task_id': task_id, 'status': 'completed'},
|
||||
context,
|
||||
)
|
||||
todo_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'todo_write',
|
||||
{'items': [{'title': 'Replace with todo snapshot', 'status': 'in_progress'}]},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertIn('Review task tools', next_result.content)
|
||||
self.assertIn(task_id, list_result.content)
|
||||
self.assertIn('Review task tools', get_result.content)
|
||||
self.assertTrue(update_result.ok)
|
||||
self.assertEqual(update_result.metadata.get('task_status'), 'completed')
|
||||
self.assertTrue(todo_result.ok)
|
||||
self.assertEqual(todo_result.metadata.get('total_tasks'), 1)
|
||||
|
||||
def test_next_tasks_respects_dependencies_and_completion(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = TaskRuntime.from_workspace(workspace)
|
||||
runtime.replace_tasks(
|
||||
[
|
||||
{'task_id': 'scan', 'title': 'Scan workspace', 'status': 'pending'},
|
||||
{
|
||||
'task_id': 'patch',
|
||||
'title': 'Patch files',
|
||||
'status': 'blocked',
|
||||
'blocked_by': ['scan'],
|
||||
},
|
||||
]
|
||||
)
|
||||
first_next = runtime.render_next_tasks()
|
||||
runtime.complete_task('scan')
|
||||
second_next = runtime.render_next_tasks()
|
||||
|
||||
self.assertIn('Scan workspace', first_next)
|
||||
self.assertNotIn('Patch files', first_next)
|
||||
self.assertIn('Patch files', second_next)
|
||||
|
||||
def test_task_execution_tools_handle_block_start_and_complete(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = TaskRuntime.from_workspace(workspace)
|
||||
runtime.replace_tasks(
|
||||
[
|
||||
{'task_id': 'scan', 'title': 'Scan workspace', 'status': 'pending'},
|
||||
{
|
||||
'task_id': 'patch',
|
||||
'title': 'Patch files',
|
||||
'status': 'blocked',
|
||||
'blocked_by': ['scan'],
|
||||
},
|
||||
]
|
||||
)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
task_runtime=runtime,
|
||||
)
|
||||
blocked_start = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_start',
|
||||
{'task_id': 'patch'},
|
||||
context,
|
||||
)
|
||||
complete_scan = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_complete',
|
||||
{'task_id': 'scan'},
|
||||
context,
|
||||
)
|
||||
start_patch = execute_tool(
|
||||
default_tool_registry(),
|
||||
'task_start',
|
||||
{'task_id': 'patch', 'owner': 'agent_1', 'active_form': 'Patching files'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(blocked_start.ok)
|
||||
self.assertIn('[blocked]', blocked_start.content)
|
||||
self.assertTrue(complete_scan.ok)
|
||||
self.assertTrue(start_patch.ok)
|
||||
self.assertIn('[in_progress]', start_patch.content)
|
||||
self.assertEqual(runtime.get_task('patch').owner, 'agent_1')
|
||||
|
||||
def test_agent_can_use_task_tools_in_model_loop(self) -> None:
|
||||
responses = [
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'I will create a task first.',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_1',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'task_create',
|
||||
'arguments': '{"title": "Review runtime tasks", "status": "pending"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
'finish_reason': 'tool_calls',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 8, 'completion_tokens': 3},
|
||||
},
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'The task was created successfully.',
|
||||
},
|
||||
'finish_reason': 'stop',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 6, 'completion_tokens': 3},
|
||||
},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
with patch('src.openai_compat.request.urlopen', side_effect=make_urlopen_side_effect(responses)):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(
|
||||
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
|
||||
base_url='http://127.0.0.1:8000/v1',
|
||||
),
|
||||
runtime_config=AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
)
|
||||
result = agent.run('Create a task for the current work')
|
||||
self.assertIsNotNone(result.scratchpad_directory)
|
||||
self.assertTrue(
|
||||
(Path(result.scratchpad_directory) / 'task_runtime.json').exists()
|
||||
)
|
||||
self.assertFalse((workspace / '.port_sessions' / 'task_runtime.json').exists())
|
||||
|
||||
self.assertEqual(result.final_output, 'The task was created successfully.')
|
||||
self.assertEqual(result.tool_calls, 1)
|
||||
tool_message = next(
|
||||
message
|
||||
for message in result.transcript
|
||||
if message.get('role') == 'tool'
|
||||
)
|
||||
self.assertIn('task_create', tool_message.get('content', ''))
|
||||
@@ -1,76 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||
from src.team_runtime import TeamRuntime
|
||||
|
||||
|
||||
class TeamRuntimeTests(unittest.TestCase):
|
||||
def test_team_runtime_persists_team_and_messages(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = TeamRuntime.from_workspace(workspace)
|
||||
runtime.create_team('reviewers', members=['alice', 'bob'])
|
||||
runtime.send_message(
|
||||
team_name='reviewers',
|
||||
text='Please review the patch.',
|
||||
sender='agent',
|
||||
recipient='alice',
|
||||
)
|
||||
restored = TeamRuntime.from_workspace(workspace)
|
||||
|
||||
self.assertEqual(len(restored.teams), 1)
|
||||
self.assertEqual(restored.teams[0].name, 'reviewers')
|
||||
self.assertEqual(len(restored.messages), 1)
|
||||
self.assertIn('Please review the patch.', restored.render_messages())
|
||||
|
||||
def test_team_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
runtime = TeamRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
team_runtime=runtime,
|
||||
)
|
||||
create_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'team_create',
|
||||
{'team_name': 'reviewers', 'members': ['alice', 'bob']},
|
||||
context,
|
||||
)
|
||||
send_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'send_message',
|
||||
{
|
||||
'team_name': 'reviewers',
|
||||
'message': 'Check notebook changes',
|
||||
'sender': 'agent',
|
||||
},
|
||||
context,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'team_list',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
message_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'team_messages',
|
||||
{'team_name': 'reviewers'},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(create_result.ok)
|
||||
self.assertEqual(create_result.metadata.get('action'), 'team_create')
|
||||
self.assertTrue(send_result.ok)
|
||||
self.assertEqual(send_result.metadata.get('action'), 'send_message')
|
||||
self.assertIn('reviewers', list_result.content)
|
||||
self.assertIn('Check notebook changes', message_result.content)
|
||||
@@ -1,186 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from benchmarks.run_terminal_bench_local import (
|
||||
TerminalBenchTask,
|
||||
build_host_agent_command,
|
||||
build_verifier_exec_command,
|
||||
discover_tasks,
|
||||
filter_tasks,
|
||||
parse_dockerfile_workdir,
|
||||
strip_canary,
|
||||
)
|
||||
|
||||
|
||||
class TerminalBenchLocalTests(unittest.TestCase):
|
||||
def test_strip_canary_removes_leading_markers(self) -> None:
|
||||
raw = "<!-- canary -->\n# canary line\n\nactual instruction\n"
|
||||
self.assertEqual(strip_canary(raw), "actual instruction")
|
||||
|
||||
def test_parse_dockerfile_workdir_uses_last_workdir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
dockerfile = Path(tmp_dir) / "Dockerfile"
|
||||
dockerfile.write_text(
|
||||
"FROM python:3.11\nWORKDIR /repo\nRUN echo hi\nWORKDIR /repo/app\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(parse_dockerfile_workdir(dockerfile), "/repo/app")
|
||||
|
||||
def test_parse_dockerfile_workdir_defaults_when_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
dockerfile = Path(tmp_dir) / "Dockerfile"
|
||||
dockerfile.write_text("FROM ubuntu:22.04\n", encoding="utf-8")
|
||||
self.assertEqual(parse_dockerfile_workdir(dockerfile), "/workspace")
|
||||
|
||||
def test_discover_tasks_and_filter(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
task_a = root / "task-a"
|
||||
task_a.mkdir()
|
||||
(task_a / "instruction.md").write_text("solve a\n", encoding="utf-8")
|
||||
(task_a / "task.toml").write_text(
|
||||
"""
|
||||
schema_version = "1.1"
|
||||
[task]
|
||||
name = "terminal-bench/headless-terminal"
|
||||
description = "demo"
|
||||
[environment]
|
||||
docker_image = "example/demo:latest"
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env_a = task_a / "environment"
|
||||
env_a.mkdir()
|
||||
(env_a / "Dockerfile").write_text("FROM ubuntu\nWORKDIR /work\n", encoding="utf-8")
|
||||
|
||||
task_b = root / "task-b"
|
||||
task_b.mkdir()
|
||||
(task_b / "instruction.md").write_text("solve b\n", encoding="utf-8")
|
||||
(task_b / "task.toml").write_text(
|
||||
"""
|
||||
schema_version = "1.1"
|
||||
[task]
|
||||
name = "terminal-bench/other-task"
|
||||
description = "demo"
|
||||
[environment]
|
||||
docker_image = "example/other:latest"
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env_b = task_b / "environment"
|
||||
env_b.mkdir()
|
||||
(env_b / "docker-compose.yaml").write_text("services: {}\n", encoding="utf-8")
|
||||
|
||||
tasks = discover_tasks(root)
|
||||
self.assertEqual(len(tasks), 2)
|
||||
|
||||
selected = filter_tasks(
|
||||
tasks,
|
||||
include_patterns=["headless-*"],
|
||||
exclude_patterns=[],
|
||||
limit=None,
|
||||
)
|
||||
self.assertEqual(len(selected), 1)
|
||||
self.assertEqual(selected[0].short_name, "headless-terminal")
|
||||
self.assertFalse(selected[0].has_docker_compose)
|
||||
|
||||
selected = filter_tasks(
|
||||
tasks,
|
||||
include_patterns=[],
|
||||
exclude_patterns=["other-*"],
|
||||
limit=None,
|
||||
)
|
||||
self.assertEqual(len(selected), 1)
|
||||
self.assertEqual(selected[0].short_name, "headless-terminal")
|
||||
|
||||
def test_build_host_agent_command_uses_current_interpreter(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
workspace_dir = root / "workspace"
|
||||
repo_dir = root / "repo"
|
||||
agent_logs_dir = root / "agent"
|
||||
workspace_dir.mkdir()
|
||||
repo_dir.mkdir()
|
||||
agent_logs_dir.mkdir()
|
||||
task = TerminalBenchTask(
|
||||
task_dir=root,
|
||||
name="terminal-bench/demo",
|
||||
short_name="demo",
|
||||
instruction="solve it",
|
||||
docker_image="example/demo:latest",
|
||||
agent_timeout_sec=30.0,
|
||||
verifier_timeout_sec=30.0,
|
||||
workdir="/workspace",
|
||||
has_docker_compose=False,
|
||||
)
|
||||
|
||||
cmd = build_host_agent_command(
|
||||
task=task,
|
||||
workspace_dir=workspace_dir,
|
||||
repo_dir=repo_dir,
|
||||
agent_logs_dir=agent_logs_dir,
|
||||
)
|
||||
|
||||
self.assertIn(" -m src.main agent ", cmd)
|
||||
self.assertIn("instruction=$(cat", cmd)
|
||||
self.assertNotIn("claw-code-agent agent", cmd)
|
||||
|
||||
def test_build_verifier_command_fakeroot_adds_flags(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
workspace_dir = root / "workspace"
|
||||
verifier_logs_dir = root / "verifier"
|
||||
task_dir = root / "task"
|
||||
tests_dir = task_dir / "tests"
|
||||
workspace_dir.mkdir()
|
||||
verifier_logs_dir.mkdir()
|
||||
tests_dir.mkdir(parents=True)
|
||||
image_path = root / "image.sif"
|
||||
image_path.write_text("fake", encoding="utf-8")
|
||||
task = TerminalBenchTask(
|
||||
task_dir=task_dir,
|
||||
name="terminal-bench/demo",
|
||||
short_name="demo",
|
||||
instruction="solve it",
|
||||
docker_image="example/demo:latest",
|
||||
agent_timeout_sec=30.0,
|
||||
verifier_timeout_sec=30.0,
|
||||
workdir="/workspace",
|
||||
has_docker_compose=False,
|
||||
)
|
||||
|
||||
cmd_no_fakeroot = build_verifier_exec_command(
|
||||
task=task,
|
||||
image_path=image_path,
|
||||
workspace_dir=workspace_dir,
|
||||
task_dir=task_dir,
|
||||
verifier_logs_dir=verifier_logs_dir,
|
||||
env={},
|
||||
fakeroot=False,
|
||||
)
|
||||
self.assertNotIn("--fakeroot", cmd_no_fakeroot)
|
||||
self.assertNotIn("--writable-tmpfs", cmd_no_fakeroot)
|
||||
|
||||
cmd_fakeroot = build_verifier_exec_command(
|
||||
task=task,
|
||||
image_path=image_path,
|
||||
workspace_dir=workspace_dir,
|
||||
task_dir=task_dir,
|
||||
verifier_logs_dir=verifier_logs_dir,
|
||||
env={},
|
||||
fakeroot=True,
|
||||
)
|
||||
self.assertIn("--fakeroot", cmd_fakeroot)
|
||||
self.assertIn("--writable-tmpfs", cmd_fakeroot)
|
||||
self.assertIn("--contain", cmd_fakeroot)
|
||||
self.assertIn("TMPDIR", cmd_fakeroot)
|
||||
self.assertIn("CURL_CA_BUNDLE", cmd_fakeroot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,100 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_session import AgentSessionState
|
||||
from src.agent_context_usage import ContextUsageReport, MessageBreakdown
|
||||
from src.agent_types import BudgetConfig
|
||||
from src.token_budget import calculate_token_budget, format_token_budget
|
||||
|
||||
|
||||
class TokenBudgetTests(unittest.TestCase):
|
||||
def test_calculate_token_budget_reports_soft_and_hard_limits(self) -> None:
|
||||
session = AgentSessionState.create(
|
||||
['# System\nYou are helpful.'],
|
||||
'Inspect the repository and summarize the current implementation status.',
|
||||
user_context={'currentDate': "Today's date is 2026-04-11."},
|
||||
system_context={'gitStatus': 'Current branch: main'},
|
||||
)
|
||||
session.append_assistant('Reading files and checking runtime state.')
|
||||
|
||||
fake_usage = ContextUsageReport(
|
||||
model='test-model',
|
||||
total_tokens=200,
|
||||
raw_max_tokens=128_000,
|
||||
percentage=0.15,
|
||||
strategy='token_budget',
|
||||
message_count=len(session.messages),
|
||||
categories=(),
|
||||
system_prompt_sections=(),
|
||||
user_context_entries=(),
|
||||
system_context_entries=(),
|
||||
memory_files=(),
|
||||
message_breakdown=MessageBreakdown(
|
||||
user_message_tokens=50,
|
||||
assistant_message_tokens=50,
|
||||
tool_call_tokens=0,
|
||||
tool_result_tokens=0,
|
||||
user_context_tokens=10,
|
||||
tool_calls_by_type=(),
|
||||
),
|
||||
token_counter_backend='heuristic',
|
||||
token_counter_source='test',
|
||||
token_counter_accurate=False,
|
||||
)
|
||||
with patch('src.token_budget.collect_context_usage', return_value=fake_usage):
|
||||
snapshot = calculate_token_budget(
|
||||
session=session,
|
||||
model='test-model',
|
||||
budget_config=BudgetConfig(),
|
||||
)
|
||||
rendered = format_token_budget(snapshot)
|
||||
|
||||
self.assertGreater(snapshot.projected_input_tokens, 0)
|
||||
self.assertGreater(snapshot.hard_input_limit_tokens, snapshot.soft_input_limit_tokens)
|
||||
self.assertGreater(snapshot.chat_overhead_tokens, 0)
|
||||
self.assertIn('# Token Budget', rendered)
|
||||
self.assertIn('Hard input limit', rendered)
|
||||
self.assertIn('Auto-compact buffer', rendered)
|
||||
|
||||
def test_calculate_token_budget_honors_explicit_max_input_tokens(self) -> None:
|
||||
session = AgentSessionState.create(
|
||||
['# System\nYou are helpful.'],
|
||||
'This prompt is deliberately longer than the tiny configured input budget. ' * 4,
|
||||
)
|
||||
|
||||
fake_usage = ContextUsageReport(
|
||||
model='test-model',
|
||||
total_tokens=120,
|
||||
raw_max_tokens=128_000,
|
||||
percentage=0.09,
|
||||
strategy='token_budget',
|
||||
message_count=len(session.messages),
|
||||
categories=(),
|
||||
system_prompt_sections=(),
|
||||
user_context_entries=(),
|
||||
system_context_entries=(),
|
||||
memory_files=(),
|
||||
message_breakdown=MessageBreakdown(
|
||||
user_message_tokens=80,
|
||||
assistant_message_tokens=0,
|
||||
tool_call_tokens=0,
|
||||
tool_result_tokens=0,
|
||||
user_context_tokens=0,
|
||||
tool_calls_by_type=(),
|
||||
),
|
||||
token_counter_backend='heuristic',
|
||||
token_counter_source='test',
|
||||
token_counter_accurate=False,
|
||||
)
|
||||
with patch('src.token_budget.collect_context_usage', return_value=fake_usage):
|
||||
snapshot = calculate_token_budget(
|
||||
session=session,
|
||||
model='test-model',
|
||||
budget_config=BudgetConfig(max_input_tokens=20),
|
||||
)
|
||||
|
||||
self.assertTrue(snapshot.exceeds_hard_limit)
|
||||
self.assertGreater(snapshot.overflow_tokens, 0)
|
||||
self.assertLessEqual(snapshot.hard_input_limit_tokens, 20)
|
||||
@@ -1,67 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.tokenizer_runtime import (
|
||||
ResolvedTokenCounter,
|
||||
TokenCounterInfo,
|
||||
clear_token_counter_cache,
|
||||
count_tokens,
|
||||
describe_token_counter,
|
||||
resolve_token_counter,
|
||||
)
|
||||
|
||||
class TokenizerRuntimeTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
clear_token_counter_cache()
|
||||
|
||||
def test_gpt_models_prefer_tiktoken_backend_when_available(self) -> None:
|
||||
fake_counter = ResolvedTokenCounter(
|
||||
info=TokenCounterInfo(
|
||||
backend='tiktoken',
|
||||
source='o200k_base',
|
||||
accurate=True,
|
||||
),
|
||||
count_text=lambda text: len(text.split()),
|
||||
)
|
||||
with patch('src.tokenizer_runtime._try_build_tiktoken_counter', return_value=fake_counter):
|
||||
with patch('src.tokenizer_runtime._try_build_transformers_counter', return_value=None):
|
||||
info = describe_token_counter('gpt-4o-mini')
|
||||
token_count = count_tokens('hello world from claw code', 'gpt-4o-mini')
|
||||
|
||||
self.assertEqual(info.backend, 'tiktoken')
|
||||
self.assertTrue(info.accurate)
|
||||
self.assertEqual(token_count, 5)
|
||||
|
||||
def test_transformers_backend_can_be_selected_with_env_override(self) -> None:
|
||||
fake_counter = ResolvedTokenCounter(
|
||||
info=TokenCounterInfo(
|
||||
backend='transformers',
|
||||
source='/tmp/fake-tokenizer (local_files_only)',
|
||||
accurate=True,
|
||||
),
|
||||
count_text=lambda text: len(text.split()),
|
||||
)
|
||||
with patch.dict(
|
||||
'os.environ',
|
||||
{'CLAW_CODE_TOKENIZER_PATH': '/tmp/fake-tokenizer'},
|
||||
clear=False,
|
||||
):
|
||||
with patch('src.tokenizer_runtime._try_build_transformers_counter', return_value=fake_counter):
|
||||
info = describe_token_counter('Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
token_count = count_tokens('one two three', 'Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||
|
||||
self.assertEqual(info.backend, 'transformers')
|
||||
self.assertTrue(info.accurate)
|
||||
self.assertEqual(token_count, 3)
|
||||
|
||||
def test_fallback_backend_is_used_when_all_tokenizers_fail(self) -> None:
|
||||
with patch('src.tokenizer_runtime._try_build_tiktoken_counter', return_value=None):
|
||||
with patch('src.tokenizer_runtime._try_build_transformers_counter', return_value=None):
|
||||
counter = resolve_token_counter('unknown-model')
|
||||
token_count = count_tokens('abcd' * 5, 'unknown-model')
|
||||
|
||||
self.assertEqual(counter.info.backend, 'heuristic')
|
||||
self.assertFalse(counter.info.accurate)
|
||||
self.assertGreater(token_count, 0)
|
||||
@@ -1,106 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.models import PortingModule
|
||||
from src.permissions import ToolPermissionContext
|
||||
from src.tool_pool import ToolPool, assemble_tool_pool
|
||||
|
||||
|
||||
class TestAssembleToolPool(unittest.TestCase):
|
||||
def test_returns_tool_pool_with_tools(self) -> None:
|
||||
pool = assemble_tool_pool()
|
||||
self.assertIsInstance(pool, ToolPool)
|
||||
self.assertIsInstance(pool.tools, tuple)
|
||||
self.assertTrue(all(isinstance(t, PortingModule) for t in pool.tools))
|
||||
|
||||
def test_default_mode_includes_tools(self) -> None:
|
||||
pool = assemble_tool_pool()
|
||||
self.assertGreater(len(pool.tools), 0)
|
||||
|
||||
def test_simple_mode_flag_stored(self) -> None:
|
||||
pool_default = assemble_tool_pool()
|
||||
pool_simple = assemble_tool_pool(simple_mode=True)
|
||||
self.assertFalse(pool_default.simple_mode)
|
||||
self.assertTrue(pool_simple.simple_mode)
|
||||
|
||||
def test_include_mcp_flag_stored(self) -> None:
|
||||
pool_default = assemble_tool_pool()
|
||||
pool_no_mcp = assemble_tool_pool(include_mcp=False)
|
||||
self.assertTrue(pool_default.include_mcp)
|
||||
self.assertFalse(pool_no_mcp.include_mcp)
|
||||
|
||||
def test_simple_mode_reduces_tools(self) -> None:
|
||||
pool_full = assemble_tool_pool(simple_mode=False)
|
||||
pool_simple = assemble_tool_pool(simple_mode=True)
|
||||
self.assertGreater(len(pool_full.tools), len(pool_simple.tools))
|
||||
simple_names = {t.name for t in pool_simple.tools}
|
||||
self.assertTrue(simple_names.issubset({'BashTool', 'FileReadTool', 'FileEditTool'}))
|
||||
|
||||
def test_include_mcp_false_excludes_mcp_tools(self) -> None:
|
||||
pool = assemble_tool_pool(include_mcp=False)
|
||||
for tool in pool.tools:
|
||||
self.assertNotIn('mcp', tool.name.lower())
|
||||
self.assertNotIn('mcp', tool.source_hint.lower())
|
||||
|
||||
def test_permission_context_filters_blocked_tools(self) -> None:
|
||||
ctx = ToolPermissionContext.from_iterables(deny_names=['BashTool'])
|
||||
pool = assemble_tool_pool(permission_context=ctx)
|
||||
tool_names = {t.name for t in pool.tools}
|
||||
self.assertNotIn('BashTool', tool_names)
|
||||
|
||||
pool_unfiltered = assemble_tool_pool()
|
||||
unfiltered_names = {t.name for t in pool_unfiltered.tools}
|
||||
self.assertIn('BashTool', unfiltered_names)
|
||||
|
||||
|
||||
class TestToolPoolAsMarkdown(unittest.TestCase):
|
||||
def test_includes_header_and_tool_count(self) -> None:
|
||||
pool = assemble_tool_pool()
|
||||
md = pool.as_markdown()
|
||||
self.assertIn('# Tool Pool', md)
|
||||
self.assertIn(f'Tool count: {len(pool.tools)}', md)
|
||||
|
||||
def test_includes_mode_flags(self) -> None:
|
||||
pool = assemble_tool_pool(simple_mode=True, include_mcp=False)
|
||||
md = pool.as_markdown()
|
||||
self.assertIn('Simple mode: True', md)
|
||||
self.assertIn('Include MCP: False', md)
|
||||
|
||||
def test_shows_at_most_15_tools(self) -> None:
|
||||
pool = assemble_tool_pool()
|
||||
self.assertGreater(len(pool.tools), 15, 'Need >15 tools for this test')
|
||||
md = pool.as_markdown()
|
||||
tool_lines = [line for line in md.splitlines() if line.startswith('- ')]
|
||||
self.assertEqual(len(tool_lines), 15)
|
||||
|
||||
def test_empty_tools_renders_correctly(self) -> None:
|
||||
pool = ToolPool(tools=(), simple_mode=False, include_mcp=True)
|
||||
md = pool.as_markdown()
|
||||
self.assertIn('# Tool Pool', md)
|
||||
self.assertIn('Tool count: 0', md)
|
||||
self.assertIn('Simple mode: False', md)
|
||||
self.assertIn('Include MCP: True', md)
|
||||
tool_lines = [line for line in md.splitlines() if line.startswith('- ')]
|
||||
self.assertEqual(len(tool_lines), 0)
|
||||
|
||||
def test_tool_lines_contain_name_and_source_hint(self) -> None:
|
||||
tool = PortingModule(
|
||||
name='TestTool',
|
||||
responsibility='testing',
|
||||
source_hint='test/path.ts',
|
||||
)
|
||||
pool = ToolPool(tools=(tool,), simple_mode=False, include_mcp=False)
|
||||
md = pool.as_markdown()
|
||||
self.assertIn('- TestTool — test/path.ts', md)
|
||||
|
||||
|
||||
class TestToolPoolFrozen(unittest.TestCase):
|
||||
def test_cannot_mutate_fields(self) -> None:
|
||||
pool = ToolPool(tools=(), simple_mode=False, include_mcp=True)
|
||||
with self.assertRaises(AttributeError):
|
||||
pool.simple_mode = True # type: ignore[misc]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,77 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentRuntimeConfig
|
||||
from src.workflow_runtime import WorkflowRuntime
|
||||
|
||||
|
||||
class WorkflowRuntimeTests(unittest.TestCase):
|
||||
def test_workflow_runtime_discovers_and_runs_workflow(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-workflows.json').write_text(
|
||||
(
|
||||
'{"workflows":['
|
||||
'{"name":"review","description":"Review the current patch.",'
|
||||
'"steps":[{"title":"Inspect diff","detail":"Read {path}"},{"title":"Summarize"}],'
|
||||
'"prompt":"Review changes under {path}"}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = WorkflowRuntime.from_workspace(workspace)
|
||||
rendered = runtime.render_workflow('review')
|
||||
run_report = runtime.render_run_report('review', arguments={'path': 'src/'})
|
||||
|
||||
self.assertIn('Review the current patch', rendered)
|
||||
self.assertIn('Read src/', run_report)
|
||||
self.assertIn('Review changes under src/', run_report)
|
||||
|
||||
def test_workflow_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
(workspace / '.claw-workflows.json').write_text(
|
||||
(
|
||||
'{"workflows":['
|
||||
'{"name":"build","description":"Build the project.",'
|
||||
'"steps":["Inspect package","Run build"]}'
|
||||
']}'
|
||||
),
|
||||
encoding='utf-8',
|
||||
)
|
||||
runtime = WorkflowRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(cwd=workspace),
|
||||
workflow_runtime=runtime,
|
||||
)
|
||||
list_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'workflow_list',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
get_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'workflow_get',
|
||||
{'workflow_name': 'build'},
|
||||
context,
|
||||
)
|
||||
run_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'workflow_run',
|
||||
{'workflow_name': 'build', 'arguments': {'target': 'dist'}},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(list_result.ok)
|
||||
self.assertIn('build', list_result.content)
|
||||
self.assertTrue(get_result.ok)
|
||||
self.assertIn('Build the project', get_result.content)
|
||||
self.assertTrue(run_result.ok)
|
||||
self.assertEqual(run_result.metadata.get('action'), 'workflow_run')
|
||||
self.assertIn('# Workflow Run', run_result.content)
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from src.agent_types import AgentPermissions, AgentRuntimeConfig, ModelConfig
|
||||
from src.worktree_runtime import WorktreeRuntime
|
||||
|
||||
|
||||
class _FakeHTTPResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode('utf-8')
|
||||
|
||||
def __enter__(self) -> '_FakeHTTPResponse':
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_urlopen_side_effect(responses: list[dict[str, object]]):
|
||||
queued = [_FakeHTTPResponse(payload) for payload in responses]
|
||||
|
||||
def _fake_urlopen(request_obj, timeout=None): # noqa: ANN001
|
||||
return queued.pop(0)
|
||||
|
||||
return _fake_urlopen
|
||||
|
||||
|
||||
def _init_git_repo(workspace: Path) -> None:
|
||||
subprocess.run(['git', 'init', '-q'], cwd=workspace, check=True)
|
||||
subprocess.run(['git', 'config', 'user.email', 'test@example.com'], cwd=workspace, check=True)
|
||||
subprocess.run(['git', 'config', 'user.name', 'Test User'], cwd=workspace, check=True)
|
||||
(workspace / 'README.md').write_text('hello\n', encoding='utf-8')
|
||||
subprocess.run(['git', 'add', 'README.md'], cwd=workspace, check=True)
|
||||
subprocess.run(['git', 'commit', '-qm', 'init'], cwd=workspace, check=True)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('git'), 'git is required for worktree tests')
|
||||
class WorktreeRuntimeTests(unittest.TestCase):
|
||||
def test_worktree_runtime_enters_and_exits_managed_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
_init_git_repo(workspace)
|
||||
runtime = WorktreeRuntime.from_workspace(workspace)
|
||||
enter_report = runtime.enter('feature-preview')
|
||||
worktree_path = Path(enter_report.worktree_path or '')
|
||||
exit_report = runtime.exit(action='keep')
|
||||
|
||||
self.assertTrue(enter_report.active)
|
||||
self.assertTrue(worktree_path.exists())
|
||||
self.assertIn('feature-preview', enter_report.worktree_branch or '')
|
||||
self.assertFalse(exit_report.active)
|
||||
self.assertEqual(exit_report.original_cwd, str(workspace))
|
||||
|
||||
def test_worktree_tools_execute_against_runtime(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
_init_git_repo(workspace)
|
||||
runtime = WorktreeRuntime.from_workspace(workspace)
|
||||
context = build_tool_context(
|
||||
AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
worktree_runtime=runtime,
|
||||
)
|
||||
enter_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'worktree_enter',
|
||||
{'name': 'preview'},
|
||||
context,
|
||||
)
|
||||
status_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'worktree_status',
|
||||
{},
|
||||
context,
|
||||
)
|
||||
exit_result = execute_tool(
|
||||
default_tool_registry(),
|
||||
'worktree_exit',
|
||||
{'action': 'remove', 'discard_changes': True},
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertTrue(enter_result.ok)
|
||||
self.assertIn('preview', enter_result.content)
|
||||
self.assertEqual(enter_result.metadata.get('action'), 'worktree_enter')
|
||||
self.assertTrue(status_result.ok)
|
||||
self.assertIn('Active managed worktree: True', status_result.content)
|
||||
self.assertTrue(exit_result.ok)
|
||||
self.assertEqual(exit_result.metadata.get('action'), 'worktree_exit')
|
||||
|
||||
def test_agent_switches_cwd_after_worktree_enter(self) -> None:
|
||||
responses = [
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'Entering worktree.',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_enter',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'worktree_enter',
|
||||
'arguments': '{"name":"preview"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
'finish_reason': 'tool_calls',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 8, 'completion_tokens': 2},
|
||||
},
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'Writing inside the worktree.',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': 'call_write',
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'write_file',
|
||||
'arguments': '{"path":"note.txt","content":"from worktree\\n"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
'finish_reason': 'tool_calls',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 8, 'completion_tokens': 2},
|
||||
},
|
||||
{
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'done',
|
||||
},
|
||||
'finish_reason': 'stop',
|
||||
}
|
||||
],
|
||||
'usage': {'prompt_tokens': 6, 'completion_tokens': 1},
|
||||
},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace = Path(tmp_dir)
|
||||
_init_git_repo(workspace)
|
||||
with patch(
|
||||
'src.openai_compat.request.urlopen',
|
||||
side_effect=_make_urlopen_side_effect(responses),
|
||||
):
|
||||
agent = LocalCodingAgent(
|
||||
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
runtime_config=AgentRuntimeConfig(
|
||||
cwd=workspace,
|
||||
permissions=AgentPermissions(allow_file_write=True),
|
||||
),
|
||||
)
|
||||
result = agent.run('Use a worktree and write a file there')
|
||||
runtime = WorktreeRuntime.from_workspace(workspace)
|
||||
assert runtime.active_session is not None
|
||||
worktree_path = Path(runtime.active_session.worktree_path)
|
||||
|
||||
self.assertEqual(result.final_output, 'done')
|
||||
self.assertFalse((workspace / 'note.txt').exists())
|
||||
self.assertTrue((worktree_path / 'note.txt').exists())
|
||||
self.assertEqual(agent.runtime_config.cwd, worktree_path.resolve())
|
||||
|
||||
Reference in New Issue
Block a user