"""Workspace initialization tests.""" from __future__ import annotations import json import os from pathlib import Path from unittest.mock import patch import pytest @pytest.fixture def mock_agents_dir(tmp_path: Path) -> Path: agents_dir = tmp_path / "agentkit" config_dir = agents_dir / "config" config_dir.mkdir(parents=True) (config_dir / "opencode.json.template").write_text( json.dumps( { "$schema": "https://opencode.ai/config.json", "default_agent": "investigator", "permission": {"skill": {"*": "allow"}}, "agent": { "investigator": { "mode": "primary", "prompt": "{file:./investibook/agents/investigator.md}", }, "evidence-agent": { "mode": "subagent", "prompt": "{file:./investibook/agents/evidence-agent.md}", }, "diagnosis-analyst": { "mode": "subagent", "prompt": "{file:./investibook/agents/diagnosis-analyst.md}", }, }, }, indent=2, ) ) (config_dir / "SKILL_CATALOG.md").write_text("# Skill Catalog\n") investibook_config_dir = config_dir / "investibook" agents_config_dir = investibook_config_dir / "agents" agents_config_dir.mkdir(parents=True) for name in ("investigator", "evidence-agent", "diagnosis-analyst"): (agents_config_dir / f"{name}.md").write_text(f"# {name}\n") schemas_config_dir = investibook_config_dir / "schemas" schemas_config_dir.mkdir() (schemas_config_dir / "evidence-agent-output.schema.json").write_text('{"type":"object"}\n') (schemas_config_dir / "diagnosis-analyst-output.schema.json").write_text('{"type":"object"}\n') skills_dir = agents_dir / "skills" / "builtin" skills_dir.mkdir(parents=True) for skill_name in [ "clinic-cli", "devops", "github-issue-search", "grafana-panel-finder", "jira", "time-conversion", "tidb-clinic-diagnosis", "tidb-knowledge-base", ]: skill_dir = skills_dir / skill_name skill_dir.mkdir() (skill_dir / "SKILL.md").write_text(f"# {skill_name}\n") plugins_dir = agents_dir / "plugins" plugins_dir.mkdir() plugin_dir = plugins_dir / "telemetry" plugin_dir.mkdir() (plugin_dir / "index.ts").write_text("export default {};\n") return agents_dir def test_workspace_init_writes_opencode_without_tool_servers( tmp_path: Path, mock_agents_dir: Path ) -> None: from investibook.core.workspace_setup import _write_workspace_opencode_json workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"): _write_workspace_opencode_json(workspace_dir) config = json.loads((workspace_dir / ".opencode" / "opencode.json").read_text()) assert "instructions" not in config assert "permission" in config assert "m" + "cp" not in config def test_workspace_init_enables_default_skills(tmp_path: Path, mock_agents_dir: Path) -> None: from investibook.core.workspace_setup import _write_workspace_opencode_json workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"): _write_workspace_opencode_json(workspace_dir) enabled = workspace_dir / ".opencode" / "skills" / "builtin" assert (enabled / "clinic-cli" / "SKILL.md").exists() assert (enabled / "devops" / "SKILL.md").exists() assert (enabled / "jira" / "SKILL.md").exists() assert (enabled / "tidb-knowledge-base" / "SKILL.md").exists() assert not (enabled / "tidb-clinic-diagnosis" / "SKILL.md").exists() def test_workspace_init_copies_plugins(tmp_path: Path, mock_agents_dir: Path) -> None: from investibook.core.workspace_setup import _write_workspace_opencode_json workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"): _write_workspace_opencode_json(workspace_dir) plugins_dir = workspace_dir / ".opencode" / "plugins" assert (plugins_dir / "telemetry" / "index.ts").exists() assert (plugins_dir / "telemetry.ts").exists() def test_workspace_init_prefers_bundled_plugin( tmp_path: Path, mock_agents_dir: Path ) -> None: from investibook.core.workspace_setup import _write_workspace_opencode_json plugin_dir = mock_agents_dir / "plugins" / "telemetry" (plugin_dir / "dist").mkdir() (plugin_dir / "dist" / "index.js").write_text("export default {};\n") workspace_dir = tmp_path / "workspaces" / "ws-bundled-plugin" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"): _write_workspace_opencode_json(workspace_dir) plugins_dir = workspace_dir / ".opencode" / "plugins" assert (plugins_dir / "telemetry.js").read_text() == "export default {};\n" assert not (plugins_dir / "telemetry").exists() assert not (plugins_dir / "telemetry.ts").exists() def test_workspace_init_copies_investibook_diagnosis_payload( tmp_path: Path, mock_agents_dir: Path ) -> None: from investibook.core.workspace_setup import _write_workspace_opencode_json workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"): _write_workspace_opencode_json(workspace_dir) opencode_dir = workspace_dir / ".opencode" payload_dir = opencode_dir / "investibook" assert (opencode_dir / "opencode.json").exists() assert (payload_dir / "SKILL_CATALOG.md").exists() assert (payload_dir / "agents" / "investigator.md").exists() assert (payload_dir / "agents" / "evidence-agent.md").exists() assert (payload_dir / "agents" / "diagnosis-analyst.md").exists() assert (payload_dir / "schemas" / "evidence-agent-output.schema.json").exists() assert (payload_dir / "schemas" / "diagnosis-analyst-output.schema.json").exists() assert not (opencode_dir / "skills" / "SKILL_CATALOG.md").exists() assert not (opencode_dir / ".all-skills" / "SKILL_CATALOG.md").exists() def test_workspace_init_writes_pi_config_and_mirrors_enabled_skills( tmp_path: Path, mock_agents_dir: Path ) -> None: from investibook.core.workspace_setup import _write_workspace_opencode_json workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"): _write_workspace_opencode_json(workspace_dir) pi_settings = json.loads((workspace_dir / ".pi" / "settings.json").read_text()) assert pi_settings["defaultProjectTrust"] == "always" assert pi_settings["enableInstallTelemetry"] is False pi_skills = workspace_dir / ".pi" / "skills" assert (pi_skills / "clinic-cli" / "SKILL.md").exists() assert (pi_skills / "devops" / "SKILL.md").exists() assert (pi_skills / "jira" / "SKILL.md").exists() assert (pi_skills / "tidb-knowledge-base" / "SKILL.md").exists() assert not (pi_skills / "tidb-clinic-diagnosis" / "SKILL.md").exists() # --------------------------------------------------------------------------- # Shared opencode npm directory (symlink injection) # --------------------------------------------------------------------------- def _make_primed_shared_dir(shared_dir: Path) -> None: """Pre-populate a fake opencode global config dir as if `opencode` itself had already reified it on the first invocation.""" shared_dir.mkdir(parents=True, exist_ok=True) (shared_dir / "node_modules").mkdir() (shared_dir / "node_modules" / "@opencode-ai").mkdir() (shared_dir / "package-lock.json").write_text( '{"name":"opencode","lockfileVersion":3}' ) (shared_dir / "package.json").write_text( '{"dependencies":{"@opencode-ai/plugin":"1.4.6"}}' ) def test_workspace_init_symlinks_to_shared_opencode_npm( tmp_path: Path, mock_agents_dir: Path ) -> None: """When the shared opencode npm dir is primed, workspace .opencode/{node_modules, package-lock.json} should be symlinks pointing into it (skipping the ~6s reify).""" from investibook.core.workspace_setup import _write_workspace_opencode_json shared_dir = tmp_path / "opencode-global" _make_primed_shared_dir(shared_dir) workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"), \ patch("investibook.config.SHARED_OPENCODE_NPM_DIR", shared_dir): _write_workspace_opencode_json(workspace_dir) nm_link = workspace_dir / ".opencode" / "node_modules" pkg_lock_link = workspace_dir / ".opencode" / "package-lock.json" assert nm_link.is_symlink(), "node_modules should be a symlink" assert pkg_lock_link.is_symlink(), "package-lock.json should be a symlink" assert Path(os.readlink(nm_link)) == shared_dir / "node_modules" assert Path(os.readlink(pkg_lock_link)) == shared_dir / "package-lock.json" def test_workspace_init_skips_symlink_when_shared_dir_unprimed( tmp_path: Path, mock_agents_dir: Path ) -> None: """If the shared dir's node_modules doesn't exist yet, no symlinks are created — workspace falls back to opencode's per-workspace reify.""" from investibook.core.workspace_setup import _write_workspace_opencode_json shared_dir = tmp_path / "opencode-global" # NOTE: not priming — node_modules absent. workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"), \ patch("investibook.config.SHARED_OPENCODE_NPM_DIR", shared_dir): _write_workspace_opencode_json(workspace_dir) assert not (workspace_dir / ".opencode" / "node_modules").exists() assert not (workspace_dir / ".opencode" / "package-lock.json").exists() def test_workspace_init_uses_primed_xdg_data_home_fallback( tmp_path: Path, mock_agents_dir: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Reuse OpenCode's data-home installation when config-home is unprimed.""" from investibook.core.workspace_setup import _write_workspace_opencode_json configured_shared_dir = tmp_path / "unprimed-config-home" / "opencode" data_home = tmp_path / "opencode-data-home" _make_primed_shared_dir(data_home) monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) monkeypatch.delenv("INVESTIBOOK_SHARED_OPENCODE_NPM_DIR", raising=False) workspace_dir = tmp_path / "workspaces" / "ws-data-home" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"), \ patch("investibook.config.SHARED_OPENCODE_NPM_DIR", configured_shared_dir): _write_workspace_opencode_json(workspace_dir) config_dir = workspace_dir / ".opencode" assert Path(os.readlink(config_dir / "node_modules")) == data_home / "node_modules" assert Path(os.readlink(config_dir / "package-lock.json")) == data_home / "package-lock.json" def test_workspace_init_reclaims_legacy_node_modules_dir( tmp_path: Path, mock_agents_dir: Path ) -> None: """A pre-existing real node_modules/ from an older per-workspace reify should be replaced by a symlink into the shared dir.""" from investibook.core.workspace_setup import _write_workspace_opencode_json shared_dir = tmp_path / "opencode-global" _make_primed_shared_dir(shared_dir) workspace_dir = tmp_path / "workspaces" / "ws-legacy" config_dir = workspace_dir / ".opencode" config_dir.mkdir(parents=True) legacy_nm = config_dir / "node_modules" legacy_nm.mkdir() (legacy_nm / "marker.txt").write_text("legacy-content") (config_dir / "package-lock.json").write_text('{"legacy":true}') with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"), \ patch("investibook.config.SHARED_OPENCODE_NPM_DIR", shared_dir): _write_workspace_opencode_json(workspace_dir) assert (config_dir / "node_modules").is_symlink() assert (config_dir / "package-lock.json").is_symlink() assert Path(os.readlink(config_dir / "node_modules")) == shared_dir / "node_modules" def test_workspace_init_symlink_is_idempotent( tmp_path: Path, mock_agents_dir: Path ) -> None: """Calling _write_workspace_opencode_json twice keeps the symlinks correct without churn.""" from investibook.core.workspace_setup import _write_workspace_opencode_json shared_dir = tmp_path / "opencode-global" _make_primed_shared_dir(shared_dir) workspace_dir = tmp_path / "workspaces" / "ws-test" workspace_dir.mkdir(parents=True) with patch("investibook.config.AGENT_CONFIG_DIR", mock_agents_dir / "config"), \ patch("investibook.config.AGENTKIT_PROMPTS_DIR", mock_agents_dir / "prompts"), \ patch("investibook.config.AGENTKIT_SKILLS_DIR", mock_agents_dir / "skills"), \ patch("investibook.config.AGENTKIT_PLUGINS_DIR", mock_agents_dir / "plugins"), \ patch("investibook.config.SHARED_OPENCODE_NPM_DIR", shared_dir): _write_workspace_opencode_json(workspace_dir) _write_workspace_opencode_json(workspace_dir) nm_link = workspace_dir / ".opencode" / "node_modules" assert nm_link.is_symlink() assert Path(os.readlink(nm_link)) == shared_dir / "node_modules"