Files
Zara1024andCursor 82263eb0e7 feat(cron): 定时任务空状态提示词按专家区分 (#651) (#747)
* feat(cron): resolve empty-state prompts per expert (#651)

Prefer workspace/catalog/name-based task_examples so cron suggestions differ by agent.

* style: ruff-format cron/chat routers for CI lint

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 21:25:55 +08:00

272 lines
8.8 KiB
Python

"""tests/integration/test_cron_api.py — cron CRUD + run-now + trigger validation.
Plan §12.6 mandates this file. Covers list/create/get/patch/delete cycle,
trigger validation surfacing CRON_TRIGGER_INVALID at create + patch, and
run-now invoking the cron manager and updating last_status.
"""
from __future__ import annotations
import json
from typing import Any
import pytest
@pytest.fixture
async def env(env_alice_bob_agent):
yield env_alice_bob_agent
# --- CRUD cycle ---------------------------------------------------------------
async def test_create_lists_get_patch_delete_cycle(env: Any) -> None:
c, _srv, alice_auth, _bob_auth, aid = env
# CREATE with valid interval trigger
r = await c.post(
f"/api/agents/{aid}/cron",
headers=alice_auth,
json={
"trigger": "interval:60",
"prompt": "ping the server",
},
)
assert r.status_code == 201, r.text
body = r.json()
cid = body["id"]
assert body["trigger"] == "interval:60"
assert body["prompt"] == "ping the server"
assert body["fresh_thread"] is False
assert body["enabled"] is True
assert body["last_status"] is None
# LIST contains the row
r = await c.get(f"/api/agents/{aid}/cron", headers=alice_auth)
assert r.status_code == 200
rows = r.json()
assert any(row["id"] == cid for row in rows)
# GET single
r = await c.get(f"/api/agents/{aid}/cron/{cid}", headers=alice_auth)
assert r.status_code == 200
assert r.json()["id"] == cid
# PATCH prompt + enabled
r = await c.patch(
f"/api/agents/{aid}/cron/{cid}",
headers=alice_auth,
json={"prompt": "ping-v2", "enabled": False},
)
assert r.status_code == 200
body = r.json()
assert body["prompt"] == "ping-v2"
assert body["enabled"] is False
# DELETE → 204
r = await c.delete(f"/api/agents/{aid}/cron/{cid}", headers=alice_auth)
assert r.status_code == 204
# GET after delete → 404
r = await c.get(f"/api/agents/{aid}/cron/{cid}", headers=alice_auth)
assert r.status_code == 404
# --- Trigger validation -------------------------------------------------------
async def test_create_with_invalid_trigger_returns_400(env: Any) -> None:
"""Plan §12.6: ``build_trigger`` is called server-side at create;
invalid spec must surface ``CRON_TRIGGER_INVALID`` (HTTP 400)."""
c, _srv, alice_auth, _bob_auth, aid = env
r = await c.post(
f"/api/agents/{aid}/cron",
headers=alice_auth,
json={
"trigger": "not-a-valid-trigger", # missing kind:value
"prompt": "noop",
},
)
assert r.status_code == 400
assert r.json()["error"]["code"] == "CRON_TRIGGER_INVALID"
async def test_create_with_invalid_cron_expr_returns_400(env: Any) -> None:
c, _srv, alice_auth, _bob_auth, aid = env
r = await c.post(
f"/api/agents/{aid}/cron",
headers=alice_auth,
json={
"trigger": "cron:not a real cron",
"prompt": "noop",
},
)
assert r.status_code == 400
assert r.json()["error"]["code"] == "CRON_TRIGGER_INVALID"
async def test_patch_invalid_trigger_returns_400(env: Any) -> None:
"""Validation also runs on PATCH when trigger is updated."""
c, _srv, alice_auth, _bob_auth, aid = env
r = await c.post(
f"/api/agents/{aid}/cron",
headers=alice_auth,
json={"trigger": "interval:30", "prompt": "x"},
)
cid = r.json()["id"]
r = await c.patch(
f"/api/agents/{aid}/cron/{cid}",
headers=alice_auth,
json={"trigger": "garbage"},
)
assert r.status_code == 400
assert r.json()["error"]["code"] == "CRON_TRIGGER_INVALID"
async def test_create_with_valid_cron_expr(env: Any) -> None:
"""`cron:* * * * *` should pass validation."""
c, _srv, alice_auth, _bob_auth, aid = env
r = await c.post(
f"/api/agents/{aid}/cron",
headers=alice_auth,
json={
"trigger": "cron:* * * * *",
"prompt": "tick",
},
)
assert r.status_code == 201, r.text
# --- Run-now ------------------------------------------------------------------
async def test_run_now_records_last_status(env: Any) -> None:
"""Run-now should invoke the cron manager and the job's repo update
leaves ``last_status`` set."""
c, _srv, alice_auth, _bob_auth, aid = env
r = await c.post(
f"/api/agents/{aid}/cron",
headers=alice_auth,
json={
"trigger": "interval:3600",
"prompt": "do the thing",
},
)
cid = r.json()["id"]
r = await c.post(f"/api/agents/{aid}/cron/{cid}/run-now", headers=alice_auth)
assert r.status_code == 204
r = await c.get(f"/api/agents/{aid}/cron/{cid}", headers=alice_auth)
assert r.status_code == 200
body = r.json()
# CronJob.run() writes a status string ("ok" / "error" / etc.) — assert
# the row was touched, regardless of which terminal status it lands on.
assert body["last_status"] is not None
assert body["last_run_at"] is not None
async def test_cron_settings_returns_timezone(env: Any) -> None:
c, _srv, alice_auth, _bob_auth, _aid = env
r = await c.get("/api/cron/settings", headers=alice_auth)
assert r.status_code == 200
assert r.json() == {"timezone": "Asia/Shanghai"}
async def test_cron_examples_missing_field_uses_name_defaults(env: Any) -> None:
c, _srv, alice_auth, _bob_auth, aid = env
r = await c.get(f"/api/agents/{aid}/cron/examples", headers=alice_auth)
assert r.status_code == 200, r.text
examples = r.json()["task_examples"]
assert examples is not None
assert len(examples["zh"]) == 6
assert "bot" in examples["zh"][0]
assert len(examples["en"]) == 6
assert "bot" in examples["en"][0]
async def test_cron_examples_reads_workspace_manifest(env: Any) -> None:
c, srv, alice_auth, bob_auth, aid = env
workspace = srv.app_runtime.agent_registry.workspace_for_agent(aid)
assert workspace is not None
await workspace.awrite_text(
".octop/manifest.json",
json.dumps(
{
"id": "demo",
"task_examples": {
"zh": ["每天 09:00 巡检"],
"en": ["Patrol daily at 09:00"],
},
}
),
force=True,
)
r = await c.get(f"/api/agents/{aid}/cron/examples", headers=alice_auth)
assert r.status_code == 200, r.text
assert r.json()["task_examples"] == {
"zh": ["每天 09:00 巡检"],
"en": ["Patrol daily at 09:00"],
}
denied = await c.get(f"/api/agents/{aid}/cron/examples", headers=bob_auth)
assert denied.status_code == 403
async def test_cron_examples_display_normalizes_four_or_five_to_three(env: Any) -> None:
c, srv, alice_auth, _bob_auth, aid = env
workspace = srv.app_runtime.agent_registry.workspace_for_agent(aid)
assert workspace is not None
await workspace.awrite_text(
".octop/manifest.json",
json.dumps(
{
"id": "demo",
"task_examples": {
"zh": ["一", "二", "三", "四", "五"],
"en": ["a", "b", "c", "d", "e"],
},
}
),
force=True,
)
r = await c.get(f"/api/agents/{aid}/cron/examples", headers=alice_auth)
assert r.status_code == 200, r.text
assert r.json()["task_examples"] == {"zh": ["一", "二", "三"], "en": ["a", "b", "c"]}
welcome = await c.get(f"/api/agents/{aid}/chat/welcome", headers=alice_auth)
assert welcome.status_code == 200, welcome.text
assert welcome.json()["task_examples"] == {"zh": ["一", "二", "三"], "en": ["a", "b", "c"]}
async def test_settings_timezone_returns_default(env: Any) -> None:
c, _srv, alice_auth, _bob_auth, _aid = env
r = await c.get("/api/settings/timezone", headers=alice_auth)
assert r.status_code == 200
assert r.json() == {"timezone": "Asia/Shanghai"}
async def test_settings_upload_returns_default_limit(env: Any) -> None:
c, _srv, alice_auth, _bob_auth, _aid = env
r = await c.get("/api/settings/upload", headers=alice_auth)
assert r.status_code == 200
assert r.json() == {"max_upload_mb": 100, "max_upload_bytes": 100 * 1024 * 1024}
async def test_run_now_unknown_cron_returns_404(env: Any) -> None:
c, _srv, alice_auth, _bob_auth, aid = env
r = await c.post(
f"/api/agents/{aid}/cron/01HMISSING0000000000000000/run-now",
headers=alice_auth,
)
assert r.status_code == 404
# --- Cross-user isolation -----------------------------------------------------
async def test_cross_user_cannot_list_cron(env: Any) -> None:
c, _srv, _alice_auth, bob_auth, aid = env
r = await c.get(f"/api/agents/{aid}/cron", headers=bob_auth)
assert r.status_code == 403