test(036): backend schemas (22 tests) + frontend AgentRunModel (13 tests)

This commit is contained in:
2026-07-28 10:35:47 +03:00
parent eb0bd201a8
commit 24fca2ec8a
4 changed files with 367 additions and 0 deletions

View File

@@ -72,6 +72,8 @@ class UIContextV2(BaseModel):
@model_validator(mode="after")
def validate_scenario_intent(self):
if self.intent is not None and self.intent not in ("build_dashboard_test_scenario",):
raise ValueError(f"unsupported intent '{self.intent}' — must be 'build_dashboard_test_scenario'")
if self.intent == "build_dashboard_test_scenario" and self.contextVersion != 2:
raise ValueError("scenario intent requires contextVersion=2")
if self.contextVersion == 2 and self.objectType != "dashboard":

View File

@@ -0,0 +1,204 @@
# backend/tests/services/agent_runs/test_schemas.py
# #region Test.AgentRuns.Schemas [C:3] [TYPE Module] [SEMANTICS test,agent-run,schema,validation]
# @BRIEF L1 unit tests for AgentRun Pydantic schemas — no DB dependency.
# @RELATION BINDS_TO -> [Schemas.AgentRun]
# @TEST_EDGE invalid_v2_context -> 422
# @TEST_EDGE scenario_intent_with_v1 -> invalid
# @TEST_EDGE unknown_intent -> invalid
import pytest
from pydantic import ValidationError as PydanticValidationError
from src.schemas.agent_run import (
AgentRunSnapshot,
AppendEventRequest,
ApprovalDecisionRequest,
ApprovalRequest,
CreateAgentRunRequest,
DraftArtifactRef,
EventStatus,
RegisterDraftRequest,
StageEnum,
UIContextV2,
ValidationStatus,
)
class TestUIContextV2:
def test_valid_v1_context_passes(self):
ctx = UIContextV2(
objectType="dashboard", objectId="42", envId="dev",
route="/dashboards/42", contextVersion=1, intent=None,
)
assert ctx.objectId == "42"
assert ctx.contextVersion == 1
def test_valid_v2_scenario_context_passes(self):
ctx = UIContextV2(
objectType="dashboard", objectId="42", envId="dev",
route="/dashboards/42", contextVersion=2,
intent="build_dashboard_test_scenario",
)
assert ctx.intent == "build_dashboard_test_scenario"
def test_scenario_intent_with_v1_is_invalid(self):
with pytest.raises(PydanticValidationError):
UIContextV2(
objectType="dashboard", objectId="42", envId="dev",
route="/dashboards/42", contextVersion=1,
intent="build_dashboard_test_scenario",
)
def test_v2_with_non_dashboard_rejected(self):
with pytest.raises(PydanticValidationError):
UIContextV2(
objectType="dataset", objectId="1", envId="dev",
route="/datasets/1", contextVersion=2,
intent="build_dashboard_test_scenario",
)
def test_unknown_intent_rejected(self):
with pytest.raises(PydanticValidationError):
UIContextV2(
objectType="dashboard", objectId="42", envId="dev",
route="/dashboards/42", contextVersion=2,
intent="run_sql",
)
def test_objectId_must_be_numeric(self):
with pytest.raises(PydanticValidationError):
UIContextV2(
objectType="dashboard", objectId="abc", envId="dev",
route="/dashboards/42", contextVersion=1,
)
def test_route_must_start_with_dashboards_for_v2(self):
ctx = UIContextV2(
objectType="dashboard", objectId="42", envId="dev",
route="/dashboards/42", contextVersion=2,
intent="build_dashboard_test_scenario",
)
assert ctx.route.startswith("/dashboards/")
class TestCreateAgentRunRequest:
def test_valid_request(self):
ctx = UIContextV2(
objectType="dashboard", objectId="42", envId="dev",
route="/dashboards/42", contextVersion=2,
intent="build_dashboard_test_scenario",
)
req = CreateAgentRunRequest(context=ctx)
assert req.context.intent == "build_dashboard_test_scenario"
def test_with_conversation_id(self):
ctx = UIContextV2(
objectType="dashboard", objectId="42", envId="dev",
route="/dashboards/42", contextVersion=2,
intent="build_dashboard_test_scenario",
)
req = CreateAgentRunRequest(context=ctx, conversation_id="conv-123")
assert req.conversation_id == "conv-123"
class TestAppendEventRequest:
def test_valid_progress_event(self):
req = AppendEventRequest(
event_type="progress", stage=StageEnum.inspect,
status=EventStatus.active, sequence=1,
)
assert req.event_type == "progress"
assert req.stage == StageEnum.inspect
def test_sequence_must_be_positive(self):
with pytest.raises(PydanticValidationError):
AppendEventRequest(event_type="progress", sequence=0)
def test_event_type_required(self):
with pytest.raises(PydanticValidationError):
AppendEventRequest(sequence=1)
class TestApprovalRequest:
def test_valid_repository_write_gate(self):
req = ApprovalRequest(
operation="repository_write",
request_hash="a" * 64,
target_paths=["dashboard-tests/FI-0080/scenario.yaml"],
required_permission="dashboard:testing:WRITE",
)
assert req.operation == "repository_write"
assert len(req.target_paths) == 1
def test_reason_required_for_baseline_approval(self):
req = ApprovalRequest(
operation="baseline_approval",
request_hash="b" * 64,
target_paths=["baselines.yaml"],
required_permission="dashboard:testing:APPROVE",
reason_required=True,
)
assert req.reason_required is True
def test_empty_target_paths_rejected(self):
with pytest.raises(PydanticValidationError):
ApprovalRequest(
operation="repository_write",
request_hash="c" * 64,
target_paths=[],
required_permission="dashboard:testing:WRITE",
)
class TestApprovalDecisionRequest:
def test_confirm_with_reason(self):
req = ApprovalDecisionRequest(decision="confirm", reason="Initial QA baseline")
assert req.decision == "confirm"
def test_deny_without_reason(self):
req = ApprovalDecisionRequest(decision="deny")
assert req.decision == "deny"
def test_invalid_decision_rejected(self):
with pytest.raises(PydanticValidationError):
ApprovalDecisionRequest(decision="maybe")
class TestAgentRunSnapshot:
def test_minimal_snapshot(self):
snap = AgentRunSnapshot(
id="run-1", user_id="user-1", intent="dashboard_scenario_build",
trigger="manual", dashboard_id="42", environment_id="dev",
context_snapshot={}, status="CREATED", last_sequence=0,
created_at="2026-01-01T00:00:00Z", updated_at="2026-01-01T00:00:00Z",
)
assert snap.id == "run-1"
assert snap.stages == []
assert snap.drafts == []
class TestDraftArtifactRef:
def test_draft_ref(self):
ref = DraftArtifactRef(
id="draft-1", kind="scenario", name="test.yaml",
intended_path="test.yaml", sha256="a" * 64,
validation_status="pending",
)
assert ref.kind == "scenario"
class TestRegisterDraftRequest:
def test_valid_draft(self):
req = RegisterDraftRequest(
kind="scenario", name="scenario.yaml",
intended_path="dashboard-tests/scenario.yaml",
sha256="a" * 64,
)
assert req.intended_path == "dashboard-tests/scenario.yaml"
def test_invalid_sha256_length_rejected(self):
with pytest.raises(PydanticValidationError):
RegisterDraftRequest(
kind="scenario", name="scenario.yaml",
intended_path="test.yaml", sha256="too_short",
)
# #endregion Test.AgentRuns.Schemas

