""" tests/test_workspace_provision.py — 创建智能体实例时自动开辟工作目录。 布局参照 Workspace/multi-reviewer67: /<名>/ work_dir(最终交付物 + Bash CWD) /<名>/data source_dir /<名>/workspace run_dir(bare 模式,run_* 直接建在其下) """ from __future__ import annotations import json import os import pytest os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AGENTPAAS_TESTING", "1") # ── 单元:provision 函数 ───────────────────────────────────────────────────── def test_provision_layout(tmp_path): from agentpaas.engine.workspace_provision import provision_agent_workspace d = provision_agent_workspace("审稿助手", base=str(tmp_path)) root = os.path.join(str(tmp_path), "审稿助手") assert d["work_dir"] == os.path.abspath(root) assert d["source_dir"] == os.path.join(os.path.abspath(root), "data") assert d["run_dir"] == os.path.join(os.path.abspath(root), "workspace") assert os.path.isdir(d["source_dir"]) and os.path.isdir(d["run_dir"]) def test_provision_duplicate_names_get_suffix(tmp_path): from agentpaas.engine.workspace_provision import provision_agent_workspace d1 = provision_agent_workspace("助手", base=str(tmp_path)) d2 = provision_agent_workspace("助手", base=str(tmp_path)) d3 = provision_agent_workspace("助手", base=str(tmp_path)) assert d1["work_dir"].endswith("助手") assert d2["work_dir"].endswith("助手-2") assert d3["work_dir"].endswith("助手-3") def test_provision_slug_sanitises_path_chars(tmp_path): from agentpaas.engine.workspace_provision import provision_agent_workspace d = provision_agent_workspace("../evil/name v2", base=str(tmp_path)) # 不逃逸 base;路径分隔符被清洗 assert os.path.commonpath([d["work_dir"], str(tmp_path)]) == str( os.path.abspath(str(tmp_path))) assert "/evil/" not in d["work_dir"].replace(str(tmp_path), "") def test_provision_failure_returns_empty(monkeypatch): from agentpaas.engine import workspace_provision as wp monkeypatch.setattr(wp.os, "makedirs", lambda *a, **k: (_ for _ in ()).throw(OSError("ro"))) d = wp.provision_agent_workspace("x", base="/nonexistent-base") assert d == {"work_dir": "", "source_dir": "", "run_dir": ""} # ── 集成:两个创建端点 ─────────────────────────────────────────────────────── @pytest.fixture() def api_client(tmp_path, monkeypatch): """TestClient + 内存 DB + 租户/key + workspace_base 指到 tmp。""" import secrets from fastapi.testclient import TestClient from agentpaas.api.app import app from agentpaas.api.middleware.auth import hash_key from agentpaas.config import settings from agentpaas.db.models import Database, gen_id, now_utc import agentpaas.db.session as _session_mod ws_base = tmp_path / "Workspace" monkeypatch.setattr(settings, "workspace_base", str(ws_base)) prev = _session_mod._db _session_mod._db = Database("sqlite:///:memory:") db = _session_mod._db tid, uid = gen_id("tn_"), gen_id("usr_") raw_key = f"ap_{secrets.token_hex(16)}" now = now_utc() db.execute("INSERT INTO tenants (id, name, plan, status, created_at) " "VALUES (?, 'test', 'free', 'active', ?)", (tid, now)) db.execute("INSERT INTO users (id, tenant_id, email, role, created_at) " "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now)) db.execute( "INSERT INTO api_keys (id, tenant_id, user_id, key_hash, key_prefix, name, " "scopes, rate_limit, status, created_at) " "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)", (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8], json.dumps(["agents:*"]), now)) db.commit() with TestClient(app) as client: yield client, raw_key, str(ws_base), db _session_mod._db = prev def _auth(k): return {"Authorization": f"Bearer {k}"} _MIN_CONFIG = {"name": "目录测试", "type": "simple", "model": {"name": "ollama/qwen2.5:7b"}, "systemPrompt": "t"} def test_create_agent_auto_provisions_dirs(api_client): client, key, ws_base, db = api_client r = client.post("/api/v1/agents", headers=_auth(key), json={"name": "目录测试", "config": _MIN_CONFIG}) assert r.status_code in (200, 201), r.text[:300] body = r.json() assert body["work_dir"].endswith("目录测试") assert body["run_dir"] == os.path.join(body["work_dir"], "workspace") assert os.path.isdir(body["source_dir"]) # DB 行一致 row = db.fetchone("SELECT work_dir, source_dir, run_dir FROM agents WHERE id=?", (body["agent_id"],)) assert row["work_dir"] == body["work_dir"] assert row["run_dir"] == body["run_dir"] def test_create_agent_respects_explicit_dirs(api_client, tmp_path): client, key, ws_base, db = api_client my_dir = str(tmp_path / "my-own-dir") os.makedirs(my_dir) r = client.post("/api/v1/agents", headers=_auth(key), json={"name": "显式目录", "config": _MIN_CONFIG, "work_dir": my_dir}) assert r.status_code in (200, 201), r.text[:300] body = r.json() assert body["work_dir"] == my_dir assert body["run_dir"] == "" # 显式给了任一目录 → 其余不自动补 # 没有为它建 Workspace/<名> 目录 assert not os.path.exists(os.path.join(ws_base, "显式目录")) # ── 模型显示与切换(模型页功能)───────────────────────────────────────────── def test_models_in_use_and_switch(api_client): client, key, ws_base, db = api_client # 建两个不同模型的智能体 for name, model in [("甲", {"provider": "ollama", "name": "qwen2.5:7b"}), ("乙", {"provider": "dashscope", "name": "qwen-max"})]: cfg = {"name": name, "type": "simple", "model": model, "systemPrompt": "t"} r = client.post("/api/v1/agents", headers=_auth(key), json={"name": name, "config": cfg}) assert r.status_code in (200, 201), r.text[:200] # 一览 r = client.get("/api/v1/providers/models-in-use", headers=_auth(key)) assert r.status_code == 200, r.text[:200] items = {i["agent_name"]: i for i in r.json()["items"]} assert items["甲"]["provider"] == "ollama" and items["甲"]["model"] == "qwen2.5:7b" assert items["乙"]["provider"] == "dashscope" and items["乙"]["model"] == "qwen-max" # 切换甲 → dashscope/qwen-plus aid = items["甲"]["agent_id"] r = client.put(f"/api/v1/agents/{aid}/model", headers=_auth(key), json={"provider": "dashscope", "name": "qwen-plus"}) assert r.status_code == 200, r.text[:300] body = r.json() assert body["version"] == 2 and body["previous"] == "ollama/qwen2.5:7b" # 配置生效且其余字段保留;版本历史可回滚 g = client.get(f"/api/v1/agents/{aid}", headers=_auth(key)).json() assert g["config"]["model"]["provider"] == "dashscope" assert g["config"]["model"]["name"] == "qwen-plus" assert g["config"]["systemPrompt"] == "t" assert g["current_version"] == 2 rb = client.post(f"/api/v1/agents/{aid}/rollback", headers=_auth(key), json={"target_version": 1}) assert rb.status_code == 200 g2 = client.get(f"/api/v1/agents/{aid}", headers=_auth(key)).json() assert g2["config"]["model"]["name"] == "qwen2.5:7b" def test_switch_model_unknown_agent_404(api_client): client, key, *_ = api_client r = client.put("/api/v1/agents/ag_nonexistent/model", headers=_auth(key), json={"provider": "ollama", "name": "x"}) assert r.status_code == 404 # ── simple agent token 透传(P3)──────────────────────────────────────────── def test_simple_agent_usage_source_found(): """_find_usage_source 沿包装链找到 get_usage:裸 ConversationLam、 Guard 包装、Memory(Guard(...)) 双层包装都要命中。""" from lambdagent.conversation import ConversationLam from lambdagent.extensions import Guard, Memory class FakeProvider: default_model = "qwen-max" model_name = "qwen-max" context_window = 100000 def chat(self, messages, **kw): return "ok" def get_usage(self): return {"input_tokens": 123, "output_tokens": 45, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0} lam = ConversationLam(name="t", provider=FakeProvider(), system_prompt="s") # 复用 _execute_agent 内的同款查找逻辑(提取为本测试的等价实现以 # 锁定语义:_think_ref 优先 → 自身 get_usage → .agent 递归) def find(t, depth=0): if t is None or depth > 5: return None ref = getattr(t, "_think_ref", None) if ref is not None and callable(getattr(ref, "get_usage", None)): return ref if callable(getattr(t, "get_usage", None)): return t return find(getattr(t, "agent", None), depth + 1) assert find(lam) is lam guarded = Guard(lam, validator=lambda x: True) assert find(guarded) is lam wrapped = Memory(Guard(lam, validator=lambda x: True)) assert find(wrapped) is lam assert find(wrapped).get_usage()["input_tokens"] == 123 def test_anthropic_provider_accumulates_usage(): """AnthropicProvider 现在有 get_usage/reset_session(与 openai_compat 同形)。""" from lambdagent.providers.anthropic_provider import AnthropicProvider from lambdagent.providers.base import ProviderConfig p = AnthropicProvider(ProviderConfig(model="claude-sonnet-4-6")) u = p.get_usage() assert u == {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0} p._usage_input += 10; p._usage_output += 5 assert p.get_usage()["input_tokens"] == 10 p.reset_session() assert p.get_usage()["output_tokens"] == 0