test(036): backend schemas (22 tests) + frontend AgentRunModel (13 tests)
This commit is contained in:
@@ -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":
|
||||
|
||||
204
backend/tests/services/agent_runs/test_schemas.py
Normal file
204
backend/tests/services/agent_runs/test_schemas.py
Normal 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
|
||||
@@ -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;
|
||||
|
||||
160
frontend/src/lib/models/__tests__/AgentRunModel.test.ts
Normal file
160
frontend/src/lib/models/__tests__/AgentRunModel.test.ts
Normal 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
|
||||
Reference in New Issue
Block a user