From 24fca2ec8a92b3c910e7e9e3c8c39cb15dff9675 Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 28 Jul 2026 10:35:47 +0300 Subject: [PATCH] test(036): backend schemas (22 tests) + frontend AgentRunModel (13 tests) --- backend/src/schemas/agent_run.py | 2 + .../tests/services/agent_runs/test_schemas.py | 204 ++++++++++++++++++ .../src/lib/models/AgentRunModel.svelte.ts | 1 + .../models/__tests__/AgentRunModel.test.ts | 160 ++++++++++++++ 4 files changed, 367 insertions(+) create mode 100644 backend/tests/services/agent_runs/test_schemas.py create mode 100644 frontend/src/lib/models/__tests__/AgentRunModel.test.ts diff --git a/backend/src/schemas/agent_run.py b/backend/src/schemas/agent_run.py index 3661d10b0..4de97e7db 100644 --- a/backend/src/schemas/agent_run.py +++ b/backend/src/schemas/agent_run.py @@ -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": diff --git a/backend/tests/services/agent_runs/test_schemas.py b/backend/tests/services/agent_runs/test_schemas.py new file mode 100644 index 000000000..f846d5901 --- /dev/null +++ b/backend/tests/services/agent_runs/test_schemas.py @@ -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 diff --git a/frontend/src/lib/models/AgentRunModel.svelte.ts b/frontend/src/lib/models/AgentRunModel.svelte.ts index c6e94ae66..9b24b9c1e 100644 --- a/frontend/src/lib/models/AgentRunModel.svelte.ts +++ b/frontend/src/lib/models/AgentRunModel.svelte.ts @@ -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; diff --git a/frontend/src/lib/models/__tests__/AgentRunModel.test.ts b/frontend/src/lib/models/__tests__/AgentRunModel.test.ts new file mode 100644 index 000000000..1fd86dbdf --- /dev/null +++ b/frontend/src/lib/models/__tests__/AgentRunModel.test.ts @@ -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 { + 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