mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix: post-finale completeness sweep — routing surface, provider config, budgets, compose env, interactive exemption (#661)
This commit is contained in:
@@ -53,15 +53,21 @@ async def llm_setup(
|
||||
base_url="https://ollama.example.com",
|
||||
)
|
||||
# Mirrors migration 083_seed_openai_provider's contract: enabled=True at
|
||||
# seed time (no apply_mode="codex" write path exists to flip it later —
|
||||
# see that migration's docstring).
|
||||
# seed time.
|
||||
openai = ProviderConfigTable(
|
||||
name="openai-test",
|
||||
type=ModelProvider.OPENAI,
|
||||
enabled=True,
|
||||
base_url="https://api.openai.com/v1",
|
||||
)
|
||||
db_session.add_all([anthropic, grok, ollama, openai])
|
||||
# Mirrors the post-086 seeded state (085 seeds enabled=false, 086 flips it
|
||||
# true to match Codex) — no base_url, subscription OAuth auth only.
|
||||
gemini = ProviderConfigTable(
|
||||
name="gemini-test",
|
||||
type=ModelProvider.GEMINI,
|
||||
enabled=True,
|
||||
)
|
||||
db_session.add_all([anthropic, grok, ollama, openai, gemini])
|
||||
await db_session.flush()
|
||||
yield {"svc": ModelRoutingService(db_session)}
|
||||
|
||||
@@ -217,6 +223,18 @@ async def test_derive_mode_codex_when_only_openai_global(llm_setup: dict) -> Non
|
||||
assert await svc.derive_mode() == "codex"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_derive_mode_gemini_when_only_gemini_global(llm_setup: dict) -> None:
|
||||
"""A pure-GEMINI global assignment reports "gemini", not the catch-all
|
||||
"mix" — mirrors the codex branch derive_mode already carries."""
|
||||
svc = llm_setup["svc"]
|
||||
gemini_model = _first_model_for_type(ModelProvider.GEMINI)
|
||||
await svc.upsert_assignment(
|
||||
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=gemini_model
|
||||
)
|
||||
assert await svc.derive_mode() == "gemini"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_derive_mode_mix_with_per_agent(llm_setup: dict) -> None:
|
||||
svc = llm_setup["svc"]
|
||||
@@ -332,6 +350,75 @@ async def test_apply_mode_grok_enables_grok_provider(llm_setup: dict) -> None:
|
||||
assert refetched.enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_codex_sets_global(llm_setup: dict) -> None:
|
||||
svc = llm_setup["svc"]
|
||||
await svc.apply_mode(mode="codex")
|
||||
assignments = await svc.list_assignments()
|
||||
assert len(assignments) == 1
|
||||
assert assignments[0].scope == AssignmentScope.GLOBAL
|
||||
assert assignments[0].provider.type == ModelProvider.OPENAI
|
||||
assert assignments[0].model_name == "gpt-5.3-codex"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_codex_enables_openai_provider(llm_setup: dict) -> None:
|
||||
"""apply_mode('codex') force-enables the OPENAI row — belt-and-suspenders
|
||||
alongside migration 083's own enabled=true seed."""
|
||||
svc = llm_setup["svc"]
|
||||
provider_svc = ProviderService(svc.session)
|
||||
openai = next(
|
||||
p
|
||||
for p in await provider_svc.list_providers(include_disabled=True)
|
||||
if p.type == ModelProvider.OPENAI
|
||||
)
|
||||
await provider_svc.update_provider(
|
||||
cast("UUID", openai.id), ProviderUpdate(enabled=False)
|
||||
)
|
||||
await svc.session.flush()
|
||||
|
||||
await svc.apply_mode(mode="codex")
|
||||
|
||||
refetched = await provider_svc.get_provider(cast("UUID", openai.id))
|
||||
assert refetched is not None
|
||||
assert refetched.enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_gemini_sets_global(llm_setup: dict) -> None:
|
||||
svc = llm_setup["svc"]
|
||||
await svc.apply_mode(mode="gemini")
|
||||
assignments = await svc.list_assignments()
|
||||
assert len(assignments) == 1
|
||||
assert assignments[0].scope == AssignmentScope.GLOBAL
|
||||
assert assignments[0].provider.type == ModelProvider.GEMINI
|
||||
assert assignments[0].model_name == "gemini-2.5-pro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_gemini_enables_gemini_provider(llm_setup: dict) -> None:
|
||||
"""apply_mode('gemini') force-enables the GEMINI row — the exact gap this
|
||||
fix closes (migration 085 seeds it disabled and nothing else ever flipped
|
||||
it before this write path + migration 086 existed)."""
|
||||
svc = llm_setup["svc"]
|
||||
provider_svc = ProviderService(svc.session)
|
||||
gemini = next(
|
||||
p
|
||||
for p in await provider_svc.list_providers(include_disabled=True)
|
||||
if p.type == ModelProvider.GEMINI
|
||||
)
|
||||
await provider_svc.update_provider(
|
||||
cast("UUID", gemini.id), ProviderUpdate(enabled=False)
|
||||
)
|
||||
await svc.session.flush()
|
||||
|
||||
await svc.apply_mode(mode="gemini")
|
||||
|
||||
refetched = await provider_svc.get_provider(cast("UUID", gemini.id))
|
||||
assert refetched is not None
|
||||
assert refetched.enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_mix_requires_per_agent(llm_setup: dict) -> None:
|
||||
svc = llm_setup["svc"]
|
||||
@@ -440,6 +527,129 @@ async def test_upsert_and_resolve_openai_assignment_roundtrip(
|
||||
assert route.auth_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_and_resolve_gemini_assignment_roundtrip(
|
||||
llm_setup: dict,
|
||||
) -> None:
|
||||
"""gemini-2.5-pro through upsert_assignment -> resolve_for_agent, against
|
||||
the seeded GEMINI row. Proves resolve_for_agent actually returns a GEMINI
|
||||
spawn route — not a silent Anthropic fallback — the exact gap left open
|
||||
by the row seeding disabled with no enable path (migration 085 alone)."""
|
||||
svc = llm_setup["svc"]
|
||||
gemini_model = _first_model_for_type(ModelProvider.GEMINI)
|
||||
row = await svc.upsert_assignment(
|
||||
scope=AssignmentScope.AGENT_SLUG,
|
||||
scope_value="ux-dev-1",
|
||||
model_name=gemini_model,
|
||||
)
|
||||
assert row.model_name == gemini_model
|
||||
|
||||
route = await svc.resolve_for_agent("ux-dev-1")
|
||||
assert route.provider_type == ModelProvider.GEMINI
|
||||
assert route.model_name == gemini_model
|
||||
# Subscription OAuth auth (~/.gemini), not a decrypted provider token.
|
||||
assert route.auth_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_assignment_enables_disabled_gemini_provider(
|
||||
llm_setup: dict,
|
||||
) -> None:
|
||||
"""Belt-and-suspenders: assigning a Gemini model via Mix (upsert_assignment)
|
||||
force-enables the row even if it was disabled — not just apply_mode('gemini')."""
|
||||
svc = llm_setup["svc"]
|
||||
provider_svc = ProviderService(svc.session)
|
||||
gemini = next(
|
||||
p
|
||||
for p in await provider_svc.list_providers(include_disabled=True)
|
||||
if p.type == ModelProvider.GEMINI
|
||||
)
|
||||
await provider_svc.update_provider(
|
||||
cast("UUID", gemini.id), ProviderUpdate(enabled=False)
|
||||
)
|
||||
await svc.session.flush()
|
||||
|
||||
gemini_model = _first_model_for_type(ModelProvider.GEMINI)
|
||||
await svc.upsert_assignment(
|
||||
scope=AssignmentScope.AGENT_SLUG,
|
||||
scope_value="ux-dev-1",
|
||||
model_name=gemini_model,
|
||||
)
|
||||
|
||||
refetched = await provider_svc.get_provider(cast("UUID", gemini.id))
|
||||
assert refetched is not None
|
||||
assert refetched.enabled is True
|
||||
# And the route actually resolves to GEMINI now that it's enabled.
|
||||
route = await svc.resolve_for_agent("ux-dev-1")
|
||||
assert route.provider_type == ModelProvider.GEMINI
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_gemini_end_to_end_reachable(llm_setup: dict) -> None:
|
||||
"""The full reachability chain the original drill missed: apply_mode
|
||||
-> derive_mode reflects it -> resolve_for_agent actually spawns Gemini."""
|
||||
svc = llm_setup["svc"]
|
||||
await svc.apply_mode(mode="gemini")
|
||||
|
||||
assert await svc.derive_mode() == "gemini"
|
||||
|
||||
route = await svc.resolve_for_agent("ux-dev-1")
|
||||
assert route.provider_type == ModelProvider.GEMINI
|
||||
assert route.model_name == "gemini-2.5-pro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_codex_end_to_end_reachable(llm_setup: dict) -> None:
|
||||
"""Same reachability chain for Codex, mirroring the Gemini test above."""
|
||||
svc = llm_setup["svc"]
|
||||
await svc.apply_mode(mode="codex")
|
||||
|
||||
assert await svc.derive_mode() == "codex"
|
||||
|
||||
route = await svc.resolve_for_agent("be-dev-1")
|
||||
assert route.provider_type == ModelProvider.OPENAI
|
||||
assert route.model_name == "gpt-5.3-codex"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", ["codex", "gemini"])
|
||||
@pytest.mark.parametrize("interactive_slug", ["intake-1", "secretary-1"])
|
||||
async def test_interactive_agents_exempt_from_delivery_only_global_mode(
|
||||
llm_setup: dict, mode: str, interactive_slug: str
|
||||
) -> None:
|
||||
"""A fleet-wide Codex/Gemini mode must not capture Intake/Secretary —
|
||||
they have no V1 support on those providers, so the resolver keeps them
|
||||
on the legacy Anthropic path (the completeness-drill gap: previously
|
||||
they resolved to the unsupported provider and the spawn guard left both
|
||||
chats refusing to start after a one-click mode switch)."""
|
||||
svc = llm_setup["svc"]
|
||||
await svc.apply_mode(mode=mode)
|
||||
|
||||
# The mode still derives cleanly (single GLOBAL row — no extra pins).
|
||||
assert await svc.derive_mode() == mode
|
||||
|
||||
route = await svc.resolve_for_agent(interactive_slug)
|
||||
assert route.provider_type == ModelProvider.ANTHROPIC
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interactive_agent_explicit_pin_is_not_exempted(
|
||||
llm_setup: dict,
|
||||
) -> None:
|
||||
"""An EXPLICIT AGENT_SLUG pin to a delivery-only provider is honored by
|
||||
the resolver (the orchestrator's spawn guard refuses it loudly) — a
|
||||
deliberate operator choice must error, never be silently overridden."""
|
||||
svc = llm_setup["svc"]
|
||||
await svc.upsert_assignment(
|
||||
scope=AssignmentScope.AGENT_SLUG,
|
||||
scope_value="intake-1",
|
||||
model_name="gpt-5.3-codex",
|
||||
)
|
||||
|
||||
route = await svc.resolve_for_agent("intake-1")
|
||||
assert route.provider_type == ModelProvider.OPENAI
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_for_agent_uses_provider_token(llm_setup: dict) -> None:
|
||||
"""When provider has auth_token_encrypted, it's decrypted (lines 345-346)."""
|
||||
@@ -1107,3 +1317,42 @@ async def test_apply_routing_preset_validates_before_wiping_anything(
|
||||
# rather than a stale expectation of survival.
|
||||
remaining = await svc.list_assignments()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_routing_preset_skips_entry_whose_provider_went_disabled(
|
||||
llm_setup: dict,
|
||||
) -> None:
|
||||
"""A preset entry that resolved fine at save time but whose provider has
|
||||
SINCE been disabled (key cleared, self-hosted disconnected, Codex/Gemini
|
||||
disabled) must be skipped-with-note, never silently restored — applying
|
||||
a preset can't resurrect a dead route behind a success toast."""
|
||||
svc = llm_setup["svc"]
|
||||
gemini_model = _first_model_for_type(ModelProvider.GEMINI)
|
||||
await svc.upsert_assignment(
|
||||
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=gemini_model
|
||||
)
|
||||
preset = await svc.save_routing_preset("gemini-then-disabled")
|
||||
|
||||
# Disable the GEMINI provider AFTER the preset was saved (mirrors an
|
||||
# operator turning it off, or a fresh env where the row starts disabled).
|
||||
provider_svc = ProviderService(svc.session)
|
||||
gemini = next(
|
||||
p
|
||||
for p in await provider_svc.list_providers(include_disabled=True)
|
||||
if p.type == ModelProvider.GEMINI
|
||||
)
|
||||
await provider_svc.update_provider(
|
||||
cast("UUID", gemini.id), ProviderUpdate(enabled=False)
|
||||
)
|
||||
await svc.session.flush()
|
||||
|
||||
# Clear current routing so the preset apply has something to (not) restore.
|
||||
await svc.apply_mode(mode="anthropic")
|
||||
|
||||
notes = await svc.apply_routing_preset(preset.id)
|
||||
assert len(notes) == 1
|
||||
assert "unavailable" in notes[0]
|
||||
|
||||
remaining = await svc.list_assignments()
|
||||
assert remaining == [] # the disabled-provider entry was never written
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.project import router as project_router
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.permissions import AgentContext
|
||||
@@ -166,6 +167,79 @@ async def test_update_project_explicit_null_clears_field(
|
||||
assert cleared.json()["test_command"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_rejects_zero_monthly_budget_usd(
|
||||
project_client: AsyncClient,
|
||||
) -> None:
|
||||
"""#654: a 0 cap would block every claim immediately — rejected at the
|
||||
request boundary, never stored."""
|
||||
create = await project_client.post("/api/projects", json=_payload(), headers=_HDR)
|
||||
pid = create.json()["id"]
|
||||
response = await project_client.patch(
|
||||
f"/api/projects/{pid}",
|
||||
json={"monthly_budget_usd": 0},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_rejects_negative_monthly_budget_usd(
|
||||
project_client: AsyncClient,
|
||||
) -> None:
|
||||
create = await project_client.post("/api/projects", json=_payload(), headers=_HDR)
|
||||
pid = create.json()["id"]
|
||||
response = await project_client.patch(
|
||||
f"/api/projects/{pid}",
|
||||
json={"monthly_budget_usd": -5},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_accepts_positive_monthly_budget_usd(
|
||||
project_client: AsyncClient,
|
||||
) -> None:
|
||||
create = await project_client.post("/api/projects", json=_payload(), headers=_HDR)
|
||||
pid = create.json()["id"]
|
||||
cap = 100
|
||||
response = await project_client.patch(
|
||||
f"/api/projects/{pid}",
|
||||
json={"monthly_budget_usd": cap},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["monthly_budget_usd"] == cap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_by_id_includes_spend_when_budgets_enabled(
|
||||
project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""monthly_spend_usd is populated (0.0 with no spawn sessions yet) once
|
||||
ROBOCO_TASK_BUDGETS_ENABLED is on — the extra DB read only runs then."""
|
||||
monkeypatch.setattr(settings, "task_budgets_enabled", True)
|
||||
create = await project_client.post("/api/projects", json=_payload(), headers=_HDR)
|
||||
pid = create.json()["id"]
|
||||
response = await project_client.get(f"/api/projects/{pid}", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["monthly_spend_usd"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_by_id_omits_spend_when_budgets_disabled(
|
||||
project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Flag off => monthly_spend_usd stays null, same as before this field existed."""
|
||||
monkeypatch.setattr(settings, "task_budgets_enabled", False)
|
||||
create = await project_client.post("/api/projects", json=_payload(), headers=_HDR)
|
||||
pid = create.json()["id"]
|
||||
response = await project_client.get(f"/api/projects/{pid}", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["monthly_spend_usd"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_not_found(project_client: AsyncClient) -> None:
|
||||
response = await project_client.patch(
|
||||
|
||||
@@ -330,6 +330,117 @@ async def test_apply_mode_ollama_without_provider_returns_404(
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app_client_with_codex_and_gemini(
|
||||
db_session: AsyncSession,
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
"""App client pre-seeded with Anthropic + disabled OPENAI/GEMINI providers
|
||||
(mirrors the real seeded state before an operator ever applies either
|
||||
mode: OPENAI seeds enabled=true per migration 083, GEMINI seeds
|
||||
enabled=false per migration 085 — deliberately seeded disabled here so the
|
||||
apply-mode round trip below proves the force-enable, not a pre-enabled
|
||||
no-op)."""
|
||||
app = _make_app(db_session)
|
||||
suffix = uuid4().hex[:8]
|
||||
await db_session.execute(delete(ModelAssignmentTable))
|
||||
await db_session.execute(delete(ProviderConfigTable))
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
ProviderConfigTable(
|
||||
name=f"anthropic-cg-{suffix}", type=ModelProvider.ANTHROPIC, enabled=True
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
ProviderConfigTable(
|
||||
name=f"codex-cg-{suffix}",
|
||||
type=ModelProvider.OPENAI,
|
||||
enabled=False,
|
||||
base_url="https://api.openai.com/v1",
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
ProviderConfigTable(
|
||||
name=f"gemini-cg-{suffix}", type=ModelProvider.GEMINI, enabled=False
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_codex_returns_200_reflects_mode_and_enables_provider(
|
||||
app_client_with_codex_and_gemini: AsyncClient,
|
||||
) -> None:
|
||||
"""The full HTTP round trip: POST mode="codex" -> 200, GET reflects
|
||||
mode="codex", and the assignment resolves through the now-enabled OPENAI
|
||||
provider — proving the pydantic Literal + dispatch + enable chain end to
|
||||
end, not just the service-layer call this mirrors."""
|
||||
response = await app_client_with_codex_and_gemini.post(
|
||||
"/api/providers", json={"mode": "codex"}, headers=_HDR_PM
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
body = response.json()
|
||||
assert body["mode"] == "codex"
|
||||
assert body["assignments"][0]["provider_type"] == "openai"
|
||||
assert body["assignments"][0]["model_name"] == "gpt-5.3-codex"
|
||||
|
||||
followup = await app_client_with_codex_and_gemini.get(
|
||||
"/api/providers", headers=_HDR_PM
|
||||
)
|
||||
assert followup.json()["mode"] == "codex"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_gemini_returns_200_reflects_mode_and_enables_provider(
|
||||
app_client_with_codex_and_gemini: AsyncClient,
|
||||
) -> None:
|
||||
"""Same round trip as Codex's, for Gemini — the exact reachability gap
|
||||
this fix closes (the row seeds disabled and nothing else ever flipped it)."""
|
||||
response = await app_client_with_codex_and_gemini.post(
|
||||
"/api/providers", json={"mode": "gemini"}, headers=_HDR_PM
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
body = response.json()
|
||||
assert body["mode"] == "gemini"
|
||||
assert body["assignments"][0]["provider_type"] == "gemini"
|
||||
assert body["assignments"][0]["model_name"] == "gemini-2.5-pro"
|
||||
|
||||
followup = await app_client_with_codex_and_gemini.get(
|
||||
"/api/providers", headers=_HDR_PM
|
||||
)
|
||||
assert followup.json()["mode"] == "gemini"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_gemini_without_provider_returns_404(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Apply 'gemini' mode without the GEMINI provider seeded raises
|
||||
NotFoundError -> 404 (mirrors the ollama/grok equivalents)."""
|
||||
# FK-safe: a prior test may have committed a real GEMINI assignment
|
||||
# (model_assignments.provider_config_id references provider_configs.id),
|
||||
# so assignments must be cleared before the provider row can be deleted.
|
||||
await db_session.execute(delete(ModelAssignmentTable))
|
||||
await db_session.execute(
|
||||
delete(ProviderConfigTable).where(
|
||||
ProviderConfigTable.type == ModelProvider.GEMINI
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
app = _make_app(db_session)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/providers", json={"mode": "gemini"}, headers=_HDR_PM
|
||||
)
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Self-hosted endpoints
|
||||
# =============================================================================
|
||||
@@ -876,6 +987,14 @@ async def test_save_list_and_apply_preset_round_trip(
|
||||
app_client_with_ollama: AsyncClient,
|
||||
) -> None:
|
||||
"""Save captures the current state; mutating + re-applying restores it."""
|
||||
# Set the Ollama key first — the fixture seeds OLLAMA_CLOUD `enabled=False`
|
||||
# (no key yet), and `_validate_preset_entry` now rejects (skip-with-note)
|
||||
# any preset entry whose provider is disabled, so a meaningful round trip
|
||||
# needs the provider actually live, same as the real UI's key-gated mode
|
||||
# button.
|
||||
await app_client_with_ollama.put(
|
||||
"/api/providers/ollama-key", json={"api_key": "test-key"}, headers=_HDR_PM
|
||||
)
|
||||
# Arrange a distinctive state: a GLOBAL Ollama default.
|
||||
await app_client_with_ollama.post(
|
||||
"/api/providers", json={"mode": "ollama"}, headers=_HDR_PM
|
||||
|
||||
@@ -22,6 +22,7 @@ from roboco.api.routes.tasks import (
|
||||
from roboco.api.routes.tasks import (
|
||||
router as tasks_router,
|
||||
)
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, ProjectTable, TaskTable, WorkSessionTable
|
||||
from roboco.exceptions import GitError, TaskLifecycleError
|
||||
from roboco.foundation.policy.lifecycle import STATUS_GRAPH
|
||||
@@ -278,6 +279,35 @@ async def test_get_task_by_id(task_client: dict) -> None:
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_by_id_includes_spend_when_budgets_enabled(
|
||||
task_client: dict, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""spend_usd is populated (0.0 with no spawn sessions yet) once
|
||||
ROBOCO_TASK_BUDGETS_ENABLED is on — the extra DB read only runs then."""
|
||||
monkeypatch.setattr(settings, "task_budgets_enabled", True)
|
||||
client = task_client["client"]
|
||||
task = _seed_task(task_client)
|
||||
await task_client["db"].flush()
|
||||
response = await client.get(f"/api/tasks/{task.id}", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["spend_usd"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_by_id_omits_spend_when_budgets_disabled(
|
||||
task_client: dict, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Flag off => spend_usd stays null, the same as before this field existed."""
|
||||
monkeypatch.setattr(settings, "task_budgets_enabled", False)
|
||||
client = task_client["client"]
|
||||
task = _seed_task(task_client)
|
||||
await task_client["db"].flush()
|
||||
response = await client.get(f"/api/tasks/{task.id}", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["spend_usd"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
@@ -291,6 +321,52 @@ async def test_update_task(task_client: dict) -> None:
|
||||
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_zero_budget_usd(task_client: dict) -> None:
|
||||
"""#654: a 0 cap would block every claim immediately — rejected at the
|
||||
request boundary, never stored."""
|
||||
client = task_client["client"]
|
||||
task = _seed_task(task_client)
|
||||
await task_client["db"].flush()
|
||||
response = await client.patch(
|
||||
f"/api/tasks/{task.id}",
|
||||
json={"budget_usd": 0},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_negative_budget_usd(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
task = _seed_task(task_client)
|
||||
await task_client["db"].flush()
|
||||
response = await client.patch(
|
||||
f"/api/tasks/{task.id}",
|
||||
json={"budget_usd": -5},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_accepts_positive_budget_usd(task_client: dict) -> None:
|
||||
# budget_usd is a _PRIVILEGED_UPDATE_FIELDS / non-"PM lighter" field —
|
||||
# a plain main_pm PATCH would 403 here, so exercise the CEO's full scope.
|
||||
_as_ceo(task_client)
|
||||
client = task_client["client"]
|
||||
task = _seed_task(task_client)
|
||||
await task_client["db"].flush()
|
||||
budget = 12.5
|
||||
response = await client.patch(
|
||||
f"/api/tasks/{task.id}",
|
||||
json={"budget_usd": budget},
|
||||
headers=_HDR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["budget_usd"] == budget
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_status_override_recovers_blocked(task_client: dict) -> None:
|
||||
"""A privileged PATCH with ``status`` + ``force`` is applied as an audited
|
||||
|
||||
@@ -277,6 +277,27 @@ def test_task_update_sequence_rejects_negative() -> None:
|
||||
TaskUpdate(sequence=-1)
|
||||
|
||||
|
||||
def test_task_update_budget_usd_accepts_null() -> None:
|
||||
"""null clears the cap back to the TaskType default — always valid."""
|
||||
assert TaskUpdate(budget_usd=None).budget_usd is None
|
||||
|
||||
|
||||
def test_task_update_budget_usd_accepts_positive() -> None:
|
||||
budget = 5.0
|
||||
assert TaskUpdate(budget_usd=budget).budget_usd == budget
|
||||
|
||||
|
||||
def test_task_update_budget_usd_rejects_zero() -> None:
|
||||
"""gt=0 — a 0 budget would block every claim immediately (#654)."""
|
||||
with pytest.raises(ValueError, match="budget_usd"):
|
||||
TaskUpdate(budget_usd=0)
|
||||
|
||||
|
||||
def test_task_update_budget_usd_rejects_negative() -> None:
|
||||
with pytest.raises(ValueError, match="budget_usd"):
|
||||
TaskUpdate(budget_usd=-5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_to_response / task_list_to_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -41,9 +41,30 @@ def test_render_config_toml_marks_gateway_pair_required() -> None:
|
||||
assert "required" not in parsed["mcp_servers"]["roboco-optimal"]
|
||||
|
||||
|
||||
def test_render_config_toml_empty_when_no_servers() -> None:
|
||||
assert cc.render_config_toml({}) == ""
|
||||
assert cc.render_config_toml({"mcpServers": {}}) == ""
|
||||
def test_render_config_toml_widens_startup_timeout_on_required_servers() -> None:
|
||||
# The CLI's default 10s MCP startup timeout fail-fast-aborts the session on
|
||||
# a cold uv wheel cache; the gateway pair gets a wider budget.
|
||||
parsed = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
|
||||
timeout = cc._REQUIRED_MCP_STARTUP_TIMEOUT_SEC
|
||||
assert parsed["mcp_servers"]["roboco-flow"]["startup_timeout_sec"] == timeout
|
||||
assert parsed["mcp_servers"]["roboco-do"]["startup_timeout_sec"] == timeout
|
||||
assert "startup_timeout_sec" not in parsed["mcp_servers"]["roboco-optimal"]
|
||||
|
||||
|
||||
def test_render_config_toml_disables_subagents_unconditionally() -> None:
|
||||
# Fleet-wide subagent ban (CEO, 2026-07-09) — a global switch, not
|
||||
# per-role, so it renders even with no MCP servers configured at all.
|
||||
no_servers = tomllib.loads(cc.render_config_toml({}))
|
||||
empty_servers = tomllib.loads(cc.render_config_toml({"mcpServers": {}}))
|
||||
with_servers = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
|
||||
assert no_servers["agents"]["enabled"] is False
|
||||
assert empty_servers["agents"]["enabled"] is False
|
||||
assert with_servers["agents"]["enabled"] is False
|
||||
|
||||
|
||||
def test_render_config_toml_no_mcp_servers_key_when_no_servers() -> None:
|
||||
assert "mcp_servers" not in tomllib.loads(cc.render_config_toml({}))
|
||||
assert "mcp_servers" not in tomllib.loads(cc.render_config_toml({"mcpServers": {}}))
|
||||
|
||||
|
||||
def test_sandbox_level_developer_is_workspace_write() -> None:
|
||||
|
||||
@@ -125,8 +125,13 @@ def test_write_policy_toml_writes_file(tmp_path: Path) -> None:
|
||||
assert "run_shell_command" in written
|
||||
|
||||
|
||||
def test_gemini_cli_args_is_yolo_only() -> None:
|
||||
assert gc.gemini_cli_args() == ["--approval-mode", "yolo"]
|
||||
def test_gemini_cli_args_is_yolo_plus_default_max_turns() -> None:
|
||||
assert gc.gemini_cli_args() == ["--approval-mode", "yolo", "--max-turns", "200"]
|
||||
|
||||
|
||||
def test_gemini_cli_args_max_turns_is_overridable() -> None:
|
||||
args = gc.gemini_cli_args(max_turns=7)
|
||||
assert args[args.index("--max-turns") + 1] == "7"
|
||||
|
||||
|
||||
def test_main_writes_settings_and_args(
|
||||
@@ -161,4 +166,50 @@ def test_main_writes_settings_and_args(
|
||||
assert args_path.read_text(encoding="utf-8").splitlines() == [
|
||||
"--approval-mode",
|
||||
"yolo",
|
||||
"--max-turns",
|
||||
"200",
|
||||
]
|
||||
|
||||
|
||||
def test_main_honors_max_turns_env_override(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mcp_path = tmp_path / "mcp-config.json"
|
||||
mcp_path.write_text(json.dumps(_SAMPLE_MCP), encoding="utf-8")
|
||||
args_path = tmp_path / "gemini-args"
|
||||
|
||||
monkeypatch.setattr(gc, "GEMINI_SETTINGS_PATH", tmp_path / ".gemini" / "s.json")
|
||||
monkeypatch.setattr(gc, "GEMINI_MEMORY_PATH", tmp_path / ".gemini" / "GEMINI.md")
|
||||
monkeypatch.setattr(gc, "GEMINI_POLICIES_DIR", tmp_path / ".gemini" / "policies")
|
||||
monkeypatch.setattr(gc, "GEMINI_ARGS_PATH", args_path)
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1")
|
||||
monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path))
|
||||
monkeypatch.setenv("ROBOCO_GEMINI_MAX_TURNS", "42")
|
||||
|
||||
assert gc.main() == 0
|
||||
assert args_path.read_text(encoding="utf-8").splitlines()[-2:] == [
|
||||
"--max-turns",
|
||||
"42",
|
||||
]
|
||||
|
||||
|
||||
def test_main_falls_back_to_default_max_turns_on_bad_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mcp_path = tmp_path / "mcp-config.json"
|
||||
mcp_path.write_text(json.dumps(_SAMPLE_MCP), encoding="utf-8")
|
||||
args_path = tmp_path / "gemini-args"
|
||||
|
||||
monkeypatch.setattr(gc, "GEMINI_SETTINGS_PATH", tmp_path / ".gemini" / "s.json")
|
||||
monkeypatch.setattr(gc, "GEMINI_MEMORY_PATH", tmp_path / ".gemini" / "GEMINI.md")
|
||||
monkeypatch.setattr(gc, "GEMINI_POLICIES_DIR", tmp_path / ".gemini" / "policies")
|
||||
monkeypatch.setattr(gc, "GEMINI_ARGS_PATH", args_path)
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1")
|
||||
monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path))
|
||||
monkeypatch.setenv("ROBOCO_GEMINI_MAX_TURNS", "not-a-number")
|
||||
|
||||
assert gc.main() == 0
|
||||
assert args_path.read_text(encoding="utf-8").splitlines()[-2:] == [
|
||||
"--max-turns",
|
||||
"200",
|
||||
]
|
||||
|
||||
@@ -15,10 +15,19 @@ from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.llm.providers import GeminiCliProvider, ProviderError, SpawnResult
|
||||
from roboco.llm.providers import gemini as gemini_module
|
||||
from roboco.models.runtime import OrchestratorAgentConfig
|
||||
|
||||
|
||||
def test_gemini_cli_model_is_a_real_settings_field() -> None:
|
||||
# Parity with codex_cli_model (roboco.config.Settings.codex_cli_model) —
|
||||
# gemini.py reads settings.gemini_cli_model, not a raw os.environ.get.
|
||||
assert settings.gemini_cli_model == gemini_module._GEMINI_CLI_MODEL
|
||||
assert settings.gemini_cli_model == "gemini-2.5-pro"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_gemini_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point GEMINI_AUTH_HOST_PATH at a fresh tmp dir so tests never mount the
|
||||
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.models.base import ModelProvider
|
||||
from roboco.models.base import AssignmentScope, ModelProvider
|
||||
from roboco.services.llm import ModelRoutingService, _ResolvedAssignment
|
||||
|
||||
_AGENT_SLUG = "be-dev-1"
|
||||
@@ -23,7 +23,9 @@ def _disabled_resolved() -> _ResolvedAssignment:
|
||||
provider = MagicMock(
|
||||
enabled=False, id="prov-disabled", type=ModelProvider.OLLAMA_CLOUD
|
||||
)
|
||||
return _ResolvedAssignment(provider=provider, model_name="grok-build")
|
||||
return _ResolvedAssignment(
|
||||
provider=provider, model_name="grok-build", scope=AssignmentScope.GLOBAL
|
||||
)
|
||||
|
||||
|
||||
def _svc() -> ModelRoutingService:
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Task.budget_usd / Project.monthly_budget_usd validation (#654).
|
||||
|
||||
The task-budgets feature's own design says "0 rejected — a zero budget
|
||||
silently blocks everything" (every claim is refused from the first tick),
|
||||
so every schema that can set these fields must reject 0 and negative values
|
||||
at the pydantic boundary — a 422, never a stored self-DoS. Null ("no cap")
|
||||
stays valid throughout. Mirrors test_project_sandbox_services.py's style
|
||||
(domain-model `pytest.raises(ValidationError)` coverage).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from roboco.models.base import Team
|
||||
from roboco.models.project import Project, ProjectCreate, ProjectUpdate
|
||||
from roboco.models.task import Task, TaskUpdate
|
||||
|
||||
|
||||
def _task(budget_usd: float | None = None) -> Task:
|
||||
return Task(
|
||||
title="Add user lookup endpoint",
|
||||
description="Add GET /v1/users/{id} returning user JSON.",
|
||||
acceptance_criteria=["returns 404 for unknown user"],
|
||||
created_by=uuid4(),
|
||||
team=Team.BACKEND,
|
||||
budget_usd=budget_usd,
|
||||
)
|
||||
|
||||
|
||||
def _project(monthly_budget_usd: float | None = None) -> Project:
|
||||
return Project(
|
||||
name="P",
|
||||
slug="p",
|
||||
git_url="https://example.com/r.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=uuid4(),
|
||||
monthly_budget_usd=monthly_budget_usd,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task.budget_usd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_task_defaults_budget_usd_to_none() -> None:
|
||||
assert _task().budget_usd is None
|
||||
|
||||
|
||||
def test_task_accepts_positive_budget_usd() -> None:
|
||||
budget = 12.5
|
||||
assert _task(budget_usd=budget).budget_usd == budget
|
||||
|
||||
|
||||
def test_task_rejects_zero_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="budget_usd"):
|
||||
_task(budget_usd=0)
|
||||
|
||||
|
||||
def test_task_rejects_negative_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="budget_usd"):
|
||||
_task(budget_usd=-5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# roboco.models.task.TaskUpdate.budget_usd (domain update model)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_task_update_accepts_null_budget_usd() -> None:
|
||||
assert TaskUpdate(budget_usd=None).budget_usd is None
|
||||
|
||||
|
||||
def test_task_update_accepts_positive_budget_usd() -> None:
|
||||
budget = 3.0
|
||||
assert TaskUpdate(budget_usd=budget).budget_usd == budget
|
||||
|
||||
|
||||
def test_task_update_rejects_zero_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="budget_usd"):
|
||||
TaskUpdate(budget_usd=0)
|
||||
|
||||
|
||||
def test_task_update_rejects_negative_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="budget_usd"):
|
||||
TaskUpdate(budget_usd=-1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project.monthly_budget_usd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_project_defaults_monthly_budget_usd_to_none() -> None:
|
||||
assert _project().monthly_budget_usd is None
|
||||
|
||||
|
||||
def test_project_accepts_positive_monthly_budget_usd() -> None:
|
||||
cap = 100.0
|
||||
assert _project(monthly_budget_usd=cap).monthly_budget_usd == cap
|
||||
|
||||
|
||||
def test_project_rejects_zero_monthly_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="monthly_budget_usd"):
|
||||
_project(monthly_budget_usd=0)
|
||||
|
||||
|
||||
def test_project_rejects_negative_monthly_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="monthly_budget_usd"):
|
||||
_project(monthly_budget_usd=-5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProjectCreate.monthly_budget_usd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_project_create_accepts_null_monthly_budget_usd() -> None:
|
||||
assert (
|
||||
ProjectCreate(
|
||||
name="P",
|
||||
slug="p",
|
||||
git_url="https://example.com/r.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
).monthly_budget_usd
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_project_create_rejects_zero_monthly_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="monthly_budget_usd"):
|
||||
ProjectCreate(
|
||||
name="P",
|
||||
slug="p",
|
||||
git_url="https://example.com/r.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
monthly_budget_usd=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProjectUpdate.monthly_budget_usd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_project_update_accepts_null_monthly_budget_usd() -> None:
|
||||
assert ProjectUpdate(monthly_budget_usd=None).monthly_budget_usd is None
|
||||
|
||||
|
||||
def test_project_update_accepts_positive_monthly_budget_usd() -> None:
|
||||
cap = 50.0
|
||||
assert ProjectUpdate(monthly_budget_usd=cap).monthly_budget_usd == cap
|
||||
|
||||
|
||||
def test_project_update_rejects_zero_monthly_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="monthly_budget_usd"):
|
||||
ProjectUpdate(monthly_budget_usd=0)
|
||||
|
||||
|
||||
def test_project_update_rejects_negative_monthly_budget_usd() -> None:
|
||||
with pytest.raises(ValidationError, match="monthly_budget_usd"):
|
||||
ProjectUpdate(monthly_budget_usd=-5)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Codex (OPENAI) and Gemini (GEMINI) are V1 delivery-roles-only — neither has
|
||||
an interactive-session driver image (unlike GROK's dedicated
|
||||
GROK_PROMPTER_IMAGE / GROK_SECRETARY_IMAGE). Routing either to the persistent
|
||||
Intake/Secretary agent must refuse loudly instead of silently falling through
|
||||
to the plain Claude SDK-driver image with a mismatched provider env.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from roboco.models.base import ModelProvider
|
||||
from roboco.runtime.orchestrator import (
|
||||
_INTERACTIVE_UNSUPPORTED_PROVIDERS,
|
||||
INTAKE_AGENT_ID,
|
||||
SECRETARY_AGENT_ID,
|
||||
AgentOrchestrator,
|
||||
_reject_interactive_unsupported_provider,
|
||||
)
|
||||
from roboco.services import prompter_live
|
||||
from roboco.services.llm import (
|
||||
INTERACTIVE_AGENT_SLUGS,
|
||||
INTERACTIVE_UNSUPPORTED_PROVIDERS,
|
||||
)
|
||||
|
||||
|
||||
def _make_minimal_orchestrator() -> AgentOrchestrator:
|
||||
with patch.object(AgentOrchestrator, "__init__", return_value=None):
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._instances = {}
|
||||
orch._bg_tasks = set()
|
||||
orch._running = True
|
||||
orch._intake_spawn_lock = asyncio.Lock()
|
||||
orch._secretary_spawn_lock = asyncio.Lock()
|
||||
return orch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_registry() -> Any:
|
||||
prev = prompter_live._RegistryHolder.instance
|
||||
prompter_live._RegistryHolder.instance = prompter_live.PrompterLiveRegistry()
|
||||
yield
|
||||
prompter_live._RegistryHolder.instance = prev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit-level: the pure guard function itself.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRejectInteractiveUnsupportedProvider:
|
||||
def test_guard_set_matches_the_resolver_exemption_set(self) -> None:
|
||||
"""The orchestrator's literal must track the resolver's canonical
|
||||
tuple (kept separate to avoid a runtime import cycle)."""
|
||||
assert tuple(_INTERACTIVE_UNSUPPORTED_PROVIDERS) == tuple(
|
||||
INTERACTIVE_UNSUPPORTED_PROVIDERS
|
||||
)
|
||||
|
||||
def test_resolver_slugs_match_the_orchestrator_agent_ids(self) -> None:
|
||||
"""The resolver's exemption must cover exactly the two interactive
|
||||
agents the orchestrator spawns — a renamed id would silently
|
||||
un-exempt a chat."""
|
||||
assert set(INTERACTIVE_AGENT_SLUGS) == {INTAKE_AGENT_ID, SECRETARY_AGENT_ID}
|
||||
|
||||
@pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI])
|
||||
def test_raises_for_delivery_only_providers(self, provider: ModelProvider) -> None:
|
||||
with pytest.raises(RuntimeError, match="delivery-roles-only"):
|
||||
_reject_interactive_unsupported_provider(INTAKE_AGENT_ID, provider)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider",
|
||||
[
|
||||
ModelProvider.ANTHROPIC,
|
||||
ModelProvider.GROK,
|
||||
ModelProvider.OLLAMA_CLOUD,
|
||||
ModelProvider.LOCAL,
|
||||
],
|
||||
)
|
||||
def test_passes_for_interactive_capable_providers(
|
||||
self, provider: ModelProvider
|
||||
) -> None:
|
||||
_reject_interactive_unsupported_provider(INTAKE_AGENT_ID, provider) # no raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intake spawn refusal — surfaces on the relay, container never launched.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIntakeSpawnRefusesDeliveryOnlyProvider:
|
||||
@pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI])
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_before_any_container_work(
|
||||
self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
|
||||
async def _clone(*_a: Any, **_k: Any) -> tuple[str, list[str]]:
|
||||
return "/data/workspaces/roboco/board/intake-1", ["/cwd"]
|
||||
|
||||
async def _route(_aid: str) -> Any:
|
||||
return SimpleNamespace(
|
||||
provider_type=provider,
|
||||
model_name="whatever",
|
||||
base_url=None,
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
run_calls: list[list[str]] = []
|
||||
|
||||
async def _run(cmd: list[str]) -> str:
|
||||
run_calls.append(cmd)
|
||||
return "containerid0123456789"
|
||||
|
||||
monkeypatch.setattr(orch, "_clone_intake_scope", _clone)
|
||||
monkeypatch.setattr(orch, "_resolve_agent_route", _route)
|
||||
monkeypatch.setattr(
|
||||
orch, "_generate_composed_prompt", lambda *_a, **_k: Path("/tmp/p.md")
|
||||
)
|
||||
monkeypatch.setattr(orch, "_run_container_cmd", _run)
|
||||
|
||||
registry = prompter_live.get_live_registry()
|
||||
pushed: list[tuple[str, dict[str, Any]]] = []
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(registry, "push", lambda sid, ev: pushed.append((sid, ev)))
|
||||
monkeypatch.setattr(registry, "close", closed.append)
|
||||
registry.open("sess-refuse", INTAKE_AGENT_ID)
|
||||
|
||||
await orch._spawn_intake_container_guarded(
|
||||
"sess-refuse", project_slug="roboco", product_id=None, initial_message=None
|
||||
)
|
||||
|
||||
assert not run_calls # no container was ever launched
|
||||
assert len(pushed) == 1
|
||||
assert pushed[0][1]["kind"] == "error"
|
||||
assert "delivery-roles-only" in pushed[0][1]["text"]
|
||||
assert closed == ["sess-refuse"]
|
||||
assert INTAKE_AGENT_ID not in orch._instances
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secretary spawn refusal — same shape, same guard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSecretarySpawnRefusesDeliveryOnlyProvider:
|
||||
@pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI])
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_before_any_container_work(
|
||||
self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
|
||||
async def _route(_aid: str) -> Any:
|
||||
return SimpleNamespace(
|
||||
provider_type=provider,
|
||||
model_name="whatever",
|
||||
base_url=None,
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
run_calls: list[list[str]] = []
|
||||
|
||||
async def _run(cmd: list[str]) -> str:
|
||||
run_calls.append(cmd)
|
||||
return "containerid0123456789"
|
||||
|
||||
monkeypatch.setattr(orch, "_resolve_agent_route", _route)
|
||||
monkeypatch.setattr(
|
||||
orch, "_generate_composed_prompt", lambda *_a, **_k: Path("/tmp/p.md")
|
||||
)
|
||||
monkeypatch.setattr(orch, "_run_container_cmd", _run)
|
||||
|
||||
registry = prompter_live.get_live_registry()
|
||||
pushed: list[tuple[str, dict[str, Any]]] = []
|
||||
closed: list[str] = []
|
||||
monkeypatch.setattr(registry, "push", lambda sid, ev: pushed.append((sid, ev)))
|
||||
monkeypatch.setattr(registry, "close", closed.append)
|
||||
registry.open("sess-sec-refuse", SECRETARY_AGENT_ID)
|
||||
|
||||
await orch._spawn_secretary_container_guarded(
|
||||
"sess-sec-refuse", initial_message=None
|
||||
)
|
||||
|
||||
assert not run_calls # no container was ever launched
|
||||
assert len(pushed) == 1
|
||||
assert pushed[0][1]["kind"] == "error"
|
||||
assert "delivery-roles-only" in pushed[0][1]["text"]
|
||||
assert closed == ["sess-sec-refuse"]
|
||||
assert SECRETARY_AGENT_ID not in orch._instances
|
||||
@@ -47,6 +47,7 @@ def _claim_task(
|
||||
last_heartbeat_at=None,
|
||||
active_claimant_id=None,
|
||||
orchestration_markers={},
|
||||
dev_notes=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -229,6 +230,30 @@ async def test_inherit_conflict_notes_the_task() -> None:
|
||||
assert note is not None
|
||||
assert "a.py, b.py" in note
|
||||
assert "sync_branch" in note
|
||||
# The dev must actually SEE it — dev_notes rides evidence(), the marker
|
||||
# does not.
|
||||
assert task.dev_notes is not None
|
||||
assert "a.py, b.py" in task.dev_notes
|
||||
assert "[BASE INHERITANCE]" in task.dev_notes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherit_merged_push_failed_notes_the_dev() -> None:
|
||||
svc = _service()
|
||||
task = _claim_task("feature/backend/AAA--BBB")
|
||||
proj_svc, git_svc, _ = _patched_deps(svc, {"status": "merged_push_failed"})
|
||||
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
MagicMock(return_value=proj_svc),
|
||||
),
|
||||
patch("roboco.services.git.get_git_service", MagicMock(return_value=git_svc)),
|
||||
):
|
||||
await svc._inherit_upstream_base(task, uuid4())
|
||||
|
||||
assert task.dev_notes is not None
|
||||
assert "push to origin failed" in task.dev_notes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user