Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/opendirector_api/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
3 changes: 3 additions & 0 deletions docs/development/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)。
Expand Down
16 changes: 12 additions & 4 deletions packages/core/src/opendirector/agent/native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand All @@ -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'
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/opendirector/agent/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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']
Expand Down
15 changes: 9 additions & 6 deletions packages/core/src/opendirector/agent/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions tests/test_native_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
36 changes: 36 additions & 0 deletions tests/test_runtime_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']