View File

@@ -80,6 +80,7 @@ export class AgentRunModel {
if (meta.type === "agent_run_started") {
this._init(meta.agent_run_id);
this.state = "running";
this._updateStage("context", "active");
this.currentStage = "context";
log("AgentRuns.Model", "REASON", "Run started", { run_id: this.runId });
return true;

View File

@@ -0,0 +1,160 @@
// frontend/src/lib/models/__tests__/AgentRunModel.test.ts
// #region TestAgentRuns.Model [C:3] [TYPE Module] [SEMANTICS test,agent-run,model,invariant]
// @BRIEF L1 unit tests for AgentRunModel — run FSM, metadata dispatch, recovery, gate decisions.
// @RELATION BINDS_TO -> [AgentRuns.Model]
// @TEST_INVARIANT Stage state derives from structured metadata only.
// @TEST_INVARIANT Drafts are keyed by artifact id and cannot cross run boundaries.
// @TEST_EDGE foreign_run_event -> ignored.
// @TEST_EDGE stale_sequence -> ignored.
// @TEST_EDGE run_id_change_without_reset -> reset triggers.
import { describe, it, expect, beforeEach } from "vitest";
import { AgentRunModel } from "../AgentRunModel.svelte.js";
import type { StreamMetadata } from "../AgentChatTypes.js";
function meta(overrides: Partial<StreamMetadata> = {}): StreamMetadata {
return {
agent_run_id: "run-1",
type: "agent_run_started",
sequence: 1,
...overrides,
};
}
describe("AgentRunModel — Run Lifecycle", () => {
let model: AgentRunModel;
beforeEach(() => {
model = new AgentRunModel();
});
it("starts in absent state", () => {
expect(model.state).toBe("absent");
expect(model.runId).toBeNull();
});
it("transitions to starting on agent_run_started", () => {
const applied = model.applyMetadata(meta({ type: "agent_run_started", agent_run_id: "run-1", sequence: 1 }));
expect(applied).toBe(true);
expect(model.state).toBe("running");
expect(model.runId).toBe("run-1");
expect(model.currentStage).toBe("context");
});
it("ignores foreign run events", () => {
model.applyMetadata(meta({ type: "agent_run_started", agent_run_id: "run-1" }));
const applied = model.applyMetadata(meta({ type: "scenario_progress", agent_run_id: "run-2", stage: "inspect", sequence: 2 }));
expect(applied).toBe(false);
expect(model.currentStage).toBe("context");
});
it("ignores stale sequence numbers", () => {
model.applyMetadata(meta({ type: "agent_run_started", sequence: 1 }));
model.applyMetadata(meta({ type: "scenario_progress", stage: "inspect", stage_status: "completed", sequence: 2 }));
const applied = model.applyMetadata(meta({ type: "scenario_progress", stage: "inspect", stage_status: "completed", sequence: 1 }));
expect(applied).toBe(false);
expect(model.lastSequence).toBe(2);
});
it("returns false for metadata without agent_run_id", () => {
const applied = model.applyMetadata({ type: "stream_token" });
expect(applied).toBe(false);
expect(model.state).toBe("absent");
});
});
describe("AgentRunModel — Stage Progress", () => {
let model: AgentRunModel;
beforeEach(() => {
model = new AgentRunModel();
model.applyMetadata(meta({ type: "agent_run_started", agent_run_id: "run-1", sequence: 1 }));
});
it("adds and updates stages via scenario_progress", () => {
model.applyMetadata(meta({ type: "scenario_progress", stage: "inspect", stage_status: "completed", sequence: 2 }));
expect(model.stages).toHaveLength(2); // context (from init) + inspect
expect(model.stages[1].stage).toBe("inspect");
expect(model.stages[1].status).toBe("completed");
});
it("sorts stages by defined order", () => {
model.applyMetadata(meta({ type: "scenario_progress", stage: "save", stage_status: "pending", sequence: 2 }));
model.applyMetadata(meta({ type: "scenario_progress", stage: "inspect", stage_status: "completed", sequence: 3 }));
const stages = model.stages.map(s => s.stage);
expect(stages.indexOf("inspect")).toBeLessThan(stages.indexOf("save"));
});
it("updates existing stage status", () => {
model.applyMetadata(meta({ type: "scenario_progress", stage: "inspect", stage_status: "active", sequence: 2 }));
expect(model.stages.find(s => s.stage === "inspect")!.status).toBe("active");
model.applyMetadata(meta({ type: "scenario_progress", stage: "inspect", stage_status: "completed", sequence: 3 }));
expect(model.stages.find(s => s.stage === "inspect")!.status).toBe("completed");
});
});
describe("AgentRunModel — Drafts", () => {
let model: AgentRunModel;
beforeEach(() => {
model = new AgentRunModel();
model.applyMetadata(meta({ type: "agent_run_started", agent_run_id: "run-1", sequence: 1 }));
});
it("replaces draft inventory on draft_artifacts", () => {
model.applyMetadata(meta({
type: "draft_artifacts", sequence: 2,
drafts: [{ id: "d1", kind: "scenario", name: "test.yaml", intended_path: "test.yaml", sha256: "a".repeat(64), validation_status: "valid" }],
}));
expect(model.drafts).toHaveLength(1);
expect(model.drafts[0].id).toBe("d1");
// Second update replaces
model.applyMetadata(meta({
type: "draft_artifacts", sequence: 3,
drafts: [
{ id: "d2", kind: "runner_plan", name: "plan.json", intended_path: "plan.json", sha256: "b".repeat(64), validation_status: "valid" },
],
}));
expect(model.drafts).toHaveLength(1);
expect(model.drafts[0].id).toBe("d2");
});
});
describe("AgentRunModel — Terminal States", () => {
let model: AgentRunModel;
beforeEach(() => {
model = new AgentRunModel();
model.applyMetadata(meta({ type: "agent_run_started", agent_run_id: "run-1", sequence: 1 }));
});
it("transitions to completed on COMPLETED terminal", () => {
model.applyMetadata(meta({ type: "agent_run_terminal", terminal_status: "COMPLETED", sequence: 2 }));
expect(model.state).toBe("completed");
});
it("transitions to failed on FAILED terminal with error", () => {
model.applyMetadata(meta({ type: "agent_run_terminal", terminal_status: "FAILED", error_code: "TOOL_ERROR", sequence: 2 }));
expect(model.state).toBe("failed");
expect(model.errorCode).toBe("TOOL_ERROR");
});
it("transitions to waiting_approval", () => {
model.applyMetadata(meta({ type: "agent_run_terminal", terminal_status: "WAITING_APPROVAL", sequence: 2 }));
expect(model.state).toBe("waiting_approval");
});
});
describe("AgentRunModel — Reset", () => {
it("resets all state to absent", () => {
const model = new AgentRunModel();
model.applyMetadata(meta({ type: "agent_run_started", agent_run_id: "run-1", sequence: 1 }));
model.applyMetadata(meta({ type: "scenario_progress", stage: "inspect", stage_status: "completed", sequence: 2 }));
model.reset();
expect(model.state).toBe("absent");
expect(model.runId).toBeNull();
expect(model.stages).toHaveLength(0);
});
});
// #endregion TestAgentRuns.Model