mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(marketing): HoM feature-spotlight X drafts + brand-voice charter (v0.18.0 B)
The Head of Marketing now markets features, not just releases: a default-off x_feature_spotlight loop periodically spawns the HoM to investigate what shipped (CHANGELOG, feature flags, docs/map, KB) and draft ONE held marketing post via propose_feature_spotlight, reviewed in the X post queue. - New x_feature source (distinct from x_post, fixing panel mislabeling) + a panel Feature-spotlight branch. - brand_voice column on company_goals (migration 061, single head) as the CEO-editable voice source, surfaced in Settings and injected into the HoM briefing; a VOICE GUIDE baseline in head-marketing.md. - propose_feature_spotlight verb (HoM-only), mirroring propose_roadmap. Gated by x_feature_spotlight_enabled (default off; flag-off dormancy proven). Also fixed two real bugs found mid-build: company_goals API schemas dropped brand_voice on GET/PUT; the live charter UI is goals-tab.tsx, not the unmounted company-goals-card.tsx. Full suite green (2935); migration single-head verified.
This commit is contained in:
@@ -38,3 +38,11 @@ def test_x_engine_flag_registered_in_feature_flags() -> None:
|
||||
|
||||
def test_x_engine_flag_validates_as_bool() -> None:
|
||||
validate_setting("x_engine_enabled", "true")
|
||||
|
||||
|
||||
def test_x_feature_spotlight_disabled_by_default() -> None:
|
||||
assert Settings().x_feature_spotlight_enabled is False
|
||||
|
||||
|
||||
def test_x_feature_spotlight_flag_registered_in_feature_flags() -> None:
|
||||
assert "x_feature_spotlight_enabled" in [key for key, _ in FEATURE_FLAGS]
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""roboco.services.gateway.content_actions.propose_feature_spotlight — HoM-gated
|
||||
feature-spotlight draft authoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
||||
|
||||
|
||||
class _FakeTask:
|
||||
"""Minimal stand-in for the ORM TaskTable row — carries just what
|
||||
``propose_feature_spotlight`` touches."""
|
||||
|
||||
def __init__(self, *, assigned_to: Any, task_id: Any = None) -> None:
|
||||
self.id = task_id or uuid4()
|
||||
self.assigned_to = assigned_to
|
||||
self.project_id = uuid4()
|
||||
|
||||
|
||||
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(),
|
||||
a2a=MagicMock(),
|
||||
journal=MagicMock(),
|
||||
workspace=MagicMock(),
|
||||
notifications=MagicMock(),
|
||||
)
|
||||
return ContentActions(deps)
|
||||
|
||||
|
||||
def _valid_kwargs(**overrides: Any) -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"feature_slug": "org-memory",
|
||||
"feature_title": "Organizational Memory Loop",
|
||||
"body": "Did you know RoboCo agents learn from every completed task?",
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_forbidden_for_product_owner() -> None:
|
||||
env = await _actions("product_owner").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs()
|
||||
)
|
||||
assert env.error == "not_authorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_forbidden_for_developer() -> None:
|
||||
env = await _actions("developer").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs()
|
||||
)
|
||||
assert env.error == "not_authorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_rejects_short_slug() -> None:
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs(feature_slug="a")
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_rejects_short_title() -> None:
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs(feature_title="ab")
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_rejects_short_body() -> None:
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs(body="short")
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_rejects_over_280_chars() -> None:
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs(body="z" * 281)
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_no_open_exploration_is_invalid_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_feature_explorations = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs()
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_ignores_exploration_assigned_to_another_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
other_agent = uuid4()
|
||||
exploration = _FakeTask(assigned_to=other_agent)
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), **_valid_kwargs()
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_rejects_already_seen_feature(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_id = uuid4()
|
||||
exploration = _FakeTask(assigned_to=agent_id)
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.is_feature_seen = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: engine)
|
||||
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=agent_id, **_valid_kwargs()
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
engine.materialize_feature_spotlight.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_materializes_new_draft_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Happy path — deliberately asymmetric vs. propose_roadmap: the returned
|
||||
task_id is the NEW materialized draft's id, never the exploration task's."""
|
||||
agent_id = uuid4()
|
||||
exploration = _FakeTask(assigned_to=agent_id)
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
|
||||
materialized = _FakeTask(assigned_to=agent_id)
|
||||
assert materialized.id != exploration.id
|
||||
engine = MagicMock()
|
||||
engine.is_feature_seen = AsyncMock(return_value=False)
|
||||
engine.materialize_feature_spotlight = AsyncMock(return_value=materialized)
|
||||
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: engine)
|
||||
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=agent_id, **_valid_kwargs()
|
||||
)
|
||||
assert env.error is None
|
||||
assert env.status == "feature_spotlight_proposed"
|
||||
assert env.task_id == str(materialized.id)
|
||||
assert env.task_id != str(exploration.id)
|
||||
engine.materialize_feature_spotlight.assert_awaited_once_with(
|
||||
exploration_task=exploration,
|
||||
feature_slug="org-memory",
|
||||
feature_title="Organizational Memory Loop",
|
||||
body="Did you know RoboCo agents learn from every completed task?",
|
||||
)
|
||||
@@ -55,7 +55,11 @@ async def test_company_goals_none_when_no_row() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_goals_none_when_empty_charter() -> None:
|
||||
row = SimpleNamespace(
|
||||
north_star="", objectives=[], constraints=[], operating_policy={}
|
||||
north_star="",
|
||||
objectives=[],
|
||||
constraints=[],
|
||||
operating_policy={},
|
||||
brand_voice="",
|
||||
)
|
||||
assert await _repo_with_goals_row(row).company_goals() is None
|
||||
|
||||
@@ -67,6 +71,7 @@ async def test_company_goals_compact_dict_when_set() -> None:
|
||||
objectives=[{"metric": "NPS", "target": 50}],
|
||||
constraints=["AGPL"],
|
||||
operating_policy={"autonomy_level": "assisted"},
|
||||
brand_voice="Confident, dry wit.",
|
||||
)
|
||||
goals = await _repo_with_goals_row(row).company_goals()
|
||||
assert goals == {
|
||||
@@ -74,9 +79,42 @@ async def test_company_goals_compact_dict_when_set() -> None:
|
||||
"objectives": [{"metric": "NPS", "target": 50}],
|
||||
"constraints": ["AGPL"],
|
||||
"operating_policy": {"autonomy_level": "assisted"},
|
||||
"brand_voice": "Confident, dry wit.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_goals_none_when_only_brand_voice_unset() -> None:
|
||||
"""A charter with substantive content but no brand_voice must still
|
||||
surface — brand_voice is additive, not required."""
|
||||
row = SimpleNamespace(
|
||||
north_star="Win the market",
|
||||
objectives=[],
|
||||
constraints=[],
|
||||
operating_policy={},
|
||||
brand_voice="",
|
||||
)
|
||||
goals = await _repo_with_goals_row(row).company_goals()
|
||||
assert goals is not None
|
||||
assert goals["brand_voice"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_goals_surfaces_when_only_brand_voice_set() -> None:
|
||||
"""The inverse: brand_voice alone (everything else empty) must still
|
||||
surface — it counts toward the "charter has content" check."""
|
||||
row = SimpleNamespace(
|
||||
north_star="",
|
||||
objectives=[],
|
||||
constraints=[],
|
||||
operating_policy={},
|
||||
brand_voice="Speak as 'we'.",
|
||||
)
|
||||
goals = await _repo_with_goals_row(row).company_goals()
|
||||
assert goals is not None
|
||||
assert goals["brand_voice"] == "Speak as 'we'."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constructor_stores_db_session() -> None:
|
||||
fake_db = MagicMock()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""propose_feature_spotlight is a Head-of-Marketing-only manifest grant
|
||||
(mirrors propose_roadmap's PO-only symmetry, reversed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.services.gateway.role_config import get_role_config
|
||||
|
||||
|
||||
def test_head_marketing_gets_propose_feature_spotlight() -> None:
|
||||
assert "propose_feature_spotlight" in get_role_config("head_marketing").do_tools
|
||||
|
||||
|
||||
def test_product_owner_does_not_get_propose_feature_spotlight() -> None:
|
||||
assert "propose_feature_spotlight" not in get_role_config("product_owner").do_tools
|
||||
|
||||
|
||||
def test_developer_does_not_get_propose_feature_spotlight() -> None:
|
||||
assert "propose_feature_spotlight" not in get_role_config("developer").do_tools
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Feature-spotlight exploration dispatch — Head-of-Marketing-solo, never the
|
||||
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
from roboco.services.task import X_FEATURE_EXPLORATION_SOURCE
|
||||
|
||||
|
||||
def _make_orch() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
orch._instances = {}
|
||||
orch._board_dispatched = set()
|
||||
return orch
|
||||
|
||||
|
||||
def _feature_task(
|
||||
*, orchestration_markers: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(uuid4()),
|
||||
"status": "pending",
|
||||
"team": "board",
|
||||
"title": "X feature-spotlight exploration",
|
||||
"description": "Investigate shipped capabilities and propose a spotlight.",
|
||||
"assigned_to": "head-marketing",
|
||||
"source": X_FEATURE_EXPLORATION_SOURCE,
|
||||
"orchestration_markers": orchestration_markers,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_dispatch_spawns_only_head_marketing() -> None:
|
||||
"""A feature-spotlight exploration task must spawn the Head of Marketing
|
||||
alone — the Product Owner is not part of this cycle."""
|
||||
orch = _make_orch()
|
||||
task = _feature_task()
|
||||
with (
|
||||
patch.object(orch, "_is_agent_active", return_value=False),
|
||||
patch.object(orch, "_task_git_context", return_value=None),
|
||||
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
||||
):
|
||||
await orch._dispatch_feature_spotlight_exploration(task)
|
||||
|
||||
spawn.assert_awaited_once()
|
||||
calls = list(spawn.await_args_list)
|
||||
assert calls[0].kwargs["agent_id"] == "head-marketing"
|
||||
assert calls[0].kwargs["task_id"] == task["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_dispatch_is_one_shot() -> None:
|
||||
"""Re-ticking a still-pending exploration must NOT respawn — board roles
|
||||
have no progression verb, so a respawn would just loop."""
|
||||
orch = _make_orch()
|
||||
task = _feature_task()
|
||||
with (
|
||||
patch.object(orch, "_is_agent_active", return_value=False),
|
||||
patch.object(orch, "_task_git_context", return_value=None),
|
||||
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
||||
):
|
||||
await orch._dispatch_feature_spotlight_exploration(task)
|
||||
await orch._dispatch_feature_spotlight_exploration(task)
|
||||
|
||||
spawn.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_dispatch_skips_active_hom() -> None:
|
||||
orch = _make_orch()
|
||||
task = _feature_task()
|
||||
with (
|
||||
patch.object(orch, "_is_agent_active", return_value=True),
|
||||
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
||||
):
|
||||
await orch._dispatch_feature_spotlight_exploration(task)
|
||||
|
||||
spawn.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_pm_work_routes_feature_source_away_from_board() -> None:
|
||||
"""An x_feature_exploration task must ride the dedicated feature-spotlight
|
||||
dispatcher, never the two-reviewer ``_handle_board_assigned_task`` (which
|
||||
would also spawn the Product Owner and fire the Approve & Start handoff —
|
||||
both wrong here), nor the roadmap dispatcher, nor plain PM handling."""
|
||||
task = _feature_task()
|
||||
stub = MagicMock()
|
||||
stub._fetch_tasks = AsyncMock(return_value=[task])
|
||||
stub._is_task_handled_this_tick = MagicMock(return_value=False)
|
||||
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
|
||||
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
|
||||
stub._dispatch_roadmap_exploration = AsyncMock()
|
||||
stub._dispatch_feature_spotlight_exploration = AsyncMock()
|
||||
stub._handle_board_assigned_task = AsyncMock()
|
||||
stub._handle_pm_assigned_task = AsyncMock()
|
||||
stub._route_unassigned_pm_task = AsyncMock()
|
||||
|
||||
client: Any = MagicMock()
|
||||
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
|
||||
|
||||
stub._dispatch_feature_spotlight_exploration.assert_awaited_once()
|
||||
stub._dispatch_roadmap_exploration.assert_not_awaited()
|
||||
stub._handle_board_assigned_task.assert_not_awaited()
|
||||
stub._handle_pm_assigned_task.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_tasks_are_never_routed_by_dev_dispatch() -> None:
|
||||
tasks = [_feature_task()]
|
||||
stub = MagicMock()
|
||||
stub._fetch_tasks = AsyncMock(return_value=tasks)
|
||||
stub._is_task_handled_this_tick = MagicMock(return_value=False)
|
||||
stub._dev_dispatch_one = AsyncMock()
|
||||
|
||||
client: Any = MagicMock()
|
||||
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
|
||||
|
||||
stub._dev_dispatch_one.assert_not_awaited()
|
||||
|
||||
|
||||
def test_feature_spotlight_prompt_names_real_verbs_and_seen_features() -> None:
|
||||
"""The prompt must steer HoM to its real verbs (triage /
|
||||
propose_feature_spotlight / i_am_idle) and render the seen-features marker,
|
||||
with a friendly fallback when the list is empty."""
|
||||
orch = _make_orch()
|
||||
prompt = orch._build_feature_spotlight_prompt(
|
||||
_feature_task(orchestration_markers={"x_seen_features": ["org-memory"]})
|
||||
)
|
||||
assert "triage()" in prompt
|
||||
assert "propose_feature_spotlight(" in prompt
|
||||
assert "i_am_idle()" in prompt
|
||||
assert "org-memory" in prompt
|
||||
|
||||
empty_prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
||||
assert "none yet" in empty_prompt.lower()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""The feature-spotlight orchestrator loop is fully dormant unless BOTH the X
|
||||
engine and the feature-spotlight sub-switch are enabled (both default off).
|
||||
|
||||
With either flag off, ``_x_feature_spotlight_loop`` must return immediately —
|
||||
no sleep, no HTTP, no DB, no Head-of-Marketing spawn — so a standard
|
||||
deployment (or one running only release posts / mention replies) behaves
|
||||
exactly as today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_x_feature_spotlight_loop_returns_immediately_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", False)
|
||||
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
# Gated off -> returns at once. If the gate were missing it would sleep the
|
||||
# full interval and this wait_for would time out.
|
||||
await asyncio.wait_for(
|
||||
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_x_feature_spotlight_loop_dormant_when_only_subswitch_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""x_engine_enabled on but the feature-spotlight sub-switch off: still
|
||||
dormant — the engine still runs release posts/mention replies via their
|
||||
own loops, unaffected."""
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", True)
|
||||
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
await asyncio.wait_for(
|
||||
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_x_feature_spotlight_loop_dormant_when_only_engine_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The subswitch alone is not enough — x_engine_enabled must also be on."""
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", False)
|
||||
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", True)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
await asyncio.wait_for(
|
||||
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
|
||||
)
|
||||
@@ -53,6 +53,7 @@ def _make_orchestrator() -> AgentOrchestrator:
|
||||
"_release_manager_task",
|
||||
"_x_mentions_task",
|
||||
"_roadmap_engine_task",
|
||||
"_x_feature_spotlight_task",
|
||||
):
|
||||
setattr(orch, attr, None)
|
||||
return orch
|
||||
|
||||
@@ -28,6 +28,7 @@ async def test_get_returns_empty_defaults_when_unset(db_session: Any) -> None:
|
||||
assert goals["objectives"] == []
|
||||
assert goals["constraints"] == []
|
||||
assert goals["operating_policy"] == {}
|
||||
assert goals["brand_voice"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -68,3 +69,23 @@ async def test_upsert_is_singleton_and_partial(db_session: Any) -> None:
|
||||
# Exactly one row exists (singleton), found at the canonical id.
|
||||
row = await db_session.get(CompanyGoalsTable, SINGLETON_ID)
|
||||
assert row is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_voice_roundtrips(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"brand_voice": "Confident, dry wit, no exclamation points."})
|
||||
goals = await svc.get()
|
||||
assert goals["brand_voice"] == "Confident, dry wit, no exclamation points."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_voice_untouched_by_partial_upsert(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"brand_voice": "Speak as 'we'."})
|
||||
# A later partial upsert that omits brand_voice must leave it unchanged —
|
||||
# the same partial-update contract north_star/constraints already have.
|
||||
await svc.upsert({"north_star": "Ship a delightful product"})
|
||||
goals = await svc.get()
|
||||
assert goals["brand_voice"] == "Speak as 'we'."
|
||||
assert goals["north_star"] == "Ship a delightful product"
|
||||
|
||||
@@ -13,13 +13,20 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.db.tables import AgentTable, ProjectTable, XSeenFeatureTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import AgentRole, AgentStatus, Team
|
||||
from roboco.models.base import TaskStatus as TS
|
||||
from roboco.services import x_engine as x_engine_module
|
||||
from roboco.services.task import X_POST_SOURCE, X_REPLY_SOURCE, get_task_service
|
||||
from roboco.services.company_goals import get_company_goals_service
|
||||
from roboco.services.task import (
|
||||
X_FEATURE_EXPLORATION_SOURCE,
|
||||
X_FEATURE_SOURCE,
|
||||
X_POST_SOURCE,
|
||||
X_REPLY_SOURCE,
|
||||
get_task_service,
|
||||
)
|
||||
from roboco.services.x_client import MAX_TWEET_CHARS, XClient, XMention, XPostResult
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -28,6 +35,7 @@ if TYPE_CHECKING:
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
|
||||
HOM_UUID = _foundation.AGENTS["head-marketing"].uuid
|
||||
SLUG = "roboco"
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
@@ -73,9 +81,10 @@ class _NullClient(XClient):
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
for uuid, slug, role in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM),
|
||||
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY),
|
||||
for uuid, slug, role, team in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
|
||||
(HOM_UUID, "head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD),
|
||||
):
|
||||
if await session.get(AgentTable, uuid) is None:
|
||||
session.add(
|
||||
@@ -84,7 +93,7 @@ async def _seed(session: AsyncSession) -> None:
|
||||
name=slug,
|
||||
slug=slug,
|
||||
role=role,
|
||||
team=None,
|
||||
team=team,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
@@ -438,3 +447,219 @@ async def test_engine_never_calls_post_tweet(
|
||||
await engine.run_cycle()
|
||||
await engine.draft_release_post(version=_VERSION, highlights=[])
|
||||
assert client.posted == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Feature-spotlight exploration (Head of Marketing)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_disabled_creates_no_exploration(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", False)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_subswitch_off_creates_no_exploration(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""x_engine_enabled on but x_feature_spotlight_enabled off: no exploration —
|
||||
the engine still drafts release posts/mention replies via the other paths."""
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=False)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_no_credentials_creates_no_exploration(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_NullClient())
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_dedupe_one_open_cycle(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
first = await engine.open_feature_spotlight_exploration()
|
||||
second = await engine.open_feature_spotlight_exploration()
|
||||
assert first is not None
|
||||
assert second is None
|
||||
open_cycles = await get_task_service(db_session).list_open_feature_explorations()
|
||||
assert len(open_cycles) == ONE
|
||||
cycle = open_cycles[0]
|
||||
assert cycle.status == TS.PENDING
|
||||
assert cycle.confirmed_by_human is False # HELD; board-dispatched only
|
||||
assert cycle.assigned_to == HOM_UUID
|
||||
assert cycle.team == Team.BOARD
|
||||
assert cycle.source == X_FEATURE_EXPLORATION_SOURCE
|
||||
assert "spotlight" in cycle.title.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_respects_open_post_cap(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True, x_max_open_posts=1)
|
||||
_mock_local_model(monkeypatch, "shipped!")
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
# Fill the shared open-post cap with an unrelated release draft first.
|
||||
await engine.draft_release_post(version="1.0.0", highlights=[])
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_unresolvable_project_no_cycle(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project")
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_exploration_carries_seen_features_marker(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
db_session.add(XSeenFeatureTable(feature_slug="old-feature-1"))
|
||||
db_session.add(XSeenFeatureTable(feature_slug="old-feature-2"))
|
||||
await db_session.flush()
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is not None
|
||||
assert set(markers.get_x_seen_features(task)) == {
|
||||
"old-feature-1",
|
||||
"old-feature-2",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materialize_feature_spotlight_holds_draft_and_completes_exploration(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
exploration = await engine.open_feature_spotlight_exploration()
|
||||
assert exploration is not None
|
||||
|
||||
draft = await engine.materialize_feature_spotlight(
|
||||
exploration_task=exploration,
|
||||
feature_slug="org-memory",
|
||||
feature_title="Organizational Memory Loop",
|
||||
body="Did you know RoboCo agents learn from every completed task?",
|
||||
)
|
||||
|
||||
assert draft.source == X_FEATURE_SOURCE
|
||||
assert draft.assigned_to == SECRETARY_UUID
|
||||
assert draft.confirmed_by_human is False
|
||||
assert draft.status == TS.PENDING
|
||||
ref = markers.get_x_feature_ref(draft)
|
||||
assert ref is not None
|
||||
assert ref["slug"] == "org-memory"
|
||||
assert ref["title"] == "Organizational Memory Loop"
|
||||
body = markers.get_x_draft_body(draft)
|
||||
assert body is not None
|
||||
assert "RoboCo" in body
|
||||
|
||||
# The exploration task itself is completed as a side effect...
|
||||
assert exploration.status == TS.COMPLETED
|
||||
# ...and therefore excluded from the open-cycle list on the next query.
|
||||
still_open = await get_task_service(db_session).list_open_feature_explorations()
|
||||
assert still_open == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materialize_feature_spotlight_marks_feature_seen(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
exploration = await engine.open_feature_spotlight_exploration()
|
||||
assert exploration is not None
|
||||
|
||||
assert await engine.is_feature_seen("sandboxed-dev-db") is False
|
||||
await engine.materialize_feature_spotlight(
|
||||
exploration_task=exploration,
|
||||
feature_slug="sandboxed-dev-db",
|
||||
feature_title="Sandboxed Dev DB/Redis",
|
||||
body="Every agent now gets a throwaway Postgres + Redis sandbox.",
|
||||
)
|
||||
assert await engine.is_feature_seen("sandboxed-dev-db") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materialize_feature_spotlight_enforces_280_chars(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
exploration = await engine.open_feature_spotlight_exploration()
|
||||
assert exploration is not None
|
||||
|
||||
draft = await engine.materialize_feature_spotlight(
|
||||
exploration_task=exploration,
|
||||
feature_slug="runaway-body",
|
||||
feature_title="Runaway Body",
|
||||
body="z" * 500, # a runaway HoM-authored draft
|
||||
)
|
||||
body = markers.get_x_draft_body(draft)
|
||||
assert body is not None
|
||||
assert len(body) <= MAX_TWEET_CHARS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Voice guide (feeds release/reply prompts + the HoM identity's briefing claim)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_guide_falls_back_when_brand_voice_unset(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await get_company_goals_service(db_session).upsert({"brand_voice": ""})
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
voice = await engine._voice_guide()
|
||||
assert voice == x_engine_module._HOM_VOICE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_guide_appends_brand_voice_when_set(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await get_company_goals_service(db_session).upsert(
|
||||
{"brand_voice": "Dry wit, never an exclamation point."}
|
||||
)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
voice = await engine._voice_guide()
|
||||
assert x_engine_module._HOM_VOICE in voice
|
||||
assert "Dry wit, never an exclamation point." in voice
|
||||
|
||||
@@ -24,7 +24,7 @@ from roboco.models.base import (
|
||||
from roboco.models.base import TaskNature as TN
|
||||
from roboco.models.base import TaskStatus as TS
|
||||
from roboco.models.base import TaskType as TT
|
||||
from roboco.services.task import X_POST_SOURCE, X_REPLY_SOURCE
|
||||
from roboco.services.task import X_FEATURE_SOURCE, X_POST_SOURCE, X_REPLY_SOURCE
|
||||
from roboco.services.x_client import XClient, XMention, XPostResult
|
||||
from roboco.services.x_post_service import (
|
||||
XPostBodyTooLongError,
|
||||
@@ -315,3 +315,36 @@ async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> No
|
||||
ids = {t.id for t in open_posts}
|
||||
assert open_task.id in ids
|
||||
assert rejected_task.id not in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_posts_feature_spotlight_draft(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The feature-spotlight source needs zero service changes: it rides the
|
||||
same generic approve path as x_post/x_reply."""
|
||||
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
|
||||
client = _StubClient()
|
||||
with (
|
||||
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
||||
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
||||
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
||||
):
|
||||
result = await _svc(db_session).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "posted"
|
||||
assert result.tweet_id == "999"
|
||||
assert client.calls == ["Draft body"]
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TS.COMPLETED
|
||||
assert markers.get_x_posted_tweet_id(task) == "999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_open_posts_includes_feature_spotlight_source(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
|
||||
open_posts = await _svc(db_session).list_open_posts()
|
||||
ids = {t.id for t in open_posts}
|
||||
assert task.id in ids
|
||||
|
||||
Reference in New Issue
Block a user