This commit is contained in:
@@ -100,7 +100,7 @@ async def test_sdk_template_creation_frozen_evidence_and_web_expansion(app, logg
|
||||
assert "submit_backtests" not in listed
|
||||
assert not tool.annotations.read_only_hint and not tool.annotations.destructive_hint
|
||||
assert tool.annotations.idempotent_hint and not tool.annotations.open_world_hint
|
||||
assert {"template", "hypothesis", "source_item_ids", "idempotency_key"} <= set(tool.input_schema["required"])
|
||||
assert {"template", "hypothesis", "idempotency_key"} <= set(tool.input_schema["required"])
|
||||
assert tool.input_schema["additionalProperties"] is False
|
||||
caps = await client.call_tool("get_research_capabilities", {})
|
||||
assert caps.structured_content["templates"]["create_with"] == TOOL
|
||||
@@ -169,7 +169,7 @@ async def test_template_invalid_inputs_and_missing_sources_are_atomic(app, compl
|
||||
principal, _ = await credentials(app)
|
||||
valid = template_request(completed_source["id"])
|
||||
variants = [
|
||||
{"source_item_ids": []}, {"source_item_ids": [completed_source["id"]] * 21},
|
||||
{"source_item_ids": [completed_source["id"]] * 21},
|
||||
{"source_item_ids": [completed_source["id"]] * 2}, {"hypothesis": " "},
|
||||
{"force": True}, {"idempotency_key": ""},
|
||||
{"template": valid["template"] | {"expression": "rank({missing})"}},
|
||||
@@ -265,3 +265,134 @@ async def test_mcp_template_version_is_idempotent_and_preserves_history(app, com
|
||||
with pytest.raises(HTTPException) as forbidden:
|
||||
await app.state.mcp.invoke(denied, tool, update)
|
||||
assert forbidden.value.status_code == 403
|
||||
|
||||
|
||||
async def test_template_without_result_sources_can_be_created_and_versioned(app):
|
||||
principal, _ = await credentials(app, {"research:read", "research:write"})
|
||||
body = template_request("unused")
|
||||
body.pop("source_item_ids")
|
||||
created = await invoke(app, principal, TOOL, body)
|
||||
assert created["provenance"]["source_items"] == []
|
||||
assert created["validation"]["source_evidence"] == "not_provided"
|
||||
revised = await invoke(app, principal, "create_research_template_version", {
|
||||
**body, "template_id": created["id"], "expected_version": 1, "idempotency_key": "no-source-v2",
|
||||
"source_item_ids": [],
|
||||
})
|
||||
assert revised["version"] == 2 and revised["provenance"]["source_items"] == []
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
|
||||
|
||||
async def template_collection(app, principal, research_input, count=130):
|
||||
body = template_request("unused")
|
||||
body.pop("source_item_ids")
|
||||
body["template"]["variables"]["field"]["values"] = ["TEST_FIN_001"]
|
||||
body["template"]["variables"]["offset"]["values"] = list(range(count))
|
||||
saved = await invoke(app, principal, TOOL, body)
|
||||
args = {
|
||||
"template_id": saved["id"], "version": saved["version"],
|
||||
"preparation_refs": [{"id": research_input["preparation_id"], "version": research_input["preparation_version"]}],
|
||||
"settings": candidate()["settings"], "limit": count, "idempotency_key": "expand-collection",
|
||||
}
|
||||
return saved, args
|
||||
|
||||
|
||||
async def test_template_collection_mcp_paging_execution_and_replay(app, logged_in, research_input):
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.models import BacktestPreview, CatalogResource, ResearchExperiment
|
||||
|
||||
principal, _ = await credentials(app)
|
||||
saved, args = await template_collection(app, principal, research_input)
|
||||
async with app.state.sessions.begin() as db:
|
||||
await db.execute(delete(CatalogResource))
|
||||
app.state.runner.backtests.wake.clear()
|
||||
first, retry = await asyncio.gather(*[invoke(app, principal, "expand_research_template", args) for _ in range(2)])
|
||||
assert first == retry and first["total"] == 130 and len(first["items"]) == 25
|
||||
assert first["has_more"] and not first["starts_backtests"]
|
||||
assert first["template"]["id"] == saved["id"] and first["template"]["version"] == 1
|
||||
assert not app.state.runner.backtests.wake.is_set()
|
||||
collected = []
|
||||
for offset in (0, 100):
|
||||
result = await invoke(app, principal, "get_template_candidates", {
|
||||
"experiment_id": first["experiment_id"], "limit": 100, "offset": offset,
|
||||
})
|
||||
collected += result["items"]
|
||||
assert len(collected) == 130 and not result["has_more"]
|
||||
assert all("validation" not in item for item in collected)
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
args = {"experiment_id": first["experiment_id"], "candidate_ids": [c["client_item_id"] for c in collected],
|
||||
"idempotency_key": "execute-collection"}
|
||||
run, replay = await asyncio.gather(*[invoke(app, principal, "start_template_backtest", args) for _ in range(2)])
|
||||
assert run == replay and run["total"] == 130
|
||||
assert run["source"]["kind"] == "template" and run["source"]["research_id"] == first["experiment_id"]
|
||||
assert run["source"]["input_snapshot_ids"]
|
||||
assert app.state.runner.backtests.wake.is_set()
|
||||
rotated, _ = await credentials(app)
|
||||
assert await invoke(app, rotated, "start_template_backtest", args) == run
|
||||
conflict = await app.state.mcp.invoke(principal, "start_template_backtest", args | {"candidate_ids": ["c1"]})
|
||||
assert conflict.is_error and conflict.structured_content["error"]["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
row = await db.get(BacktestRun, run["backtest_run_id"])
|
||||
assert row.ai_context["mcp_token_id"] == principal.token_id
|
||||
audit = await db.scalar(select(MCPAudit).where(MCPAudit.tool == "expand_research_template"))
|
||||
assert audit.business_id == first["experiment_id"]
|
||||
record = (await logged_in.get(f"/api/v1/research/experiments/{first['experiment_id']}")).json()
|
||||
assert record["backtest_run_ids"] == [run["backtest_run_id"]]
|
||||
|
||||
|
||||
async def test_template_mcp_failures_are_atomic_and_do_not_consume_keys(app, research_input):
|
||||
from app.models import BacktestPreview, ResearchExperiment
|
||||
|
||||
principal, _ = await credentials(app)
|
||||
saved, args = await template_collection(app, principal, research_input, 2)
|
||||
for changed in [args | {"settings": args["settings"] | {"region": "EUR"}},
|
||||
args | {"preparation_refs": [{**args["preparation_refs"][0], "version": 999}]}]:
|
||||
result = await app.state.mcp.invoke(principal, "expand_research_template", changed)
|
||||
assert result.is_error
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
assert not await db.scalar(select(ResearchRequest).where(ResearchRequest.operation == "expand_research_template"))
|
||||
collection = await invoke(app, principal, "expand_research_template", args)
|
||||
conflict = await app.state.mcp.invoke(principal, "expand_research_template", args | {"seed": 1})
|
||||
assert conflict.structured_content["error"]["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
request = {"experiment_id": collection["experiment_id"], "candidate_ids": ["c1"], "idempotency_key": "execute"}
|
||||
for ids in [[], ["unknown"], ["c1", "c1"]]:
|
||||
result = await app.state.mcp.invoke(principal, "start_template_backtest", request | {"candidate_ids": ids})
|
||||
assert result.is_error
|
||||
result = await app.state.mcp.invoke(principal, "start_template_backtest", request | {"expression": "rank(other)"})
|
||||
assert result.is_error and result.structured_content["error"]["code"] == "INVALID_INPUT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 0
|
||||
assert not await db.scalar(select(ResearchRequest).where(ResearchRequest.operation == "start_template_backtest"))
|
||||
await invoke(app, principal, "start_template_backtest", request)
|
||||
|
||||
|
||||
async def test_template_tool_discovery_and_execute_permissions(app, research_input):
|
||||
from fastapi import HTTPException
|
||||
|
||||
principal, secret = await credentials(app, {"research:read", "research:write"})
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver",
|
||||
headers={"Authorization": f"Bearer {secret}", "Accept": "application/json, text/event-stream"}) as http:
|
||||
listed = (await http.post(ENDPOINT, json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})).json()
|
||||
tools = {t["name"]: t for t in listed["result"]["tools"]}
|
||||
assert "expand_research_template" in tools and "get_template_candidates" in tools
|
||||
assert "start_template_backtest" not in tools
|
||||
assert "source_item_ids" not in tools[TOOL]["inputSchema"]["required"]
|
||||
assert tools["expand_research_template"]["annotations"]["idempotentHint"]
|
||||
_, args = await template_collection(app, principal, research_input, 2)
|
||||
collection = await invoke(app, principal, "expand_research_template", args)
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
await app.state.mcp.invoke(principal, "start_template_backtest", {
|
||||
"experiment_id": collection["experiment_id"], "candidate_ids": ["c1"], "idempotency_key": "denied",
|
||||
})
|
||||
assert denied.value.status_code == 403
|
||||
reader, _ = await credentials(app, {"research:read"})
|
||||
await invoke(app, reader, "get_template_candidates", {"experiment_id": collection["experiment_id"]})
|
||||
with pytest.raises(HTTPException):
|
||||
await app.state.mcp.invoke(reader, "expand_research_template", args)
|
||||
|
||||
Reference in New Issue
Block a user