diff --git a/.scratch/v0.5/issues/04-run-records-scenario-version.md b/.scratch/v0.5/issues/04-run-records-scenario-version.md
index 777be0e..893c2aa 100644
--- a/.scratch/v0.5/issues/04-run-records-scenario-version.md
+++ b/.scratch/v0.5/issues/04-run-records-scenario-version.md
@@ -4,10 +4,10 @@
**Blocked by:** 03 — 场景版本字段与升版逻辑。
-**Status:** ready-for-agent
+**Status:** done
-- [ ] 新建运行记录 scenario_version = 场景当前版本,三种触发来源一致
-- [ ] 迁移回填存量运行;孤儿运行(场景已删)回填 1
-- [ ] 运行列表 API 与报告数据含 scenario_version
-- [ ] 前端运行列表与报告头展示版本号
-- [ ] 集成测试覆盖创建快照与迁移回填(先例:现有运行 API 测试、迁移测试)
+- [x] 新建运行记录 scenario_version = 场景当前版本,三种触发来源一致
+- [x] 迁移回填存量运行;孤儿运行(场景已删)回填 1
+- [x] 运行列表 API 与报告数据含 scenario_version
+- [x] 前端运行列表与报告头展示版本号
+- [x] 集成测试覆盖创建快照与迁移回填(先例:现有运行 API 测试、迁移测试)
diff --git a/backend/agenteval/evaluation/engine.py b/backend/agenteval/evaluation/engine.py
index 87de4d9..fd17558 100644
--- a/backend/agenteval/evaluation/engine.py
+++ b/backend/agenteval/evaluation/engine.py
@@ -118,6 +118,7 @@ class EvalEngine:
id=str(uuid.uuid4()),
target_id=self.target.id or "",
scenario_id=self.scenario.id or "",
+ scenario_version=self.scenario.version or 1,
status=RunStatus.RUNNING,
triggered_by=self.triggered_by,
started_at=utc_now(),
diff --git a/backend/agenteval/evaluation/report.py b/backend/agenteval/evaluation/report.py
index 2d58e6e..398cfed 100644
--- a/backend/agenteval/evaluation/report.py
+++ b/backend/agenteval/evaluation/report.py
@@ -172,6 +172,7 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
"target_name": target.name if target else "未知",
"scenario_id": run.scenario_id,
"scenario_name": scenario.name if scenario else "未知",
+ "scenario_version": run.scenario_version,
"status": run.status.value,
"started_at": iso_utc(run.started_at),
"completed_at": iso_utc(run.completed_at),
@@ -241,6 +242,7 @@ def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[
"run_id": run_id_1,
"target_name": report_a.get("target_name"),
"scenario_name": report_a.get("scenario_name"),
+ "scenario_version": report_a.get("scenario_version"),
"status": report_a.get("status"),
"started_at": report_a.get("started_at"),
"summary": report_a["summary"],
@@ -249,6 +251,7 @@ def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[
"run_id": run_id_2,
"target_name": report_b.get("target_name"),
"scenario_name": report_b.get("scenario_name"),
+ "scenario_version": report_b.get("scenario_version"),
"status": report_b.get("status"),
"started_at": report_b.get("started_at"),
"summary": report_b["summary"],
diff --git a/backend/agenteval/models.py b/backend/agenteval/models.py
index 4783bbe..5eac6ca 100644
--- a/backend/agenteval/models.py
+++ b/backend/agenteval/models.py
@@ -162,6 +162,8 @@ class EvalRun(BaseModel):
id: Optional[str] = None
target_id: str
scenario_id: str
+ # 创建时快照的场景考纲版本(ADR-0001)
+ scenario_version: int = 1
status: RunStatus = RunStatus.PENDING
triggered_by: RunTrigger = RunTrigger.MANUAL
started_at: Optional[datetime] = None
diff --git a/backend/agenteval/storage/db.py b/backend/agenteval/storage/db.py
index 6b83890..0c615df 100644
--- a/backend/agenteval/storage/db.py
+++ b/backend/agenteval/storage/db.py
@@ -168,6 +168,7 @@ class EvalRunDB(SQLModel, table=True):
id: Optional[str] = Field(default_factory=new_uuid, primary_key=True)
target_id: Optional[str] = Field(default=None, foreign_key="eval_targets.id")
scenario_id: Optional[str] = Field(default=None, foreign_key="scenarios.id")
+ scenario_version: int = Field(default=1)
status: str = "pending"
triggered_by: str = Field(default="manual")
started_at: Optional[datetime] = Field(default_factory=utc_now)
diff --git a/backend/agenteval/storage/repository.py b/backend/agenteval/storage/repository.py
index da4ae1e..442714c 100644
--- a/backend/agenteval/storage/repository.py
+++ b/backend/agenteval/storage/repository.py
@@ -83,6 +83,7 @@ def _run_to_db(run: EvalRun) -> EvalRunDB:
id=run.id,
target_id=run.target_id,
scenario_id=run.scenario_id,
+ scenario_version=run.scenario_version,
status=run.status.value,
triggered_by=run.triggered_by.value,
started_at=run.started_at,
@@ -98,6 +99,7 @@ def _run_from_db(db: EvalRunDB) -> EvalRun:
id=db.id,
target_id=db.target_id,
scenario_id=db.scenario_id,
+ scenario_version=db.scenario_version or 1,
status=db.status,
triggered_by=db.triggered_by or "manual",
started_at=db.started_at,
diff --git a/backend/agenteval/web/routers/runs.py b/backend/agenteval/web/routers/runs.py
index 89d8ee4..6a17b29 100644
--- a/backend/agenteval/web/routers/runs.py
+++ b/backend/agenteval/web/routers/runs.py
@@ -95,6 +95,7 @@ async def start_run(
run = EvalRun(
target_id=request.target_id,
scenario_id=request.scenario_id,
+ scenario_version=scenario.version or 1,
triggered_by=request.triggered_by,
)
run = RunRepository(session).create(run)
diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts
index 9f056a3..53f3d1a 100644
--- a/frontend/web/src/api.ts
+++ b/frontend/web/src/api.ts
@@ -141,6 +141,7 @@ export interface Run {
id: string
target_id: string
scenario_id: string
+ scenario_version?: number
status: string
triggered_by?: RunTrigger
scenario_name?: string | null
diff --git a/frontend/web/src/components/RunList.tsx b/frontend/web/src/components/RunList.tsx
index 83577fd..df09596 100644
--- a/frontend/web/src/components/RunList.tsx
+++ b/frontend/web/src/components/RunList.tsx
@@ -292,6 +292,13 @@ function RunRow({ r, selected, targetName, scenarioName, onSelect, onOpenReport,
{shortDateTime(r.started_at)}
·
{elapsedStr(r.started_at, r.completed_at)}
+ {r.scenario_version != null && (
+
+
+ v{r.scenario_version}
+
+
+ )}
{r.triggered_by && r.triggered_by !== 'manual' && (
{report.target_name}
- {report.scenario_name}
+
+
+ {report.scenario_name}
+ {report.scenario_version != null && v{report.scenario_version}}
+
+
{formatDateTime(report.started_at)}
{report.completed_at ? formatDateTime(report.completed_at) : '-'}
@@ -474,7 +480,10 @@ function CompareView({ result }: { result: CompareResult | null }) {
报告 A — {run_a.run_id.slice(0, 8)}…} size="small">
- {run_a.scenario_name}
+
+ {run_a.scenario_name}
+ {run_a.scenario_version != null && v{run_a.scenario_version}}
+
{formatDateTime(run_a.started_at)}
{(run_a.summary.pass_rate * 100).toFixed(1)}%
{run_a.summary.passed_cases}/{run_a.summary.total_cases}
@@ -492,7 +501,10 @@ function CompareView({ result }: { result: CompareResult | null }) {
报告 B — {run_b.run_id.slice(0, 8)}…} size="small">
- {run_b.scenario_name}
+
+ {run_b.scenario_name}
+ {run_b.scenario_version != null && v{run_b.scenario_version}}
+
{formatDateTime(run_b.started_at)}
{(run_b.summary.pass_rate * 100).toFixed(1)}%
{run_b.summary.passed_cases}/{run_b.summary.total_cases}
diff --git a/migrations/versions/d5b8c2e4f617_add_scenario_version_to_eval_runs.py b/migrations/versions/d5b8c2e4f617_add_scenario_version_to_eval_runs.py
new file mode 100644
index 0000000..aedb58d
--- /dev/null
+++ b/migrations/versions/d5b8c2e4f617_add_scenario_version_to_eval_runs.py
@@ -0,0 +1,34 @@
+"""add scenario_version to eval_runs
+
+Revision ID: d5b8c2e4f617
+Revises: c8e2f5a7b901
+Create Date: 2026-07-29
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+import sqlmodel # noqa: F401
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "d5b8c2e4f617"
+down_revision: Union[str, Sequence[str], None] = "c8e2f5a7b901"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("eval_runs") as batch_op:
+ batch_op.add_column(
+ sa.Column("scenario_version", sa.Integer(), nullable=False, server_default="1")
+ )
+ # 存量运行回填其场景当前版本;孤儿运行(场景已删)保持默认 1
+ op.execute(
+ "UPDATE eval_runs SET scenario_version = COALESCE("
+ "(SELECT version FROM scenarios WHERE scenarios.id = eval_runs.scenario_id), 1)"
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("eval_runs") as batch_op:
+ batch_op.drop_column("scenario_version")
diff --git a/tests/integration/test_runs_api.py b/tests/integration/test_runs_api.py
index 3954af7..ae8f540 100644
--- a/tests/integration/test_runs_api.py
+++ b/tests/integration/test_runs_api.py
@@ -238,3 +238,30 @@ async def test_list_runs_includes_names_and_trigger(client, seeded_db, mock_chan
assert row["scenario_name"] == "mock-scenario"
assert row["target_name"] == "mock-target"
assert row["triggered_by"] == "ai_assistant"
+
+
+# ── scenario_version snapshot (ticket 04) ────────────────────────────────
+
+async def test_start_run_snapshots_scenario_version(client, seeded_db, mock_channel):
+ resp = await client.post("/api/runs", json={
+ "target_id": "t-1", "scenario_id": "s-1",
+ })
+ assert resp.status_code == 200
+ assert resp.json()["scenario_version"] == 1
+
+ # 编辑考纲 → 场景升版 → 新运行快照新版本;旧运行保持 1
+ old_run_id = resp.json()["id"]
+ scenario = ScenarioRepository(seeded_db).get("s-1")
+ scenario.cases.append(Case(id="c2", type=CaseType.SINGLE, messages=["more"]))
+ ScenarioRepository(seeded_db).update(scenario)
+
+ resp2 = await client.post("/api/runs", json={
+ "target_id": "t-1", "scenario_id": "s-1", "triggered_by": "ai_assistant",
+ })
+ assert resp2.json()["scenario_version"] == 2
+
+ old = (await client.get(f"/api/runs/{old_run_id}")).json()
+ assert old["scenario_version"] == 1
+
+ listing = (await client.get("/api/runs")).json()
+ assert {row["scenario_version"] for row in listing} == {1, 2}
diff --git a/tests/integration/test_scenario_version_migration.py b/tests/integration/test_scenario_version_migration.py
new file mode 100644
index 0000000..4d98042
--- /dev/null
+++ b/tests/integration/test_scenario_version_migration.py
@@ -0,0 +1,50 @@
+"""Exercise the scenario_version backfill migration against a real SQLite file (ticket 04)."""
+
+from pathlib import Path
+
+from alembic import command
+from alembic.config import Config
+from sqlalchemy import create_engine, inspect, text
+
+
+def test_scenario_version_backfill_migration(tmp_path: Path, monkeypatch):
+ from agenteval.storage import db as db_module
+
+ database_url = f"sqlite:///{tmp_path / 'migration.db'}"
+ monkeypatch.setattr(db_module, "DATABASE_URL", database_url)
+ config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
+
+ legacy_engine = create_engine(database_url)
+ with legacy_engine.begin() as connection:
+ connection.execute(text("CREATE TABLE scenarios (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL)"))
+ connection.execute(text("CREATE TABLE eval_results (id VARCHAR PRIMARY KEY, run_id VARCHAR NOT NULL)"))
+ connection.execute(
+ text(
+ "CREATE TABLE eval_runs (id VARCHAR PRIMARY KEY, target_id VARCHAR NOT NULL, "
+ "scenario_id VARCHAR NOT NULL)"
+ )
+ )
+ connection.execute(text("CREATE TABLE turns (id VARCHAR PRIMARY KEY, run_id VARCHAR NOT NULL)"))
+ connection.execute(text("INSERT INTO scenarios (id, name) VALUES ('s1', '场景一')"))
+ connection.execute(
+ text("INSERT INTO eval_runs (id, target_id, scenario_id) VALUES ('r1', 't1', 's1')")
+ )
+ connection.execute(
+ text("INSERT INTO eval_runs (id, target_id, scenario_id) VALUES ('r2', 't1', 'ghost')")
+ )
+
+ # 先升到场景版本迁移,将 s1 手动升到 5,验证回填按场景当前版本 join
+ command.upgrade(config, "c8e2f5a7b901")
+ with create_engine(database_url).begin() as connection:
+ connection.execute(text("UPDATE scenarios SET version = 5 WHERE id = 's1'"))
+
+ command.upgrade(config, "head")
+
+ engine = create_engine(database_url)
+ inspector = inspect(engine)
+ assert "scenario_version" in {c["name"] for c in inspector.get_columns("eval_runs")}
+
+ with engine.connect() as connection:
+ rows = dict(connection.execute(text("SELECT id, scenario_version FROM eval_runs")).fetchall())
+ assert rows["r1"] == 5 # 回填为场景当前版本
+ assert rows["r2"] == 1 # 孤儿运行(场景已删)回填 1
diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py
index 9dd79bb..241d040 100644
--- a/tests/unit/test_engine.py
+++ b/tests/unit/test_engine.py
@@ -437,3 +437,21 @@ async def test_pure_expectation_case_behavior_unchanged(db_session):
assert run.summary["passed_cases"] == 1
assert run.summary["failed_cases"] == 1
+
+# ── scenario_version snapshot (ticket 04) ────────────────────────────────
+
+async def test_engine_run_snapshots_scenario_version(db_session):
+ """引擎直启(CLI 路径)创建的运行快照场景当前版本。"""
+ scenario = Scenario(
+ id="s-1", name="versioned", version=3,
+ cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
+ )
+ channel = MockChannel()
+ engine = _build_engine(scenario, channel, session=db_session)
+
+ run = await engine.run()
+
+ assert run.scenario_version == 3
+ persisted = RunRepository(db_session).get(run.id)
+ assert persisted.scenario_version == 3
+