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:
Renzo F
2026-06-15 20:47:41 +02:00
committed by GitHub
co-authored by Renn F
parent 9f668b2a50
commit 46d89b58fe
92 changed files with 7249 additions and 5 deletions
+2
View File
@@ -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}"
+1
View File
@@ -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
+179
View File
@@ -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()
+150
View File
@@ -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"
+161
View File
@@ -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:
+39
View File
@@ -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"])
+123
View File
@@ -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")
+217
View File
@@ -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)
)
+314
View File
@@ -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
+111
View File
@@ -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()