diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 024a27c6..a871fa6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: npm --version python --version npm ci + npm exec --no -- opencode --version python -m pip install -r requirements.txt python -m pip check - name: Frontend tests diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index 5e2e3e98..5e0198c8 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -187,7 +187,7 @@ async def available_runtimes(project_id: UUID, probe: bool = True): if probe: return await configured_runtimes() return [{**item, 'status': 'not_installed' if not item['available'] else - 'ready' if item['id'] in {'default', 'opencode'} else 'checking'} + 'ready' if item['id'] == 'opencode' else 'checking'} for item in runtime_catalog()] @router.get('/runtimes/{runtime}/status') diff --git a/docs/development/validation.md b/docs/development/validation.md index afe225e3..11167401 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -8,6 +8,7 @@ ```sh npm ci +npm exec --no -- opencode --version python3 -m venv .venv .venv/bin/python -m pip install -r requirements.txt @@ -17,6 +18,8 @@ npm run build .venv/bin/python scripts/generate_contracts.py --check ``` +内置 Agent 安装验证必须实际执行上述 OpenCode 版本命令,不能只检查文件存在。不要跳过 `opencode-ai` 的安装脚本;若依赖安装不完整,在仓库根目录重新执行 `npm ci` 并允许安装脚本运行。 + Windows 使用对应虚拟环境的 Python 路径。涉及真实媒体探测、工具处理或对应测试时,需要 PATH 中的 `ffmpeg` 和 `ffprobe`;视频处理使用 `libx264` 编码器,可通过 `ffmpeg -encoders` 检查是否包含它。不因纯文档修改而运行全部生成或媒体测试。 macOS 使用 README 中的 `python3.12` 环境准备命令,避免误用系统旧版 `python3`。安装验证需使用全新源码目录、新建 `.venv` 和 `npm ci`,然后构建并通过正式 `serve` 入口检查页面、上传、刷新与服务重启恢复。实测边界见 [macOS 安装验证记录](macos-install-validation.md)。 diff --git a/packages/core/src/opendirector/agent/native.py b/packages/core/src/opendirector/agent/native.py index 9c9d0fd9..e28ea138 100644 --- a/packages/core/src/opendirector/agent/native.py +++ b/packages/core/src/opendirector/agent/native.py @@ -20,6 +20,9 @@ from opendirector.integrations.chat_completions import ChatCompletions +BUNDLED_INSTALL_ERROR = ('内置 Agent 依赖安装不完整或无法运行,请在仓库根目录执行 npm ci,并确保允许 opencode-ai 的安装脚本运行。') + + def executable(runtime: str) -> str | None: if runtime == 'default': for name in ('opencode.exe', 'opencode'): @@ -35,7 +38,7 @@ def command(runtime: str, work: Path, socket: str, token: str, model: str | None bridge_env: dict[str, str] | None = None) -> tuple[list[str], dict[str, str]]: binary = executable(runtime) if binary is None: - raise ProjectError('内置 OpenCode 未安装,请执行 npm ci。' if runtime == 'default' else f'{runtime} CLI 未安装,请先安装。', 503) + raise ProjectError(BUNDLED_INSTALL_ERROR if runtime == 'default' else f'{runtime} CLI 未安装,请先安装。', 503) bundled = runtime == 'default' if bundled: runtime = 'opencode' @@ -209,9 +212,14 @@ async def connection(reader, writer): prompt = f'Use the native skill tool to load {name} from {work / ".agents/skills"} before proceeding.\n' + prompt else: prompt = f'Use the native skill tool to load {name} before proceeding.\n' + prompt - process = await asyncio.create_subprocess_exec(*args, cwd=work, env=env, - stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, start_new_session=True, limit=64 * 1024 * 1024) + try: + process = await asyncio.create_subprocess_exec(*args, cwd=work, env=env, + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, start_new_session=True, limit=64 * 1024 * 1024) + except OSError as error: + if run.runtime == 'default': + raise ProjectError(BUNDLED_INSTALL_ERROR, 503, code='agent_dependency_unavailable') from error + raise stderr_tail = bytearray() async def drain_stderr(): while chunk := await process.stderr.read(4096): diff --git a/packages/core/src/opendirector/agent/runtime.py b/packages/core/src/opendirector/agent/runtime.py index 18d2541a..cf3c46b5 100644 --- a/packages/core/src/opendirector/agent/runtime.py +++ b/packages/core/src/opendirector/agent/runtime.py @@ -69,9 +69,9 @@ async def runtime_readiness(engine: str) -> str: executable = native.executable(engine) if executable is None: return 'not_installed' - if engine in {'default', 'opencode'}: + if engine == 'opencode': return 'ready' - args = ['login', 'status'] if engine == 'codex' else ['auth', 'status', '--json'] + args = ['--version'] if engine == 'default' else ['login', 'status'] if engine == 'codex' else ['auth', 'status', '--json'] try: process = await asyncio.create_subprocess_exec( str(executable), *args, stdout=asyncio.subprocess.PIPE, @@ -80,6 +80,8 @@ async def runtime_readiness(engine: str) -> str: return 'unknown' try: output, _ = await asyncio.wait_for(process.communicate(), 5) + if engine == 'default': + return 'ready' if process.returncode == 0 and output.strip() else 'unknown' if engine == 'claude': try: logged_in = json.loads(output)['loggedIn'] diff --git a/packages/core/src/opendirector/agent/skills.py b/packages/core/src/opendirector/agent/skills.py index 679af666..988d25e7 100644 --- a/packages/core/src/opendirector/agent/skills.py +++ b/packages/core/src/opendirector/agent/skills.py @@ -5,7 +5,7 @@ from pathlib import Path from tempfile import TemporaryDirectory -from opendirector.agent.native import executable +from opendirector.agent.native import BUNDLED_INSTALL_ERROR, executable from opendirector.agent.process import terminate from opendirector.agent.tools import ROOT from opendirector.domain.projects import ProjectError @@ -14,20 +14,23 @@ async def skills(repository: Path, directory: Path) -> list[dict[str, str]]: binary = executable('default') if binary is None: - raise ProjectError('技能发现需要内置 OpenCode,请执行 npm ci。', 503) + raise ProjectError(BUNDLED_INSTALL_ERROR, 503, code='agent_dependency_unavailable') roots = [path.resolve() for path in [directory / '.opendirector/skills', repository / '.opendirector/skills', ROOT / 'packages/agent/skills'] if path.is_dir()] with TemporaryDirectory(prefix='od-skills-') as temporary: env = {**os.environ, 'XDG_CONFIG_HOME': temporary, 'OPENCODE_DISABLE_EXTERNAL_SKILLS': '1', 'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS': '1', 'OPENCODE_CONFIG_CONTENT': json.dumps({'skills': {'paths': list(map(str, roots))}})} - process = await asyncio.create_subprocess_exec(binary, 'debug', 'skill', '--pure', - cwd=temporary, env=env, stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + try: + process = await asyncio.create_subprocess_exec(binary, 'debug', 'skill', '--pure', + cwd=temporary, env=env, stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + except OSError as error: + raise ProjectError(BUNDLED_INSTALL_ERROR, 503, code='agent_dependency_unavailable') from error try: data, _ = await asyncio.wait_for(process.communicate(), 20) if process.returncode: - raise ProjectError('OpenCode 技能发现失败,请检查 npm 依赖。', 503) + raise ProjectError(BUNDLED_INSTALL_ERROR, 503, code='agent_dependency_unavailable') try: catalog = json.loads(data) except json.JSONDecodeError: diff --git a/tests/test_native_runtime.py b/tests/test_native_runtime.py index e4348fc8..e9ea6c1f 100644 --- a/tests/test_native_runtime.py +++ b/tests/test_native_runtime.py @@ -245,3 +245,24 @@ def test_bundled_model_declares_configured_inputs_without_changing_cli_provider( config = json.loads(env['OPENCODE_CONFIG_CONTENT']) assert config['provider']['opendirector']['models']['vision-agent']['modalities'] == { 'input': ['text', 'image'], 'output': ['text']} + + +def test_bundled_launch_reports_incomplete_install(tmp_path, monkeypatch): + from opendirector.domain.projects import ProjectError + from opendirector.infrastructure.creation_projects import CreationProjects + + binary = tmp_path / 'opencode.exe' + binary.write_text('echo "postinstall was not run"\nexit 1\n') + binary.chmod(0o755) + monkeypatch.setattr(native, 'executable', lambda _: str(binary)) + instance = AgentRuntimes(CreationProjects(LocalProjects(tmp_path)), None, ModelConfigs(tmp_path)) + async def check(): + with pytest.raises(ProjectError, match='内置 Agent 依赖安装不完整') as caught: + async for _ in instance.stream(RunContext(runtime='default', project_id=uuid4(), + turn_id=uuid4(), session=str(uuid4()), directory=tmp_path, + config={'modelId': 'test', 'apiKey': '', 'url': 'http://localhost/v1', 'protocol': 'openai'}, + messages=[{'role': 'user', 'content': 'hello'}], selected_skills=[])): + pass + assert caught.value.code == 'agent_dependency_unavailable' + assert caught.value.status == 503 + asyncio.run(check()) diff --git a/tests/test_runtime_readiness.py b/tests/test_runtime_readiness.py index 2fb244d6..46a43b5c 100644 --- a/tests/test_runtime_readiness.py +++ b/tests/test_runtime_readiness.py @@ -97,3 +97,39 @@ async def probe(engine): assert response.status_code == 200 assert response.json()['status']=='ready' assert calls == ['codex'] + + +@pytest.mark.parametrize('content, expected', [ + ('echo "postinstall was not run"\nexit 1\n', 'unknown'), + (f'#!{sys.executable}\nraise SystemExit(1)\n', 'unknown'), + (f'#!{sys.executable}\nimport sys\nassert sys.argv[1:] == ["--version"]\nprint("1.19.5")\n', 'ready'), +]) +def test_bundled_readiness_runs_binary(tmp_path, monkeypatch, content, expected): + binary = tmp_path / 'opencode.exe' + binary.write_text(content) + binary.chmod(0o755) + monkeypatch.setattr(runtime.native, 'executable', lambda _: str(binary)) + assert asyncio.run(runtime.runtime_readiness('default')) == expected + + +def test_skill_endpoint_reports_incomplete_bundled_install(tmp_path, monkeypatch): + from uuid import uuid4 + from fastapi.testclient import TestClient + from opendirector_api.main import create_app + from opendirector.infrastructure.local_projects import LocalProjects + + binary = tmp_path / 'opencode.exe' + binary.write_text('echo "postinstall was not run"\nexit 1\n') + binary.chmod(0o755) + monkeypatch.setattr('opendirector.agent.skills.executable', lambda _: str(binary)) + pid = str(uuid4()) + path = f'/api/creation/projects/{pid}' + with TestClient(create_app(LocalProjects(tmp_path)), headers={ + 'X-OpenDirector-Request': '1', 'X-OpenDirector-Session': str(uuid4())}) as client: + assert client.post(path + '/commands', json={'type': 'project.create', 'projectId': pid, + 'commandId': str(uuid4()), 'payload': {'name': 'Incomplete install'}}).status_code == 200 + response = client.get(path + '/assistant/skills') + assert response.status_code == 503 + assert response.json()['code'] == 'agent_dependency_unavailable' + assert 'npm ci' in response.json()['detail'] + assert '磁盘空间' not in response.json()['detail']