mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: company-in-a-box — goal-aware company layer (0.4.0) (#171)
* feat(goals): company charter singleton — data layer (Business Goals slice 1)
First slice of the company-in-a-box "Business Goals" phase: a single CEO-owned
charter row (north star + objectives + constraints + operating policy) that
will be injected into every agent's context_briefing so all work is goal-aware.
- CompanyGoalsTable: singleton table (all-zeros id), JSON objectives /
constraints / operating_policy, updated_at / updated_by.
- migration 032: create + seed the singleton row (offline-renderable; column
server-defaults fill an INSERT of just the id).
- CompanyGoalsService: get() (empty defaults when unset) + upsert() (singleton,
partial update, caller commits).
- tests: empty defaults, roundtrip, singleton + partial-update preservation.
Next slices (mapped, not yet built): briefing injection (BriefingInputs +
build_context_briefing + EvidenceRepo), API route (GET any / PUT CEO-only),
panel /goals page, and base/Board/PM prompt mentions.
* feat(goals): inject the company charter into every agent briefing (slice 2)
The charter is now goal-aware context for every agent:
- BriefingInputs gains company_goals; build_context_briefing surfaces it.
- EvidenceRepo.company_goals(): single-row lookup returning a COMPACT charter
(north star + objectives + constraints + operating policy; audit columns
dropped, lists capped) or None when unset, so an empty charter never bloats
the per-verb briefing.
- _briefing_for wires it into every context_briefing.
Tests: briefing surfaces company_goals (defaults None); repo returns None for an
absent/empty charter and the compact dict when set.
* feat(goals): company charter API — GET any agent, PUT CEO-only (slice 3)
- routes/company_goals.py: GET returns the charter (any authenticated agent —
it drives every briefing); PUT is CEO-only (403 otherwise), partial update via
model_dump(exclude_unset=True), explicit commit.
- schemas/company_goals.py: response + partial-update models.
- registered at /api/company-goals.
- tests: GET open to any role, CEO update persists + is readable, non-CEO 403.
* feat(goals): make the company charter actionable in agent prompts (slice 5)
Agents already receive company_goals in the briefing (slice 2); now tell them to
act on it:
- base.md: universal "Align with the company charter" section — favour work and
trade-offs that advance the objectives, honour the constraints, flag conflicts;
never a license to leave your role.
- board / main_pm / cell_pm: role-specific lines tying triage / cell-routing /
subtask decomposition to the charter.
Prompts are composed at spawn from base.md + roles/*.md directly (compose_prompt),
so no _generated regeneration is needed.
* feat(goals): company charter panel page (slice 4)
CEO-facing editor for the charter at /company-goals:
- lib/api/company-goals.ts: get / update (PUT) client.
- company-goals-card.tsx: edit north star + constraints (one per line) +
objectives / operating_policy (JSON, parsed + validated with toast errors);
display derives from server state (no set-state-in-effect).
- (dashboard)/company-goals/page.tsx + a "Company Goals" sidebar nav link.
tsc --noEmit + eslint clean. Completes Phase 1 (Business Goals): data, briefing
injection, API, prompts, panel.
* fix(test): make test_app route assertions robust to FastAPI 0.137 _IncludedRouter
FastAPI 0.137 stopped flattening include_router into app.routes — each include is
now an _IncludedRouter (a BaseRoute with no .path), so `{r.path for r in
app.routes}` raised AttributeError and the two router-registration tests failed
(the bump arrived via the claude-agent-sdk update in uv.lock). Add
_registered_paths(): OpenAPI schema paths (the stable public contract) plus each
included router's prefix, which also covers the websocket /ws mount (never in the
schema). Drops the now-incorrect type: ignore[attr-defined].
* feat(research): pluggable web search/fetch for Board + PM agents
Add a provider-agnostic web-research capability so the Board and PMs can
ground decisions in current external evidence the knowledge base can't
answer.
- ResearchService selects a provider adapter from config: Tavily, Brave,
and Exa adapters plus a NullProvider that degrades gracefully when no
key is set. Result count and fetched-content size are clamped to caps.
- /api/research/search and /api/research/fetch: role-gated to Board + PMs
(and the CEO), with a per-agent/day Redis quota that fails open.
- roboco-search MCP server (web_search / web_fetch) calls those routes;
the provider key stays server-side and agent containers never egress.
Mounted per role by the orchestrator, behind a master switch.
- Charter-aware prompt guidance for Board, Main PM, and Cell PM.
Additive: with no key configured it is a no-op and the existing delivery
lifecycle is unchanged.
* feat(pitch): Board pitch -> CEO approve -> auto-provision repos
Add an additive origination path so a product can be proposed, approved,
and stood up without manual repo/Project setup.
- Pitch entity + migration (pitches table); PitchService create/list/
reject/approve.
- GitHubProvisioningService: the one place that creates repos (POST
/orgs/{org}/repos). Server-side token/org; when unconfigured the whole
approve path is inert and nothing is created.
- On approval: provision one repo per target cell, register a Project per
repo, create a Product when multi-cell, and seed one Main-PM delivery
task — all reusing the existing Product / coordination-task machinery.
- /api/pitches: Board authors (PO/HoM), CEO approves/rejects, Board+PM+CEO
view. Errors mapped via a single translator.
Additive: the delivery lifecycle is untouched; with no provisioning token
the capability is a no-op. Agent-facing pitch tool + panel are follow-ups.
* feat(strategy): dormant autonomous strategy engine (engine 2)
Add a second, optional engine that watches the company against its
standing goals and surfaces what needs the CEO — without touching the
delivery lifecycle (engine 1).
- StrategyEngine.assess() reports observations: the company is idle while
goals stand, and tasks stranded in 'blocked' past a threshold.
- run_cycle() notifies the CEO (notify-only; it never spends, builds, or
auto-approves — originating work stays a CEO decision).
- Orchestrator runs it on its own interval, started/stopped with the other
background loops; the loop returns immediately unless enabled.
DORMANT by default (strategy_engine_enabled=False): the loop never runs and
a standard deployment is unchanged. Auto-origination is a further opt-in.
* docs(changelog): record Business Goals, Web Research, Pitch->Provision, and the dormant strategy engine under Unreleased
* feat(secretary): wire the Secretary role end-to-end (foundation)
Add SECRETARY as a distinct role — the CEO's conversational chief-of-staff,
governed separately from the Prompter (which stays read-only/human-only).
This is the role foundation only; authority, the live agent, and the panel
land in following commits.
- foundation/identity: Role.SECRETARY (board level), seeded secretary-1 agent,
role-level mapping.
- journaling read tier (ALL — it advises the CEO), role_config entry,
per-role model (opus), prompt-layer mapping + roles/secretary.md.
- i_am_idle gains SECRETARY so the role has a verb surface.
- migration 034: add 'secretary' to the agentrole enum (mirrors 025).
- Role-registry tests updated for the new role.
Inert by itself (nothing spawns it yet); additive — existing roles unchanged.
* feat(secretary): directives + gate-list authority (backend)
The Secretary acts only under CEO command. Low-risk directives (relay a
dictated message) execute immediately; high-impact ones — charter edits,
task start/cancel/override, pitch approval, announcements — are recorded
pending and run only after the CEO confirms (the gate list).
- secretary_directives table (migration 035) as the command audit + queue.
- SecretaryService: read company state; submit (direct->run, gated->queue +
notify CEO); confirm/reject; execution runs with the CEO as actor through
the existing services (the Secretary never holds CEO authority itself).
- /api/secretary: submit + state/task reads (Secretary or CEO); list/confirm/
reject (CEO only). Writes commit explicitly.
* feat(secretary): live conversational agent (container + bridge)
Stand up the Secretary as a persistent Claude-SDK container the CEO chats
with, mirroring the Intake agent and reusing its driver/session machinery.
- secretary_driver: build_secretary_options exposes read_company_state /
read_task / submit_directive as SDK tools that call /api/secretary/* with
the agent's HMAC token; backend-call logic is module-level + tested.
- secretary_main: container entrypoint (receiver + relay) reusing IntakeDriver.
- orchestrator: start/spawn/reap secretary session + run-cmd builder; no
workspace clone (reads state via API), mints a role=secretary token.
- secretary_live routes: panel <-> container bridge over the live registry.
- agent-secretary image (Dockerfile + compose build service).
Inert until a session is started; additive — intake and all agents unchanged.
* feat(secretary): panel chat + directive confirmation queue
The CEO's Secretary surface: a live chat (SSE) to talk to the Secretary, and
a 'Needs your confirmation' queue listing gated directives the Secretary
proposed — each with Confirm / Reject. Adds the sidebar nav entry.
- lib/api/secretary.ts: live (start/stream/status/send/stop) + directive
(list/confirm/reject) + state clients (all as the CEO).
- hooks/use-secretary.ts: drives one chat, accumulating SSE token deltas.
- secretary page: chat pane + pending-directive cards.
Completes the Secretary end-to-end (role + authority + live agent + panel).
* feat(pitch): agent-facing pitch tool + pitches panel
Complete the pitch path: the Board can now author pitches through the gateway,
and the CEO reviews/approves them in the panel.
- content_actions.pitch (Board-only) -> PitchService.create, returning an
Envelope; wired as a do-tool (do_server + /api/v1/do/pitch + schema) and
added to the Board's do-tools.
- Panel /pitches page: lists pitches with CEO Approve & provision / Reject;
sidebar nav entry.
Pitch (Phase 4) is now end-to-end: author -> CEO approve -> auto-provision.
* feat(cockpit): read-only 'is the business winning?' summary
A pure aggregation for the CEO over existing data — no new state, no writes.
- CockpitService.summary(): charter north-star/objectives, delivery counts
(in-flight/blocked/awaiting-CEO), 30-day spend vs the charter's budget cap,
pending pitches, and the strategy engine's signals (what needs you). Stamped
basis='proxy' — performance is a proxy until real launches.
- GET /api/cockpit/summary (CEO / Board / Main PM / Secretary).
- Panel /cockpit page + sidebar nav.
Reuses goals + usage + StrategyEngine.assess(); reads only.
* docs(changelog): add the Secretary and Cockpit to Unreleased
* fix(test): isolate the company-goals empty-defaults test from committed state
The shared test DB persists committed writes across tests; a route test
commits a charter, so the unit test's 'unset' assertion must establish its
own clean precondition rather than assume global emptiness.
* fix(gateway): lower evidence_repo complexity to rank A (xenon gate)
company_goals()'s 4-way `or` emptiness check tipped the module average to
rank B; `any(...)` is equivalent and keeps the module under the gate's A bar.
* chore(compose): mirror agent-secretary-image build into docker-compose.yaml
Both compose files are byte-identical and tracked; .yaml carries the same
agent-secretary-image build service already present in docker-compose.yml.
* chore(lifecycle): regenerate artifacts for secretary i_am_idle
The secretary role gained i_am_idle in the lifecycle spec; regenerate the
generated prompt/doc/json artifacts so foundation-check stays green.
* docs(changelog): cut the company-in-a-box phases to 0.4.0
Label the six additive phases (business goals, web research, pitch-provision,
strategy engine, secretary, cockpit) as 0.4.0; tag v0.4.0 is held until the
branch merges to master so it points at the release commit.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,7 @@ def test_role_enum_has_every_role_inc_system() -> None:
|
||||
"head_marketing",
|
||||
"auditor",
|
||||
"prompter",
|
||||
"secretary",
|
||||
"ceo",
|
||||
"system",
|
||||
}
|
||||
@@ -82,6 +83,7 @@ def test_agents_catalog_has_all_seed_slugs() -> None:
|
||||
"head-marketing",
|
||||
"auditor",
|
||||
"intake-1",
|
||||
"secretary-1",
|
||||
}
|
||||
actual = set(identity.AGENTS.keys())
|
||||
assert actual == expected_slugs, f"agent catalog drift: {actual ^ expected_slugs}"
|
||||
|
||||
@@ -33,6 +33,7 @@ def test_role_enum_has_every_pre_gateway_role() -> None:
|
||||
"head_marketing",
|
||||
"auditor",
|
||||
"prompter", # post-gateway intake role (human-only, drafts tasks)
|
||||
"secretary", # CEO's chief-of-staff (human-only, gated CEO authority)
|
||||
"ceo",
|
||||
"system",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Company-goals API route tests: GET open to any agent, PUT CEO-only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes.company_goals import get_company_goals, update_company_goals
|
||||
from roboco.api.schemas.company_goals import CompanyGoalsUpdate
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
|
||||
|
||||
def _agent(role: AgentRole) -> AgentContext:
|
||||
return AgentContext(agent_id=uuid4(), role=role, team=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_charter_to_any_agent(db_session: Any) -> None:
|
||||
resp = await get_company_goals(db_session, _agent(AgentRole.DEVELOPER))
|
||||
assert resp.north_star == ""
|
||||
assert resp.objectives == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_can_update_and_persist(db_session: Any) -> None:
|
||||
ceo = _agent(AgentRole.CEO)
|
||||
resp = await update_company_goals(
|
||||
CompanyGoalsUpdate(north_star="Win the market"), db_session, ceo
|
||||
)
|
||||
assert resp.north_star == "Win the market"
|
||||
assert resp.updated_by == str(ceo.agent_id)
|
||||
# Persisted and readable by a non-CEO agent.
|
||||
again = await get_company_goals(db_session, _agent(AgentRole.QA))
|
||||
assert again.north_star == "Win the market"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_ceo_cannot_update() -> None:
|
||||
# The CEO check fires before any DB access, so a dummy session suffices.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_company_goals(
|
||||
CompanyGoalsUpdate(north_star="nope"),
|
||||
MagicMock(),
|
||||
_agent(AgentRole.DEVELOPER),
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
@@ -0,0 +1,179 @@
|
||||
"""roboco.api.routes.pitch — role gates + decision flow (direct-call style)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes import pitch as pitch_route
|
||||
from roboco.api.schemas.pitch import PitchCreateRequest, PitchDecision
|
||||
from roboco.db.tables import PitchTable
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services.base import ConflictError
|
||||
from roboco.services.github_provisioning import ProvisioningDisabledError
|
||||
|
||||
|
||||
def _agent(role: AgentRole) -> AgentContext:
|
||||
return AgentContext(agent_id=uuid4(), role=role, team=None)
|
||||
|
||||
|
||||
def _db() -> MagicMock:
|
||||
db = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
def _pitch() -> PitchTable:
|
||||
return PitchTable(
|
||||
id=uuid4(),
|
||||
title="Widget",
|
||||
slug="widget",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=["backend"],
|
||||
status="proposed",
|
||||
created_by=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
class _FakeService:
|
||||
def __init__(
|
||||
self, *, pitch: PitchTable | None = None, exc: Exception | None = None
|
||||
) -> None:
|
||||
self._pitch = pitch
|
||||
self._exc = exc
|
||||
|
||||
async def create(self, _data: Any, created_by: Any) -> PitchTable:
|
||||
_ = created_by
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
assert self._pitch is not None
|
||||
return self._pitch
|
||||
|
||||
async def approve(
|
||||
self, _pitch_id: Any, _notes: Any, _by: Any, *, provisioning: Any = None
|
||||
) -> PitchTable:
|
||||
_ = provisioning
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
assert self._pitch is not None
|
||||
return self._pitch
|
||||
|
||||
async def reject(self, _pitch_id: Any, _notes: Any, _by: Any) -> PitchTable:
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
assert self._pitch is not None
|
||||
return self._pitch
|
||||
|
||||
|
||||
def _install(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
|
||||
monkeypatch.setattr(pitch_route, "get_pitch_service", lambda _db: service)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_board_cannot_create() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await pitch_route.create_pitch(
|
||||
PitchCreateRequest(
|
||||
title="W",
|
||||
slug="w",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=["backend"],
|
||||
),
|
||||
_db(),
|
||||
_agent(AgentRole.DEVELOPER),
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rejects_non_cell_target() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await pitch_route.create_pitch(
|
||||
PitchCreateRequest(
|
||||
title="W",
|
||||
slug="w",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=["board"],
|
||||
),
|
||||
_db(),
|
||||
_agent(AgentRole.PRODUCT_OWNER),
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _db()
|
||||
_install(monkeypatch, _FakeService(pitch=_pitch()))
|
||||
resp = await pitch_route.create_pitch(
|
||||
PitchCreateRequest(
|
||||
title="Widget",
|
||||
slug="widget",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=["backend"],
|
||||
),
|
||||
db,
|
||||
_agent(AgentRole.HEAD_MARKETING),
|
||||
)
|
||||
assert resp.slug == "widget"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_ceo_cannot_approve() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await pitch_route.approve_pitch(
|
||||
uuid4(), _db(), _agent(AgentRole.PRODUCT_OWNER), PitchDecision(notes="x")
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_provisioning_disabled_returns_400(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_install(monkeypatch, _FakeService(exc=ProvisioningDisabledError("not configured")))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await pitch_route.approve_pitch(
|
||||
uuid4(), _db(), _agent(AgentRole.CEO), PitchDecision(notes="go")
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_conflict_returns_409(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_install(monkeypatch, _FakeService(exc=ConflictError("already decided")))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await pitch_route.approve_pitch(
|
||||
uuid4(), _db(), _agent(AgentRole.CEO), PitchDecision(notes="go")
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.CONFLICT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_requires_reason() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await pitch_route.reject_pitch(
|
||||
uuid4(), PitchDecision(notes=None), _db(), _agent(AgentRole.CEO)
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _db()
|
||||
_install(monkeypatch, _FakeService(pitch=_pitch()))
|
||||
resp = await pitch_route.reject_pitch(
|
||||
uuid4(), PitchDecision(notes="not now, off-charter"), db, _agent(AgentRole.CEO)
|
||||
)
|
||||
assert resp.slug == "widget"
|
||||
db.commit.assert_awaited_once()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""roboco.api.routes.research — role gate, quota, and error mapping.
|
||||
|
||||
Calls the route coroutines directly with a constructed AgentContext (the same
|
||||
style as test_company_goals_routes) so no app/DB wiring is needed; the service
|
||||
and quota tracker are patched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes import research as research_route
|
||||
from roboco.api.schemas.research import FetchRequest, SearchRequest
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services.research import (
|
||||
FetchOutcome,
|
||||
ResearchError,
|
||||
ResearchUnsupportedError,
|
||||
SearchHit,
|
||||
SearchOutcome,
|
||||
)
|
||||
from roboco.services.research_quota import QuotaStatus
|
||||
|
||||
|
||||
def _agent(role: AgentRole) -> AgentContext:
|
||||
return AgentContext(agent_id=uuid4(), role=role, team=None)
|
||||
|
||||
|
||||
class _FakeService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_outcome: SearchOutcome | None = None,
|
||||
fetch_outcome: FetchOutcome | None = None,
|
||||
exc: Exception | None = None,
|
||||
) -> None:
|
||||
self._search_outcome = search_outcome
|
||||
self._fetch_outcome = fetch_outcome
|
||||
self._exc = exc
|
||||
self.closed = False
|
||||
|
||||
async def search(self, _query: str, _max_results: int | None) -> SearchOutcome:
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
assert self._search_outcome is not None
|
||||
return self._search_outcome
|
||||
|
||||
async def fetch(self, _url: str, _max_chars: int | None) -> FetchOutcome:
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
assert self._fetch_outcome is not None
|
||||
return self._fetch_outcome
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _allow_quota(monkeypatch: pytest.MonkeyPatch, *, allowed: bool = True) -> None:
|
||||
async def _check(_agent_id: str, limit: int, **_: object) -> QuotaStatus:
|
||||
return QuotaStatus(allowed=allowed, used=1, limit=limit, day="2026-06-15")
|
||||
|
||||
monkeypatch.setattr(research_route._quota_tracker, "check_and_consume", _check)
|
||||
|
||||
|
||||
def _install_service(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
|
||||
monkeypatch.setattr(research_route, "get_research_service", lambda: service)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_research_role_is_forbidden() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await research_route.research_search(
|
||||
SearchRequest(query="x"), _agent(AgentRole.DEVELOPER)
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_success_maps_results(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_allow_quota(monkeypatch)
|
||||
service = _FakeService(
|
||||
search_outcome=SearchOutcome(
|
||||
query="q",
|
||||
hits=[SearchHit(title="T", url="https://t.test", snippet="s", score=0.7)],
|
||||
answer="ans",
|
||||
provider="tavily",
|
||||
)
|
||||
)
|
||||
_install_service(monkeypatch, service)
|
||||
resp = await research_route.research_search(
|
||||
SearchRequest(query="q"), _agent(AgentRole.PRODUCT_OWNER)
|
||||
)
|
||||
assert resp.provider == "tavily"
|
||||
assert resp.answer == "ans"
|
||||
assert resp.results[0].url == "https://t.test"
|
||||
assert service.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quota_exhausted_returns_429(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_allow_quota(monkeypatch, allowed=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await research_route.research_search(
|
||||
SearchRequest(query="q"), _agent(AgentRole.MAIN_PM)
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.TOO_MANY_REQUESTS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_error_returns_502(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_allow_quota(monkeypatch)
|
||||
_install_service(monkeypatch, _FakeService(exc=ResearchError("boom")))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await research_route.research_search(
|
||||
SearchRequest(query="q"), _agent(AgentRole.CELL_PM)
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.BAD_GATEWAY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_unsupported_returns_501(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_allow_quota(monkeypatch)
|
||||
_install_service(
|
||||
monkeypatch, _FakeService(exc=ResearchUnsupportedError("no fetch"))
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await research_route.research_fetch(
|
||||
FetchRequest(url="https://x.test"), _agent(AgentRole.PRODUCT_OWNER)
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.NOT_IMPLEMENTED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_allow_quota(monkeypatch)
|
||||
service = _FakeService(
|
||||
fetch_outcome=FetchOutcome(
|
||||
url="https://x.test", content="body", truncated=False, provider="exa"
|
||||
)
|
||||
)
|
||||
_install_service(monkeypatch, service)
|
||||
resp = await research_route.research_fetch(
|
||||
FetchRequest(url="https://x.test"), _agent(AgentRole.HEAD_MARKETING)
|
||||
)
|
||||
assert resp.content == "body"
|
||||
assert resp.provider == "exa"
|
||||
@@ -0,0 +1,161 @@
|
||||
"""roboco.api.routes.secretary — role gates + directive flow (direct-call style)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes import secretary as sec_route
|
||||
from roboco.api.schemas.secretary import DirectiveDecision, DirectiveSubmit
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services.base import ConflictError
|
||||
|
||||
_ROW = object()
|
||||
_DIRECTIVE_DICT: dict[str, Any] = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"kind": "relay_message",
|
||||
"status": "executed",
|
||||
"payload": {},
|
||||
"requested_by": "22222222-2222-2222-2222-222222222222",
|
||||
"requested_at": None,
|
||||
"decided_by": None,
|
||||
"decided_at": None,
|
||||
"result": "posted to #all-hands",
|
||||
}
|
||||
|
||||
|
||||
def _agent(role: AgentRole) -> AgentContext:
|
||||
return AgentContext(agent_id=uuid4(), role=role, team=None)
|
||||
|
||||
|
||||
def _db() -> MagicMock:
|
||||
db = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
class _FakeService:
|
||||
def __init__(self, *, exc: Exception | None = None) -> None:
|
||||
self._exc = exc
|
||||
|
||||
async def submit_directive(self, _kind: Any, _payload: Any, _by: Any) -> object:
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
return _ROW
|
||||
|
||||
async def confirm_directive(self, _directive_id: Any, _by: Any) -> object:
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
return _ROW
|
||||
|
||||
async def reject_directive(
|
||||
self, _directive_id: Any, _by: Any, _reason: Any
|
||||
) -> object:
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
return _ROW
|
||||
|
||||
async def list_directives(self, _status: Any = None) -> list[object]:
|
||||
return [_ROW]
|
||||
|
||||
async def read_company_state(self) -> dict[str, Any]:
|
||||
return {
|
||||
"goals": {},
|
||||
"task_counts": {},
|
||||
"pending_pitches": [],
|
||||
"pending_directives": [],
|
||||
}
|
||||
|
||||
def to_dict(self, _row: object) -> dict[str, Any]:
|
||||
return dict(_DIRECTIVE_DICT)
|
||||
|
||||
|
||||
def _install(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
|
||||
monkeypatch.setattr(sec_route, "get_secretary_service", lambda _db: service)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_forbidden_for_developer() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await sec_route.submit_directive(
|
||||
DirectiveSubmit(kind="relay_message"), _db(), _agent(AgentRole.DEVELOPER)
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_bad_kind_422() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await sec_route.submit_directive(
|
||||
DirectiveSubmit(kind="bogus"), _db(), _agent(AgentRole.SECRETARY)
|
||||
)
|
||||
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _db()
|
||||
_install(monkeypatch, _FakeService())
|
||||
resp = await sec_route.submit_directive(
|
||||
DirectiveSubmit(
|
||||
kind="relay_message", payload={"channel": "all-hands", "text": "hi"}
|
||||
),
|
||||
db,
|
||||
_agent(AgentRole.SECRETARY),
|
||||
)
|
||||
assert resp.status == "executed"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_forbidden_for_secretary() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.SECRETARY))
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _db()
|
||||
_install(monkeypatch, _FakeService())
|
||||
resp = await sec_route.confirm_directive(uuid4(), db, _agent(AgentRole.CEO))
|
||||
assert resp.id == _DIRECTIVE_DICT["id"]
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_conflict_409(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_install(monkeypatch, _FakeService(exc=ConflictError("already decided")))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.CEO))
|
||||
assert exc.value.status_code == HTTPStatus.CONFLICT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_forbidden_for_secretary() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await sec_route.list_directives(_db(), _agent(AgentRole.SECRETARY))
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _db()
|
||||
_install(monkeypatch, _FakeService())
|
||||
resp = await sec_route.reject_directive(
|
||||
uuid4(), DirectiveDecision(reason="no"), db, _agent(AgentRole.CEO)
|
||||
)
|
||||
assert resp.id == _DIRECTIVE_DICT["id"]
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_allows_secretary(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_install(monkeypatch, _FakeService())
|
||||
resp = await sec_route.read_state(_db(), _agent(AgentRole.SECRETARY))
|
||||
assert resp.pending_pitches == []
|
||||
@@ -0,0 +1,97 @@
|
||||
"""roboco.agent_sdk.secretary_driver — the backend-calling tool helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.agent_sdk import secretary_driver as sd
|
||||
|
||||
Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
def _client(handler: Handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_API_URL", "http://x:8000")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "secretary-uuid")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "secretary")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "tok")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_state_calls_backend_with_auth(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_env(monkeypatch)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/secretary/state"
|
||||
assert request.headers["X-Agent-Token"] == "tok"
|
||||
assert request.headers["X-Agent-Role"] == "secretary"
|
||||
return httpx.Response(200, json={"goals": {}})
|
||||
|
||||
out = await sd._do_read_state(client=_client(handler))
|
||||
assert out == {"goals": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_task_calls_backend(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_env(monkeypatch)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/secretary/tasks/abc"
|
||||
return httpx.Response(200, json={"id": "abc"})
|
||||
|
||||
out = await sd._do_read_task("abc", client=_client(handler))
|
||||
assert out["id"] == "abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_directive_posts_kind_and_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_env(monkeypatch)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/secretary/directives"
|
||||
body = json.loads(request.content)
|
||||
assert body == {"kind": "announce", "payload": {"text": "hi"}}
|
||||
return httpx.Response(201, json={"status": "pending"})
|
||||
|
||||
out = await sd._do_submit_directive(
|
||||
"announce", {"text": "hi"}, client=_client(handler)
|
||||
)
|
||||
assert out["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_returns_error_dict(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_env(monkeypatch)
|
||||
out = await sd._do_read_state(
|
||||
client=_client(lambda _r: httpx.Response(500, text="boom"))
|
||||
)
|
||||
assert "error" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_returns_error_dict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_env(monkeypatch)
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
out = await sd._do_read_state(client=_client(handler))
|
||||
assert out["error"] == "request_failed"
|
||||
|
||||
|
||||
def test_text_result_shape() -> None:
|
||||
result = sd._text_result({"a": 1})
|
||||
assert result["content"][0]["type"] == "text"
|
||||
assert json.loads(result["content"][0]["text"]) == {"a": 1}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""roboco.api.routes.secretary_live — live-bridge endpoints (mocked deps)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes import secretary_live as sl
|
||||
from roboco.api.routes.secretary_live import (
|
||||
AgentEvent,
|
||||
LiveMessageRequest,
|
||||
StartSecretaryRequest,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_spawns_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = MagicMock()
|
||||
orch.start_secretary_session = AsyncMock()
|
||||
monkeypatch.setattr(sl, "get_orchestrator", lambda: orch)
|
||||
resp = await sl.start_live(StartSecretaryRequest(initial_message="hi"))
|
||||
assert resp.session_id
|
||||
orch.start_secretary_session.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messages_delivers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reg = MagicMock()
|
||||
reg.deliver = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
|
||||
out = await sl.send_message("sid", LiveMessageRequest(text="hi"))
|
||||
assert out == {"delivered": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messages_404_when_not_live(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reg = MagicMock()
|
||||
reg.deliver = AsyncMock(return_value=False)
|
||||
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await sl.send_message("sid", LiveMessageRequest(text="hi"))
|
||||
assert exc.value.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_reaps(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = MagicMock()
|
||||
orch.reap_secretary_session = AsyncMock()
|
||||
monkeypatch.setattr(sl, "get_orchestrator", lambda: orch)
|
||||
out = await sl.stop_live("sid")
|
||||
assert out == {"stopped": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relay_event_pushes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reg = MagicMock()
|
||||
reg.push = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
|
||||
out = await sl.relay_event("sid", AgentEvent(kind="text", text="hello"))
|
||||
assert out == {"pushed": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reg = MagicMock()
|
||||
reg.is_alive = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
|
||||
out = await sl.session_status("sid")
|
||||
assert out == {"alive": True}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""roboco.services.gateway.content_actions.pitch — Board-gated product proposal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
||||
|
||||
|
||||
def _actions(role: str) -> ContentActions:
|
||||
task = MagicMock()
|
||||
agent = MagicMock()
|
||||
agent.role = role
|
||||
task.agent_for = AsyncMock(return_value=agent)
|
||||
task.session = MagicMock()
|
||||
deps = ContentActionsDeps(
|
||||
task=task,
|
||||
git=MagicMock(),
|
||||
messaging=MagicMock(),
|
||||
a2a=MagicMock(),
|
||||
journal=MagicMock(),
|
||||
workspace=MagicMock(),
|
||||
notifications=MagicMock(),
|
||||
)
|
||||
return ContentActions(deps)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pitch_forbidden_for_non_board() -> None:
|
||||
env = await _actions("developer").pitch(
|
||||
agent_id=uuid4(),
|
||||
title="T",
|
||||
slug="t",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=["backend"],
|
||||
)
|
||||
assert env.error is not None
|
||||
assert env.status is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pitch_creates_for_board(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
created = MagicMock()
|
||||
created.id = uuid4()
|
||||
svc = MagicMock()
|
||||
svc.create = AsyncMock(return_value=created)
|
||||
monkeypatch.setattr("roboco.services.pitch.get_pitch_service", lambda _s: svc)
|
||||
env = await _actions("product_owner").pitch(
|
||||
agent_id=uuid4(),
|
||||
title="Widget",
|
||||
slug="widget",
|
||||
problem="people need widgets",
|
||||
proposed_solution="build a widget service",
|
||||
target_cells=["backend", "frontend"],
|
||||
)
|
||||
assert env.error is None
|
||||
assert env.status == "proposed"
|
||||
svc.create.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pitch_rejects_non_cell_target() -> None:
|
||||
env = await _actions("head_marketing").pitch(
|
||||
agent_id=uuid4(),
|
||||
title="T",
|
||||
slug="t",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=["board"],
|
||||
)
|
||||
assert env.error is not None
|
||||
@@ -109,6 +109,30 @@ class TestContextBriefing:
|
||||
)
|
||||
assert build_context_briefing(with_handoff)["task_handoff"] == {"pr_number": 8}
|
||||
|
||||
def test_company_goals_defaults_none_and_surfaces_in_briefing(self) -> None:
|
||||
inputs = BriefingInputs(
|
||||
unread_a2a=[],
|
||||
unread_mentions=[],
|
||||
pending_notifications=[],
|
||||
task_metadata_gaps=[],
|
||||
recent_team_activity=[],
|
||||
blockers_in_my_lane=[],
|
||||
)
|
||||
assert build_context_briefing(inputs)["company_goals"] is None
|
||||
|
||||
with_goals = BriefingInputs(
|
||||
unread_a2a=[],
|
||||
unread_mentions=[],
|
||||
pending_notifications=[],
|
||||
task_metadata_gaps=[],
|
||||
recent_team_activity=[],
|
||||
blockers_in_my_lane=[],
|
||||
company_goals={"north_star": "win"},
|
||||
)
|
||||
assert build_context_briefing(with_goals)["company_goals"] == {
|
||||
"north_star": "win"
|
||||
}
|
||||
|
||||
|
||||
class TestTaskHandoff:
|
||||
def test_none_task_returns_none(self) -> None:
|
||||
|
||||
@@ -38,6 +38,45 @@ def _repo_with_rows(rows: list[object], *, scalar: object = None) -> EvidenceRep
|
||||
return EvidenceRepo(db)
|
||||
|
||||
|
||||
def _repo_with_goals_row(row: object | None) -> EvidenceRepo:
|
||||
"""Repo whose execute().scalar_one_or_none() yields the singleton row."""
|
||||
db = MagicMock()
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = row
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
return EvidenceRepo(db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_goals_none_when_no_row() -> None:
|
||||
assert await _repo_with_goals_row(None).company_goals() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_goals_none_when_empty_charter() -> None:
|
||||
row = SimpleNamespace(
|
||||
north_star="", objectives=[], constraints=[], operating_policy={}
|
||||
)
|
||||
assert await _repo_with_goals_row(row).company_goals() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_goals_compact_dict_when_set() -> None:
|
||||
row = SimpleNamespace(
|
||||
north_star="Win the market",
|
||||
objectives=[{"metric": "NPS", "target": 50}],
|
||||
constraints=["AGPL"],
|
||||
operating_policy={"autonomy_level": "assisted"},
|
||||
)
|
||||
goals = await _repo_with_goals_row(row).company_goals()
|
||||
assert goals == {
|
||||
"north_star": "Win the market",
|
||||
"objectives": [{"metric": "NPS", "target": 50}],
|
||||
"constraints": ["AGPL"],
|
||||
"operating_policy": {"autonomy_level": "assisted"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constructor_stores_db_session() -> None:
|
||||
fake_db = MagicMock()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""roboco.mcp.search_server — handler shaping + server construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from roboco.mcp.search_server import (
|
||||
_NOT_CONFIGURED,
|
||||
_handle_fetch,
|
||||
_handle_search,
|
||||
create_search_mcp_server,
|
||||
)
|
||||
from roboco.mcp.utils import ApiClient
|
||||
|
||||
|
||||
class _FakeClient(ApiClient):
|
||||
"""ApiClient stand-in that records calls and returns canned responses.
|
||||
|
||||
Subclasses ApiClient (so it type-checks where one is expected) but skips
|
||||
the real ``__init__`` — only ``post_or_error`` is exercised here.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
result: dict[str, Any] | None = None,
|
||||
error: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._result = result
|
||||
self._error = error
|
||||
self.calls: list[tuple[str, dict[str, Any] | None]] = []
|
||||
|
||||
async def post_or_error(
|
||||
self,
|
||||
endpoint: str,
|
||||
json: dict[str, Any] | None = None,
|
||||
error_code: str = "API_ERROR",
|
||||
error_message: str = "Request failed",
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
self.calls.append((endpoint, json))
|
||||
return self._result, self._error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_success_includes_cite_guidance() -> None:
|
||||
client = _FakeClient(
|
||||
result={
|
||||
"query": "q",
|
||||
"provider": "tavily",
|
||||
"answer": "a",
|
||||
"results": [{"title": "T", "url": "https://t.test", "snippet": "s"}],
|
||||
}
|
||||
)
|
||||
out = await _handle_search("q", 3, client)
|
||||
assert out["provider"] == "tavily"
|
||||
assert "cite" in out["guidance"].lower()
|
||||
assert client.calls == [("/research/search", {"query": "q", "max_results": 3})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_null_provider_signals_not_configured() -> None:
|
||||
client = _FakeClient(
|
||||
result={"query": "q", "provider": "null", "answer": None, "results": []}
|
||||
)
|
||||
out = await _handle_search("q", None, client)
|
||||
assert out["guidance"] == _NOT_CONFIGURED
|
||||
# No max_results key when not supplied.
|
||||
assert client.calls == [("/research/search", {"query": "q"})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_propagates_error() -> None:
|
||||
err = {"status": "error", "error": {"code": "SEARCH_FAILED"}}
|
||||
client = _FakeClient(error=err)
|
||||
out = await _handle_search("q", None, client)
|
||||
assert out == err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_success_shapes_payload() -> None:
|
||||
client = _FakeClient(
|
||||
result={
|
||||
"url": "https://x.test",
|
||||
"provider": "exa",
|
||||
"content": "body",
|
||||
"truncated": True,
|
||||
}
|
||||
)
|
||||
out = await _handle_fetch("https://x.test", 500, client)
|
||||
assert out["content"] == "body"
|
||||
assert out["truncated"] is True
|
||||
assert client.calls == [
|
||||
("/research/fetch", {"url": "https://x.test", "max_chars": 500})
|
||||
]
|
||||
|
||||
|
||||
def test_create_search_mcp_server_builds() -> None:
|
||||
server = create_search_mcp_server("be-pm")
|
||||
assert isinstance(server, FastMCP)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""roboco.runtime.orchestrator — Secretary docker-run cmd + host paths (pure)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from roboco.runtime.orchestrator import (
|
||||
SECRETARY_AGENT_ID,
|
||||
AgentOrchestrator,
|
||||
_SecretaryRunSpec,
|
||||
)
|
||||
|
||||
|
||||
def _spec() -> _SecretaryRunSpec:
|
||||
return _SecretaryRunSpec(
|
||||
container_name=f"roboco-agent-{SECRETARY_AGENT_ID}",
|
||||
image="roboco-agent-secretary",
|
||||
hosts={"claude": "/h/.claude", "prompt": "/h/p.md"},
|
||||
session_id="sid123",
|
||||
cwd="/app",
|
||||
cli_model="opus",
|
||||
api_url="http://x:8000",
|
||||
agent_uuid="uuid-1",
|
||||
agent_token="tok-1",
|
||||
provider_base_url=None,
|
||||
provider_auth_token=None,
|
||||
)
|
||||
|
||||
|
||||
def test_build_secretary_run_cmd_wires_token_and_session() -> None:
|
||||
cmd = AgentOrchestrator._build_secretary_run_cmd(_spec())
|
||||
assert cmd[-1] == "roboco-agent-secretary" # image is last
|
||||
assert "ROBOCO_AGENT_TOKEN=tok-1" in cmd
|
||||
assert "ROBOCO_AGENT_ID=uuid-1" in cmd
|
||||
assert "ROBOCO_AGENT_ROLE=secretary" in cmd
|
||||
assert "ROBOCO_SECRETARY_SESSION_ID=sid123" in cmd
|
||||
# No workspaces mount for the Secretary.
|
||||
assert not any("/data/workspaces" in part for part in cmd)
|
||||
|
||||
|
||||
def test_build_secretary_run_cmd_adds_provider_env_when_set() -> None:
|
||||
spec = _spec()
|
||||
spec_with_provider = _SecretaryRunSpec(
|
||||
**{
|
||||
**spec.__dict__,
|
||||
"provider_base_url": "https://prov",
|
||||
"provider_auth_token": "ptok",
|
||||
}
|
||||
)
|
||||
cmd = AgentOrchestrator._build_secretary_run_cmd(spec_with_provider)
|
||||
assert "ANTHROPIC_BASE_URL=https://prov" in cmd
|
||||
assert "ANTHROPIC_AUTH_TOKEN=ptok" in cmd
|
||||
|
||||
|
||||
def test_resolve_secretary_host_paths_has_claude_and_prompt() -> None:
|
||||
paths = AgentOrchestrator._resolve_secretary_host_paths(MagicMock())
|
||||
assert "claude" in paths
|
||||
assert "prompt" in paths
|
||||
assert SECRETARY_AGENT_ID in str(paths["prompt"])
|
||||
@@ -0,0 +1,123 @@
|
||||
"""roboco.services.cockpit + route — read-only company summary (mocked deps)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes import cockpit as croute
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services import cockpit as cm
|
||||
from roboco.services.cockpit import CockpitService
|
||||
from roboco.services.strategy_engine import StrategyObservation
|
||||
|
||||
_IN_PROGRESS = 2
|
||||
_CLAIMED = 1
|
||||
_BLOCKED = 3
|
||||
_BUDGET = 100.0
|
||||
_SPEND_30D = 150.0
|
||||
|
||||
|
||||
def _agent(role: AgentRole) -> AgentContext:
|
||||
return AgentContext(agent_id=uuid4(), role=role, team=None)
|
||||
|
||||
|
||||
def _patch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
goals = {
|
||||
"north_star": "Win the market",
|
||||
"objectives": [{"metric": "NPS"}],
|
||||
"operating_policy": {"monthly_budget_cap": _BUDGET},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
cm,
|
||||
"get_company_goals_service",
|
||||
lambda _s: MagicMock(get=AsyncMock(return_value=goals)),
|
||||
)
|
||||
counts = {
|
||||
"in_progress": _IN_PROGRESS,
|
||||
"claimed": _CLAIMED,
|
||||
"blocked": _BLOCKED,
|
||||
"awaiting_ceo_approval": 1,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
cm,
|
||||
"get_task_service",
|
||||
lambda _s: MagicMock(count_by_status=AsyncMock(return_value=counts)),
|
||||
)
|
||||
usage = MagicMock(
|
||||
get_summary=AsyncMock(return_value={"total_cost_usd": _SPEND_30D}),
|
||||
get_projection=AsyncMock(return_value={"projected_monthly_cost_usd": 200.0}),
|
||||
)
|
||||
monkeypatch.setattr(cm, "get_usage_service", lambda _s: usage)
|
||||
monkeypatch.setattr(
|
||||
cm,
|
||||
"get_strategy_engine",
|
||||
lambda _s: MagicMock(
|
||||
assess=AsyncMock(
|
||||
return_value=[StrategyObservation(kind="idle", summary="s", detail="d")]
|
||||
)
|
||||
),
|
||||
)
|
||||
proposed = MagicMock()
|
||||
proposed.status = "proposed"
|
||||
done = MagicMock()
|
||||
done.status = "provisioned"
|
||||
monkeypatch.setattr(
|
||||
cm,
|
||||
"get_pitch_service",
|
||||
lambda _s: MagicMock(list_pitches=AsyncMock(return_value=[proposed, done])),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_aggregates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch(monkeypatch)
|
||||
out = await CockpitService(MagicMock()).summary()
|
||||
assert out["basis"] == "proxy"
|
||||
assert out["north_star"] == "Win the market"
|
||||
assert out["delivery"]["in_flight"] == _IN_PROGRESS + _CLAIMED
|
||||
assert out["delivery"]["blocked"] == _BLOCKED
|
||||
assert out["spend"]["spend_30d_usd"] == _SPEND_30D
|
||||
assert out["spend"]["over_budget"] is True
|
||||
assert out["pending_pitches"] == 1
|
||||
assert out["signals"][0]["kind"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_forbidden_for_developer() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await croute.cockpit_summary(MagicMock(), _agent(AgentRole.DEVELOPER))
|
||||
assert exc.value.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
summary: dict[str, Any] = {
|
||||
"basis": "proxy",
|
||||
"north_star": "Win",
|
||||
"objectives": [],
|
||||
"delivery": {
|
||||
"task_counts": {},
|
||||
"in_flight": 0,
|
||||
"blocked": 0,
|
||||
"awaiting_ceo": 0,
|
||||
},
|
||||
"spend": {
|
||||
"spend_30d_usd": 0.0,
|
||||
"projected_monthly_usd": None,
|
||||
"monthly_budget_cap_usd": None,
|
||||
"over_budget": False,
|
||||
},
|
||||
"pending_pitches": 0,
|
||||
"signals": [],
|
||||
}
|
||||
svc = MagicMock(summary=AsyncMock(return_value=summary))
|
||||
monkeypatch.setattr(croute, "get_cockpit_service", lambda _db: svc)
|
||||
resp = await croute.cockpit_summary(MagicMock(), _agent(AgentRole.CEO))
|
||||
assert resp.basis == "proxy"
|
||||
assert resp.spend.over_budget is False
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for CompanyGoalsService — the singleton company charter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import CompanyGoalsTable
|
||||
from roboco.services.company_goals import (
|
||||
SINGLETON_ID,
|
||||
get_company_goals_service,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_empty_defaults_when_unset(db_session: Any) -> None:
|
||||
# The "unset" contract is about no/empty charter row. The test DB is shared
|
||||
# and route tests commit a charter to it, so establish a clean precondition
|
||||
# rather than assume global emptiness.
|
||||
existing = await db_session.get(CompanyGoalsTable, SINGLETON_ID)
|
||||
if existing is not None:
|
||||
await db_session.delete(existing)
|
||||
await db_session.commit()
|
||||
svc = get_company_goals_service(db_session)
|
||||
goals = await svc.get()
|
||||
assert goals["north_star"] == ""
|
||||
assert goals["objectives"] == []
|
||||
assert goals["constraints"] == []
|
||||
assert goals["operating_policy"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_then_get_roundtrips(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
actor = uuid4()
|
||||
await svc.upsert(
|
||||
{
|
||||
"north_star": "Ship a delightful product",
|
||||
"objectives": [{"metric": "NPS", "target": 50, "status": "active"}],
|
||||
"constraints": ["AGPL only"],
|
||||
"operating_policy": {
|
||||
"autonomy_level": "assisted",
|
||||
"monthly_budget_cap": 500,
|
||||
},
|
||||
},
|
||||
updated_by=actor,
|
||||
)
|
||||
goals = await svc.get()
|
||||
assert goals["north_star"] == "Ship a delightful product"
|
||||
assert goals["objectives"][0]["metric"] == "NPS"
|
||||
assert goals["constraints"] == ["AGPL only"]
|
||||
assert goals["operating_policy"]["autonomy_level"] == "assisted"
|
||||
assert goals["updated_by"] == str(actor)
|
||||
assert goals["updated_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_is_singleton_and_partial(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"north_star": "First", "constraints": ["a"]})
|
||||
# A second upsert updates the SAME row and only the provided keys.
|
||||
await svc.upsert({"north_star": "Second"})
|
||||
goals = await svc.get()
|
||||
assert goals["north_star"] == "Second"
|
||||
assert goals["constraints"] == ["a"] # untouched key preserved
|
||||
|
||||
# Exactly one row exists (singleton), found at the canonical id.
|
||||
row = await db_session.get(CompanyGoalsTable, SINGLETON_ID)
|
||||
assert row is not None
|
||||
@@ -0,0 +1,91 @@
|
||||
"""roboco.services.github_provisioning — repo creation against MockTransport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.services.github_provisioning import (
|
||||
GitHubProvisioningService,
|
||||
ProvisioningDisabledError,
|
||||
ProvisioningError,
|
||||
)
|
||||
|
||||
Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
def _client(handler: Handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_when_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
svc = GitHubProvisioningService(
|
||||
token="", org="", client=_client(lambda _r: httpx.Response(201))
|
||||
)
|
||||
assert svc.enabled is False
|
||||
with pytest.raises(ProvisioningDisabledError):
|
||||
await svc.create_repo("x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_when_master_switch_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", False)
|
||||
svc = GitHubProvisioningService(
|
||||
token="tok", org="acme", client=_client(lambda _r: httpx.Response(201))
|
||||
)
|
||||
assert svc.enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_repo_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/orgs/acme/repos"
|
||||
body = json.loads(request.content)
|
||||
assert body["name"] == "newrepo"
|
||||
assert body["auto_init"] is True
|
||||
assert body["private"] is True
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"full_name": "acme/newrepo",
|
||||
"clone_url": "https://github.com/acme/newrepo.git",
|
||||
"html_url": "https://github.com/acme/newrepo",
|
||||
},
|
||||
)
|
||||
|
||||
svc = GitHubProvisioningService(token="tok", org="acme", client=_client(handler))
|
||||
assert svc.enabled is True
|
||||
repo = await svc.create_repo("newrepo", "desc")
|
||||
assert repo.full_name == "acme/newrepo"
|
||||
assert repo.clone_url.endswith("newrepo.git")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_repo_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
svc = GitHubProvisioningService(
|
||||
token="tok",
|
||||
org="acme",
|
||||
client=_client(lambda _r: httpx.Response(422, text="name exists")),
|
||||
)
|
||||
with pytest.raises(ProvisioningError):
|
||||
await svc.create_repo("dup")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
svc = GitHubProvisioningService(token="tok", org="acme", client=_client(handler))
|
||||
with pytest.raises(ProvisioningError):
|
||||
await svc.create_repo("x")
|
||||
@@ -0,0 +1,217 @@
|
||||
"""roboco.services.pitch — CRUD + approve/reject orchestration (mocked deps).
|
||||
|
||||
The approve path constructs real domain models (ProjectCreate, ProductCellMapping,
|
||||
TaskCreateRequest) but the downstream services and the GitHub provisioner are
|
||||
faked, so the test exercises the orchestration logic without a DB or network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import PitchTable
|
||||
from roboco.foundation.identity import Team
|
||||
from roboco.models.pitch import PitchCreate, PitchStatus
|
||||
from roboco.services import pitch as pitch_module
|
||||
from roboco.services.base import ConflictError
|
||||
from roboco.services.github_provisioning import (
|
||||
GitHubProvisioningService,
|
||||
ProvisionedRepo,
|
||||
ProvisioningDisabledError,
|
||||
)
|
||||
from roboco.services.pitch import PitchService
|
||||
|
||||
|
||||
def _session() -> MagicMock:
|
||||
s = MagicMock()
|
||||
s.add = MagicMock()
|
||||
s.flush = AsyncMock()
|
||||
return s
|
||||
|
||||
|
||||
def _pitch(**kw: Any) -> PitchTable:
|
||||
defaults: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"title": "Widget",
|
||||
"slug": "widget",
|
||||
"problem": "people need widgets",
|
||||
"proposed_solution": "build a widget service",
|
||||
"target_cells": ["backend"],
|
||||
"status": "proposed",
|
||||
"created_by": uuid4(),
|
||||
}
|
||||
defaults.update(kw)
|
||||
return PitchTable(**defaults)
|
||||
|
||||
|
||||
class _FakeProvisioning(GitHubProvisioningService):
|
||||
def __init__(self, *, enabled: bool = True) -> None:
|
||||
self._enabled = enabled
|
||||
self.created: list[str] = []
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
async def create_repo(
|
||||
self, name: str, description: str = "", *, private: bool = True
|
||||
) -> ProvisionedRepo:
|
||||
_ = (description, private)
|
||||
self.created.append(name)
|
||||
return ProvisionedRepo(
|
||||
full_name=f"org/{name}",
|
||||
clone_url=f"https://github.com/org/{name}.git",
|
||||
html_url=f"https://github.com/org/{name}",
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _patch_topology(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
|
||||
proj = MagicMock()
|
||||
proj.id = uuid4()
|
||||
project_svc = MagicMock()
|
||||
project_svc.create = AsyncMock(return_value=proj)
|
||||
monkeypatch.setattr(pitch_module, "get_project_service", lambda _s: project_svc)
|
||||
|
||||
prod = MagicMock()
|
||||
prod.id = uuid4()
|
||||
product_svc = MagicMock()
|
||||
product_svc.create = AsyncMock(return_value=prod)
|
||||
monkeypatch.setattr(pitch_module, "get_product_service", lambda _s: product_svc)
|
||||
|
||||
task = MagicMock()
|
||||
task.id = uuid4()
|
||||
task_svc = MagicMock()
|
||||
task_svc.create = AsyncMock(return_value=task)
|
||||
monkeypatch.setattr(pitch_module, "get_task_service", lambda _s: task_svc)
|
||||
|
||||
main_pm = MagicMock()
|
||||
main_pm.id = uuid4()
|
||||
agent_svc = MagicMock()
|
||||
agent_svc.get_by_slug = AsyncMock(return_value=main_pm)
|
||||
monkeypatch.setattr(pitch_module, "get_agent_service", lambda _s: agent_svc)
|
||||
|
||||
return {"project": project_svc, "product": product_svc, "task": task_svc}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_persists(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = _session()
|
||||
svc = PitchService(session)
|
||||
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=None))
|
||||
pitch = await svc.create(
|
||||
PitchCreate(
|
||||
title="Widget",
|
||||
slug="widget",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=[Team.BACKEND, Team.FRONTEND],
|
||||
),
|
||||
created_by=uuid4(),
|
||||
)
|
||||
assert pitch.slug == "widget"
|
||||
assert pitch.status == PitchStatus.PROPOSED.value
|
||||
assert pitch.target_cells == ["backend", "frontend"]
|
||||
session.add.assert_called_once()
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_conflict_on_duplicate_slug(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = PitchService(_session())
|
||||
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=_pitch()))
|
||||
with pytest.raises(ConflictError):
|
||||
await svc.create(
|
||||
PitchCreate(
|
||||
title="Widget",
|
||||
slug="widget",
|
||||
problem="p",
|
||||
proposed_solution="s",
|
||||
target_cells=[Team.BACKEND],
|
||||
),
|
||||
created_by=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_sets_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
svc = PitchService(_session())
|
||||
pitch = _pitch()
|
||||
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
|
||||
result = await svc.reject(pitch.id, "not aligned with the charter", uuid4())
|
||||
assert result.status == PitchStatus.REJECTED.value
|
||||
assert result.decision_notes == "not aligned with the charter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_single_cell_provisions_project_and_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = PitchService(_session())
|
||||
pitch = _pitch(target_cells=["backend"])
|
||||
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
|
||||
svcs = _patch_topology(monkeypatch)
|
||||
prov = _FakeProvisioning(enabled=True)
|
||||
|
||||
result = await svc.approve(
|
||||
pitch.id, "approved for build", uuid4(), provisioning=prov
|
||||
)
|
||||
|
||||
assert result.status == PitchStatus.PROVISIONED.value
|
||||
assert result.seed_task_id is not None
|
||||
assert result.provisioned_project_ids is not None
|
||||
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
|
||||
assert result.provisioned_product_id is None
|
||||
assert prov.created == ["widget"]
|
||||
svcs["product"].create.assert_not_called()
|
||||
svcs["task"].create.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_multi_cell_creates_product(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = PitchService(_session())
|
||||
pitch = _pitch(slug="multi", target_cells=["backend", "frontend"])
|
||||
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
|
||||
svcs = _patch_topology(monkeypatch)
|
||||
prov = _FakeProvisioning(enabled=True)
|
||||
|
||||
result = await svc.approve(pitch.id, "approved", uuid4(), provisioning=prov)
|
||||
|
||||
assert result.provisioned_product_id is not None
|
||||
assert result.provisioned_project_ids is not None
|
||||
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
|
||||
assert prov.created == ["multi-backend", "multi-frontend"]
|
||||
svcs["product"].create.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_rejects_when_not_proposed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = PitchService(_session())
|
||||
monkeypatch.setattr(
|
||||
svc, "get", AsyncMock(return_value=_pitch(status="provisioned"))
|
||||
)
|
||||
with pytest.raises(ConflictError):
|
||||
await svc.approve(uuid4(), "x", uuid4(), provisioning=_FakeProvisioning())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_blocked_when_provisioning_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = PitchService(_session())
|
||||
monkeypatch.setattr(svc, "get", AsyncMock(return_value=_pitch()))
|
||||
with pytest.raises(ProvisioningDisabledError):
|
||||
await svc.approve(
|
||||
uuid4(), "x", uuid4(), provisioning=_FakeProvisioning(enabled=False)
|
||||
)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""roboco.services.research — provider adapters + service coverage.
|
||||
|
||||
Provider HTTP is exercised against ``httpx.MockTransport`` (no network, no
|
||||
extra dependency); the service-level tests use a recording fake to assert the
|
||||
result/byte clamps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.services.research import (
|
||||
BraveProvider,
|
||||
ExaProvider,
|
||||
FetchOutcome,
|
||||
NullProvider,
|
||||
ResearchError,
|
||||
ResearchService,
|
||||
ResearchUnsupportedError,
|
||||
SearchOutcome,
|
||||
SearchProvider,
|
||||
TavilyProvider,
|
||||
build_provider,
|
||||
get_research_service,
|
||||
)
|
||||
|
||||
Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
_QUERY = "agentic frameworks"
|
||||
_N_RESULTS = 3
|
||||
_TOP_SCORE = 0.9
|
||||
_TRUNC_CAP = 10
|
||||
_RESULTS_CAP = 5
|
||||
_REQ_RESULTS = 2
|
||||
_FETCH_CAP = 20
|
||||
|
||||
|
||||
def _client(handler: Handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tavily
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_search_parses_results_and_answer() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.host == "api.tavily.com"
|
||||
body = json.loads(request.content)
|
||||
assert body["query"] == _QUERY
|
||||
assert body["max_results"] == _N_RESULTS
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"query": _QUERY,
|
||||
"answer": "Several exist.",
|
||||
"results": [
|
||||
{
|
||||
"title": "A",
|
||||
"url": "https://a.test",
|
||||
"content": "sa",
|
||||
"score": 0.9,
|
||||
},
|
||||
{
|
||||
"title": "B",
|
||||
"url": "https://b.test",
|
||||
"content": "sb",
|
||||
"score": 0.5,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
client = _client(handler)
|
||||
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||||
out = await provider.search(_QUERY, _N_RESULTS)
|
||||
assert out.provider == "tavily"
|
||||
assert out.answer == "Several exist."
|
||||
assert [h.url for h in out.hits] == ["https://a.test", "https://b.test"]
|
||||
assert out.hits[0].score == _TOP_SCORE
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_fetch_extracts_raw_content() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/extract"
|
||||
return httpx.Response(
|
||||
200, json={"results": [{"url": "https://a.test", "raw_content": "hello"}]}
|
||||
)
|
||||
|
||||
client = _client(handler)
|
||||
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||||
out = await provider.fetch("https://a.test", 1000)
|
||||
assert out.content == "hello"
|
||||
assert out.truncated is False
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_fetch_truncates_to_cap() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200, json={"results": [{"url": "u", "raw_content": "x" * 100}]}
|
||||
)
|
||||
|
||||
client = _client(handler)
|
||||
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||||
out = await provider.fetch("u", _TRUNC_CAP)
|
||||
assert len(out.content) == _TRUNC_CAP
|
||||
assert out.truncated is True
|
||||
await client.aclose()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Brave
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brave_search_parses_web_results() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.host == "api.search.brave.com"
|
||||
assert request.headers["X-Subscription-Token"] == "k"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"web": {
|
||||
"results": [
|
||||
{"title": "T", "url": "https://t.test", "description": "d"}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
client = _client(handler)
|
||||
provider = BraveProvider(api_key="k", timeout=5.0, client=client)
|
||||
out = await provider.search("q", _RESULTS_CAP)
|
||||
assert out.provider == "brave"
|
||||
assert out.answer is None
|
||||
assert out.hits[0].snippet == "d"
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brave_fetch_is_unsupported() -> None:
|
||||
provider = BraveProvider(
|
||||
api_key="k", timeout=5.0, client=_client(lambda _r: httpx.Response(200))
|
||||
)
|
||||
with pytest.raises(ResearchUnsupportedError):
|
||||
await provider.fetch("https://x.test", 100)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Exa
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exa_search_and_fetch() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/search":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"results": [{"title": "E", "url": "https://e.test", "text": "snip"}]
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200, json={"results": [{"url": "https://e.test", "text": "full"}]}
|
||||
)
|
||||
|
||||
client = _client(handler)
|
||||
provider = ExaProvider(api_key="k", timeout=5.0, client=client)
|
||||
out = await provider.search("q", _RESULTS_CAP)
|
||||
assert out.hits[0].snippet == "snip"
|
||||
fetched = await provider.fetch("https://e.test", 1000)
|
||||
assert fetched.content == "full"
|
||||
await client.aclose()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Error handling
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_raises_research_error() -> None:
|
||||
client = _client(lambda _r: httpx.Response(500, text="boom"))
|
||||
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||||
with pytest.raises(ResearchError):
|
||||
await provider.search("q", _RESULTS_CAP)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_raises_research_error() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
client = _client(handler)
|
||||
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||||
with pytest.raises(ResearchError):
|
||||
await provider.search("q", _RESULTS_CAP)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_json_raises_research_error() -> None:
|
||||
client = _client(
|
||||
lambda _r: httpx.Response(
|
||||
200, text="not json", headers={"content-type": "application/json"}
|
||||
)
|
||||
)
|
||||
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
|
||||
with pytest.raises(ResearchError):
|
||||
await provider.search("q", _RESULTS_CAP)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# NullProvider + build_provider
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_provider_degrades_gracefully() -> None:
|
||||
provider = NullProvider(api_key=None, timeout=5.0)
|
||||
assert provider.configured is False
|
||||
search = await provider.search("q", _RESULTS_CAP)
|
||||
assert search.hits == []
|
||||
assert search.provider == "null"
|
||||
fetched = await provider.fetch("u", _RESULTS_CAP)
|
||||
assert fetched.content == ""
|
||||
|
||||
|
||||
def test_build_provider_selects_by_name() -> None:
|
||||
assert isinstance(build_provider("tavily", "k", 5.0), TavilyProvider)
|
||||
assert isinstance(build_provider("brave", "k", 5.0), BraveProvider)
|
||||
assert isinstance(build_provider("exa", "k", 5.0), ExaProvider)
|
||||
assert isinstance(build_provider("null", "k", 5.0), NullProvider)
|
||||
|
||||
|
||||
def test_build_provider_null_when_no_key_or_unknown() -> None:
|
||||
assert isinstance(build_provider("tavily", None, 5.0), NullProvider)
|
||||
assert isinstance(build_provider("mystery", "k", 5.0), NullProvider)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ResearchService — clamps
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _RecordingProvider(SearchProvider):
|
||||
name = "rec"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(api_key="k", timeout=5.0)
|
||||
self.last_max_results: int | None = None
|
||||
self.last_max_chars: int | None = None
|
||||
self.fetch_content = "z" * 50
|
||||
|
||||
async def search(self, query: str, max_results: int) -> SearchOutcome:
|
||||
self.last_max_results = max_results
|
||||
return SearchOutcome(query=query, hits=[], answer=None, provider=self.name)
|
||||
|
||||
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
|
||||
self.last_max_chars = max_chars
|
||||
return FetchOutcome(
|
||||
url=url, content=self.fetch_content, truncated=False, provider=self.name
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_clamps_max_results() -> None:
|
||||
provider = _RecordingProvider()
|
||||
service = ResearchService(
|
||||
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=100
|
||||
)
|
||||
await service.search("q", 100)
|
||||
assert provider.last_max_results == _RESULTS_CAP
|
||||
await service.search("q", None)
|
||||
assert provider.last_max_results == _RESULTS_CAP
|
||||
await service.search("q", _REQ_RESULTS)
|
||||
assert provider.last_max_results == _REQ_RESULTS
|
||||
await service.search("q", 0)
|
||||
assert provider.last_max_results == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_clamps_and_truncates_fetch() -> None:
|
||||
provider = _RecordingProvider()
|
||||
provider.fetch_content = "y" * 80
|
||||
service = ResearchService(
|
||||
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=_FETCH_CAP
|
||||
)
|
||||
out = await service.fetch("u", 1000)
|
||||
assert provider.last_max_chars == _FETCH_CAP
|
||||
assert len(out.content) == _FETCH_CAP
|
||||
assert out.truncated is True
|
||||
|
||||
|
||||
def test_get_research_service_uses_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "research_provider", "null")
|
||||
monkeypatch.setattr(settings, "research_api_key", None)
|
||||
service = get_research_service()
|
||||
assert service.provider_name == "null"
|
||||
assert service.configured is False
|
||||
@@ -0,0 +1,94 @@
|
||||
"""roboco.services.research_quota — per-agent daily quota (mocked Redis)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from roboco.services import research_quota
|
||||
from roboco.services.research_quota import ResearchQuotaTracker
|
||||
|
||||
_EXPIRY_SECONDS = 86400
|
||||
_OVER_LIMIT_USED = 3
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.store: dict[str, int] = {}
|
||||
self.expires: dict[str, int] = {}
|
||||
|
||||
async def incr(self, key: str) -> int:
|
||||
self.store[key] = self.store.get(key, 0) + 1
|
||||
return self.store[key]
|
||||
|
||||
async def expire(self, key: str, ttl: int) -> None:
|
||||
self.expires[key] = ttl
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _BrokenRedis:
|
||||
async def incr(self, _key: str) -> int:
|
||||
raise ConnectionError("redis down")
|
||||
|
||||
|
||||
def _patch_redis(monkeypatch: pytest.MonkeyPatch, fake: Any) -> None:
|
||||
monkeypatch.setattr(research_quota.redis, "from_url", lambda _url: fake)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_call_increments_and_sets_expiry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake = _FakeRedis()
|
||||
_patch_redis(monkeypatch, fake)
|
||||
tracker = ResearchQuotaTracker(redis_url="redis://x")
|
||||
now = datetime(2026, 6, 15, tzinfo=UTC)
|
||||
result = await tracker.check_and_consume("agent-1", 50, now=now)
|
||||
assert result.allowed is True
|
||||
assert result.used == 1
|
||||
assert result.day == "2026-06-15"
|
||||
# expiry set exactly once, on the first increment
|
||||
key = "roboco:research_quota:agent-1:2026-06-15"
|
||||
assert fake.expires[key] == _EXPIRY_SECONDS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocks_when_over_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake = _FakeRedis()
|
||||
_patch_redis(monkeypatch, fake)
|
||||
tracker = ResearchQuotaTracker(redis_url="redis://x")
|
||||
now = datetime(2026, 6, 15, tzinfo=UTC)
|
||||
first = await tracker.check_and_consume("a", 2, now=now)
|
||||
second = await tracker.check_and_consume("a", 2, now=now)
|
||||
third = await tracker.check_and_consume("a", 2, now=now)
|
||||
assert first.allowed is True
|
||||
assert second.allowed is True
|
||||
assert third.allowed is False
|
||||
assert third.used == _OVER_LIMIT_USED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_separate_counters_per_day(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake = _FakeRedis()
|
||||
_patch_redis(monkeypatch, fake)
|
||||
tracker = ResearchQuotaTracker(redis_url="redis://x")
|
||||
d1 = await tracker.check_and_consume("a", 50, now=datetime(2026, 6, 15, tzinfo=UTC))
|
||||
d2 = await tracker.check_and_consume("a", 50, now=datetime(2026, 6, 16, tzinfo=UTC))
|
||||
assert d1.used == 1
|
||||
assert d2.used == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fails_open_when_redis_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_redis(monkeypatch, _BrokenRedis())
|
||||
tracker = ResearchQuotaTracker(redis_url="redis://x")
|
||||
result = await tracker.check_and_consume(
|
||||
"a", 50, now=datetime(2026, 6, 15, tzinfo=UTC)
|
||||
)
|
||||
assert result.allowed is True
|
||||
assert result.used == 0
|
||||
@@ -0,0 +1,173 @@
|
||||
"""roboco.services.secretary — directive gate + execution (mocked deps)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import SecretaryDirectiveTable
|
||||
from roboco.models.secretary import DirectiveKind, DirectiveStatus
|
||||
from roboco.services import secretary as sec_module
|
||||
from roboco.services.base import ValidationError
|
||||
from roboco.services.secretary import SecretaryService
|
||||
|
||||
|
||||
def _session() -> MagicMock:
|
||||
s = MagicMock()
|
||||
s.add = MagicMock()
|
||||
s.flush = AsyncMock()
|
||||
return s
|
||||
|
||||
|
||||
def _patch(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
|
||||
msg = MagicMock()
|
||||
msg.post_to_channel = AsyncMock()
|
||||
monkeypatch.setattr(sec_module, "get_messaging_service", lambda _s: msg)
|
||||
goals = MagicMock()
|
||||
goals.upsert = AsyncMock()
|
||||
monkeypatch.setattr(sec_module, "get_company_goals_service", lambda _s: goals)
|
||||
pitch = MagicMock()
|
||||
pitch.approve = AsyncMock()
|
||||
monkeypatch.setattr(sec_module, "get_pitch_service", lambda _s: pitch)
|
||||
task = MagicMock()
|
||||
task.approve_and_start = AsyncMock()
|
||||
task.admin_set_status = AsyncMock()
|
||||
monkeypatch.setattr(sec_module, "get_task_service", lambda _s: task)
|
||||
notifier = MagicMock()
|
||||
notifier.send_ack_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.notification.NotificationService", lambda: notifier
|
||||
)
|
||||
return {
|
||||
"msg": msg,
|
||||
"goals": goals,
|
||||
"pitch": pitch,
|
||||
"task": task,
|
||||
"notifier": notifier,
|
||||
}
|
||||
|
||||
|
||||
def _pending(kind: DirectiveKind, payload: dict[str, Any]) -> SecretaryDirectiveTable:
|
||||
return SecretaryDirectiveTable(
|
||||
id=uuid4(),
|
||||
kind=kind.value,
|
||||
payload=payload,
|
||||
status=DirectiveStatus.PENDING.value,
|
||||
requested_by=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relay_executes_directly(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
svcs = _patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = await svc.submit_directive(
|
||||
DirectiveKind.RELAY_MESSAGE,
|
||||
{"channel": "all-hands", "text": "standup at 10"},
|
||||
uuid4(),
|
||||
)
|
||||
assert row.status == DirectiveStatus.EXECUTED.value
|
||||
svcs["msg"].post_to_channel.assert_awaited_once()
|
||||
svcs["notifier"].send_ack_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gated_charter_queues_and_notifies(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svcs = _patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = await svc.submit_directive(
|
||||
DirectiveKind.UPDATE_CHARTER, {"charter": {"north_star": "Win"}}, uuid4()
|
||||
)
|
||||
assert row.status == DirectiveStatus.PENDING.value
|
||||
svcs["goals"].upsert.assert_not_awaited()
|
||||
svcs["notifier"].send_ack_notification.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_charter_executes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
svcs = _patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = _pending(DirectiveKind.UPDATE_CHARTER, {"charter": {"north_star": "Win"}})
|
||||
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
|
||||
out = await svc.confirm_directive(row.id, uuid4())
|
||||
assert out.status == DirectiveStatus.EXECUTED.value
|
||||
svcs["goals"].upsert.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_control_task_start(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
svcs = _patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = _pending(
|
||||
DirectiveKind.CONTROL_TASK, {"task_id": str(uuid4()), "action": "start"}
|
||||
)
|
||||
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
|
||||
out = await svc.confirm_directive(row.id, uuid4())
|
||||
assert out.status == DirectiveStatus.EXECUTED.value
|
||||
svcs["task"].approve_and_start.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_approve_pitch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
svcs = _patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = _pending(DirectiveKind.APPROVE_PITCH, {"pitch_id": str(uuid4())})
|
||||
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
|
||||
out = await svc.confirm_directive(row.id, uuid4())
|
||||
assert out.status == DirectiveStatus.EXECUTED.value
|
||||
svcs["pitch"].approve.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_announce_queues_then_confirm_posts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svcs = _patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = await svc.submit_directive(
|
||||
DirectiveKind.ANNOUNCE, {"text": "we shipped v1"}, uuid4()
|
||||
)
|
||||
assert row.status == DirectiveStatus.PENDING.value
|
||||
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
|
||||
out = await svc.confirm_directive(row.id, uuid4())
|
||||
assert out.status == DirectiveStatus.EXECUTED.value
|
||||
svcs["msg"].post_to_channel.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_sets_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = _pending(DirectiveKind.ANNOUNCE, {"text": "x"})
|
||||
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
|
||||
out = await svc.reject_directive(row.id, uuid4(), "not now")
|
||||
assert out.status == DirectiveStatus.REJECTED.value
|
||||
assert out.result == "not now"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_payload_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
with pytest.raises(ValidationError):
|
||||
await svc.submit_directive(
|
||||
DirectiveKind.RELAY_MESSAGE, {"channel": "x"}, uuid4()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_task_action_fails_directive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch(monkeypatch)
|
||||
svc = SecretaryService(_session())
|
||||
row = _pending(
|
||||
DirectiveKind.CONTROL_TASK, {"task_id": str(uuid4()), "action": "explode"}
|
||||
)
|
||||
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
|
||||
out = await svc.confirm_directive(row.id, uuid4())
|
||||
assert out.status == DirectiveStatus.FAILED.value
|
||||
@@ -0,0 +1,111 @@
|
||||
"""roboco.services.strategy_engine — assessment + notify (dormant by default)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from roboco.services import strategy_engine as se_module
|
||||
from roboco.services.strategy_engine import StrategyEngine
|
||||
|
||||
_GOALS_WITH_DIRECTION: dict[str, Any] = {
|
||||
"north_star": "Win the market",
|
||||
"objectives": [{"metric": "NPS", "target": 50}],
|
||||
"constraints": [],
|
||||
"operating_policy": {},
|
||||
}
|
||||
_GOALS_EMPTY: dict[str, Any] = {
|
||||
"north_star": "",
|
||||
"objectives": [],
|
||||
"constraints": [],
|
||||
"operating_policy": {},
|
||||
}
|
||||
|
||||
|
||||
def _engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
in_flight: list[Any],
|
||||
blocked: list[Any],
|
||||
goals: dict[str, Any],
|
||||
) -> StrategyEngine:
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_in_progress_or_claimed = AsyncMock(return_value=in_flight)
|
||||
task_svc.list_long_running_blocked = AsyncMock(return_value=blocked)
|
||||
monkeypatch.setattr(se_module, "get_task_service", lambda _s: task_svc)
|
||||
goals_svc = MagicMock()
|
||||
goals_svc.get = AsyncMock(return_value=goals)
|
||||
monkeypatch.setattr(se_module, "get_company_goals_service", lambda _s: goals_svc)
|
||||
return StrategyEngine(MagicMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_with_goals_observed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
|
||||
kinds = {o.kind for o in await eng.assess()}
|
||||
assert "idle" in kinds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_idle_when_work_in_flight(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
eng = _engine(
|
||||
monkeypatch, in_flight=[MagicMock()], blocked=[], goals=_GOALS_WITH_DIRECTION
|
||||
)
|
||||
assert all(o.kind != "idle" for o in await eng.assess())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_observations_when_idle_without_goals(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_EMPTY)
|
||||
assert await eng.assess() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stranded_blocked_observed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
eng = _engine(
|
||||
monkeypatch,
|
||||
in_flight=[MagicMock()],
|
||||
blocked=[MagicMock(), MagicMock()],
|
||||
goals=_GOALS_EMPTY,
|
||||
)
|
||||
assert any(o.kind == "stranded_blocked" for o in await eng.assess())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_disabled_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", False)
|
||||
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
|
||||
assert await eng.run_cycle() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_enabled_notifies_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
|
||||
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
|
||||
notifier = MagicMock()
|
||||
notifier.send_ack_notification = AsyncMock()
|
||||
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
|
||||
|
||||
observations = await eng.run_cycle()
|
||||
|
||||
assert observations
|
||||
notifier.send_ack_notification.assert_awaited()
|
||||
_, kwargs = notifier.send_ack_notification.call_args
|
||||
assert kwargs["to_agent"] == "ceo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_enabled_no_observations_no_notify(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
|
||||
eng = _engine(monkeypatch, in_flight=[MagicMock()], blocked=[], goals=_GOALS_EMPTY)
|
||||
notifier = MagicMock()
|
||||
notifier.send_ack_notification = AsyncMock()
|
||||
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
|
||||
|
||||
assert await eng.run_cycle() == []
|
||||
notifier.send_ack_notification.assert_not_awaited()
|
||||
Reference in New Issue
Block a user