feat(tg): P1 — Today home tab + one-round-trip /telegram/today brief

Mini App V4 phase 1: the cockpit now opens on a "Today" brief answering
"does anything need me?" in one glance.

Backend: GET /api/telegram/today (CEO-gated, rate-limited) returns the
whole brief in one round trip via the new TgCockpitService — needs-you
items (awaiting-CEO + blocked tasks capped for a phone screen, held-draft
counts across release/X/video/roadmap queues), a fleet snapshot with
per-agent current-task titles, today's spend from the day rollup
(degrading to zeros on a usage hiccup, mirroring the CEO overview), and
ship state. Deliberately DB-only: no live GitHub calls, no readiness
snapshot (that path clones), no orchestrator singleton — the CI
red/green proxy is the set of open ci_watch fix tasks.

Panel: TgTodayTab is the new default tab (Gauge icon) — needs-you rows
and draft chips deep-link into the tab that acts on them (with a haptic
tap), fleet/spend/ship render as dense cards, 45s refetch until the P3
WebSocket wiring lands.
This commit is contained in:
Renn F
2026-07-19 00:35:21 +02:00
parent ae21874817
commit 6ea57ca1a2
9 changed files with 777 additions and 5 deletions
@@ -29,6 +29,9 @@ vi.mock("@/lib/api/client", () => ({
vi.mock("@/components/tg/tg-tab-bar", () => ({
TgTabBar: () => <div data-testid="tg-tab-bar" />,
}));
vi.mock("@/components/tg/tg-today-tab", () => ({
TgTodayTab: () => <div data-testid="tg-today-tab" />,
}));
vi.mock("@/components/tg/tg-approvals-tab", () => ({
TgApprovalsTab: () => <div data-testid="tg-approvals-tab" />,
}));
@@ -96,8 +99,8 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
init_data: "real-init-data",
});
// Default tab is Approvals.
expect(screen.getByTestId("tg-approvals-tab")).toBeInTheDocument();
// Default tab is Today.
expect(screen.getByTestId("tg-today-tab")).toBeInTheDocument();
});
it("renders an error screen with the server's message when auth is refused", async () => {
+3 -1
View File
@@ -11,6 +11,7 @@ import {
import { startTelegramThemeSync } from "@/lib/telegram/theme";
import { TgWebAppProvider } from "@/lib/telegram/hooks";
import { TgTabBar, type TgTab } from "@/components/tg/tg-tab-bar";
import { TgTodayTab } from "@/components/tg/tg-today-tab";
import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab";
import { TgInboxTab } from "@/components/tg/tg-inbox-tab";
import { TgBoardTab } from "@/components/tg/tg-board-tab";
@@ -46,7 +47,7 @@ function CenteredMessage({ children }: { children: React.ReactNode }) {
*/
export default function TelegramMiniAppPage() {
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
const [tab, setTab] = useState<TgTab>("approvals");
const [tab, setTab] = useState<TgTab>("today");
useEffect(() => {
let cancelled = false;
@@ -129,6 +130,7 @@ export default function TelegramMiniAppPage() {
return (
<TgWebAppProvider webApp={state.webApp}>
<div className="p-3 pb-20">
{tab === "today" && <TgTodayTab onNavigate={setTab} />}
{tab === "approvals" && <TgApprovalsTab />}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TgTodayTab } from "../tg-today-tab";
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/lib/api/client", () => ({ default: { get } }));
function renderTab(onNavigate = vi.fn()) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<TgTodayTab onNavigate={onNavigate} />
</QueryClientProvider>,
);
return onNavigate;
}
function brief(overrides: Record<string, unknown> = {}) {
return {
needs_you: {
total: 0,
awaiting_ceo_count: 0,
awaiting_ceo: [],
blocked_count: 0,
blocked: [],
held_drafts: {
release_proposals: 0,
x_posts: 0,
video_posts: 0,
roadmap_items: 0,
},
},
fleet: { total: 3, by_status: { active: 3 }, working: [] },
spend: { tokens_today: 1_234_000, cost_today_usd: 12.34 },
ship: { version: "0.25.0", open_release_proposal: false, ci_fix_tasks: 0 },
...overrides,
};
}
describe("TgTodayTab", () => {
// Block body on purpose: returning the mock from beforeEach would make
// vitest call it as an after-test teardown hook.
beforeEach(() => {
get.mockReset();
});
it("shows skeletons while loading", () => {
get.mockReturnValue(new Promise(() => {}));
renderTab();
expect(document.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(
0,
);
});
it("renders the all-clear state and the spend/ship numbers", async () => {
get.mockResolvedValue({ data: brief() });
renderTab();
expect(await screen.findByText(/all clear/i)).toBeInTheDocument();
expect(screen.getByText("$12.34")).toBeInTheDocument();
expect(screen.getByText(/1\.2M tokens/)).toBeInTheDocument();
expect(screen.getByText("v0.25.0")).toBeInTheDocument();
expect(screen.getByText(/no release pending/i)).toBeInTheDocument();
expect(screen.getByText("3 agents")).toBeInTheDocument();
});
it("renders needs-you items and deep-links taps into the right tab", async () => {
get.mockResolvedValue({
data: brief({
needs_you: {
total: 3,
awaiting_ceo_count: 1,
awaiting_ceo: [
{
id: "t1",
title: "Root PR ready",
status: "awaiting_ceo_approval",
team: "backend",
updated_at: null,
},
],
blocked_count: 0,
blocked: [],
held_drafts: {
release_proposals: 1,
x_posts: 1,
video_posts: 0,
roadmap_items: 0,
},
},
}),
});
const onNavigate = vi.fn();
renderTab(onNavigate);
await userEvent.click(await screen.findByText(/Release · 1/));
expect(onNavigate).toHaveBeenCalledWith("approvals");
await userEvent.click(screen.getByText("Root PR ready"));
expect(onNavigate).toHaveBeenCalledWith("board");
});
it("shows an error state when the brief fails to load", async () => {
get.mockRejectedValue(new Error("boom"));
renderTab();
await waitFor(() =>
expect(screen.getByText(/couldn.t load/i)).toBeInTheDocument(),
);
});
});
+3 -2
View File
@@ -1,15 +1,16 @@
"use client";
import { CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react";
import { Gauge, CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
export type TgTab = "approvals" | "inbox" | "board" | "chat";
export type TgTab = "today" | "approvals" | "inbox" | "board" | "chat";
const TABS: ReadonlyArray<{
id: TgTab;
label: string;
icon: typeof CheckSquare;
}> = [
{ id: "today", label: "Today", icon: Gauge },
{ id: "approvals", label: "Approvals", icon: CheckSquare },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "board", label: "Board", icon: Kanban },
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api/client";
import { haptics } from "@/lib/telegram/webapp";
import type { TgTab } from "@/components/tg/tg-tab-bar";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertTriangle,
CheckCircle2,
ChevronRight,
CircleDollarSign,
Rocket,
Users,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
interface TodayTaskItem {
id: string;
title: string;
status: string;
team: string | null;
updated_at: string | null;
}
interface TodayBrief {
needs_you: {
total: number;
awaiting_ceo_count: number;
awaiting_ceo: TodayTaskItem[];
blocked_count: number;
blocked: TodayTaskItem[];
held_drafts: Record<string, number>;
};
fleet: {
total: number;
by_status: Record<string, number>;
working: Array<{
name: string;
role: string;
team: string | null;
task_title: string | null;
}>;
};
spend: { tokens_today: number; cost_today_usd: number };
ship: {
version: string;
open_release_proposal: boolean;
ci_fix_tasks: number;
};
}
const REFETCH_MS = 45_000;
const DRAFT_LABELS: Record<string, string> = {
release_proposals: "Release",
x_posts: "X posts",
video_posts: "Videos",
roadmap_items: "Roadmap",
};
const compactNumber = new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
});
function SectionCard({
icon: Icon,
title,
children,
}: {
icon: typeof Users;
title: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-lg border bg-card p-3 text-card-foreground">
<h2 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{title}
</h2>
{children}
</section>
);
}
function TaskRow({
task,
onOpen,
}: {
task: TodayTaskItem;
onOpen: () => void;
}) {
return (
<button
type="button"
onClick={onOpen}
className="flex w-full items-center gap-2 rounded-md py-1.5 text-left"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm leading-snug">{task.title}</p>
<p className="text-[11px] text-muted-foreground">
{task.team ?? "—"}
{task.updated_at &&
` · ${formatDistanceToNow(new Date(task.updated_at))} ago`}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
/**
* The cockpit's home screen: one glance answering "does anything need me?"
* — capped needs-you items, held-draft counts, fleet, today's spend, and
* ship state, off the single aggregated `/telegram/today` round trip.
* Row taps deep-link into the tab that acts on the item.
*/
export function TgTodayTab({
onNavigate,
}: {
onNavigate: (tab: TgTab) => void;
}) {
const { data, isLoading, isError } = useQuery<TodayBrief>({
queryKey: ["tg-today"],
queryFn: async () => (await api.get<TodayBrief>("/telegram/today")).data,
refetchInterval: REFETCH_MS,
});
if (isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
);
}
if (isError || !data) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<AlertTriangle className="h-8 w-8 opacity-50" />
<p className="text-sm">Couldn&apos;t load the brief</p>
</div>
);
}
const { needs_you: needs, fleet, spend, ship } = data;
const go = (tab: TgTab) => {
haptics.tap();
onNavigate(tab);
};
const heldEntries = Object.entries(needs.held_drafts).filter(
([, count]) => count > 0,
);
return (
<div className="space-y-3">
<SectionCard icon={CheckCircle2} title="Needs you">
{needs.total === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
All clear nothing is waiting on you.
</p>
) : (
<div className="space-y-2">
{heldEntries.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{heldEntries.map(([key, count]) => (
<button
key={key}
type="button"
onClick={() => go("approvals")}
className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary"
>
{DRAFT_LABELS[key] ?? key} · {count}
</button>
))}
</div>
)}
{needs.awaiting_ceo.map((t) => (
<TaskRow key={t.id} task={t} onOpen={() => go("board")} />
))}
{needs.blocked_count > 0 && (
<p className="text-[11px] font-medium text-destructive">
{needs.blocked_count} blocked
</p>
)}
{needs.blocked.map((t) => (
<TaskRow key={t.id} task={t} onOpen={() => go("board")} />
))}
</div>
)}
</SectionCard>
<SectionCard icon={Users} title="Fleet">
<div className="mb-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
<span>{fleet.total} agents</span>
{Object.entries(fleet.by_status).map(([status, count]) => (
<span key={status}>
{count} {status}
</span>
))}
</div>
{fleet.working.length === 0 ? (
<p className="text-sm text-muted-foreground">No one is mid-task.</p>
) : (
<ul className="space-y-1">
{fleet.working.map((agent) => (
<li key={agent.name} className="text-sm leading-snug">
<span className="font-medium">{agent.name}</span>
{agent.task_title && (
<span className="text-muted-foreground">
{" "}
{agent.task_title}
</span>
)}
</li>
))}
</ul>
)}
</SectionCard>
<div className="grid grid-cols-2 gap-3">
<SectionCard icon={CircleDollarSign} title="Spend today">
<p className="text-lg font-semibold tabular-nums">
${spend.cost_today_usd.toFixed(2)}
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{compactNumber.format(spend.tokens_today)} tokens
</p>
</SectionCard>
<SectionCard icon={Rocket} title="Ship">
<p className="text-lg font-semibold tabular-nums">v{ship.version}</p>
<p className="text-xs text-muted-foreground">
{ship.open_release_proposal
? "Release proposal waiting"
: ship.ci_fix_tasks > 0
? `${ship.ci_fix_tasks} CI fix open`
: "No release pending"}
</p>
</SectionCard>
</div>
</div>
);
}
+16
View File
@@ -23,6 +23,7 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.telegram import (
TelegramCredentialsSetRequest,
TelegramCredentialsStatus,
TelegramTodayResponse,
TelegramWebAppAuthRequest,
)
from roboco.config import settings
@@ -34,6 +35,7 @@ from roboco.services.telegram_credentials import (
TelegramCredentialsValidationError,
get_telegram_credentials_service,
)
from roboco.services.tg_cockpit import get_tg_cockpit_service
from roboco.utils.telegram_initdata import validate_init_data
if TYPE_CHECKING:
@@ -84,6 +86,20 @@ async def set_telegram_credentials(
return TelegramCredentialsStatus(has_credentials=has_creds)
@router.get("/today", response_model=TelegramTodayResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def get_today_brief(
db: DbSession, agent: CurrentAgentContext
) -> TelegramTodayResponse:
"""The Mini App's one-round-trip "Today" brief — needs-you items, fleet
snapshot, today's spend, ship state. DB-only by construction (see
``TgCockpitService``)."""
require_ceo_role(agent.role, action="view the Today brief")
return TelegramTodayResponse.model_validate(
await get_tg_cockpit_service(db).today()
)
# ==========================================================================
# Mini App sign-in — public, pre-auth. Conditionally mounted; see
# ``mount_telegram_miniapp_auth`` below.
+61
View File
@@ -1,5 +1,7 @@
"""Schemas for the Telegram notifications bridge's CEO surface."""
from datetime import datetime
from pydantic import BaseModel, Field
@@ -25,3 +27,62 @@ class TelegramWebAppAuthRequest(BaseModel):
"""
init_data: str = Field(min_length=1, max_length=4096)
# ==========================================================================
# Mini App "Today" brief — the cockpit's one-round-trip home screen.
# ==========================================================================
class TodayTaskItem(BaseModel):
"""One task row on the brief — just enough to recognize and jump."""
id: str
title: str
status: str
team: str | None = None
updated_at: datetime | None = None
class TodayNeedsYou(BaseModel):
"""Everything currently waiting on the CEO, capped for a phone screen."""
total: int
awaiting_ceo_count: int
awaiting_ceo: list[TodayTaskItem]
blocked_count: int
blocked: list[TodayTaskItem]
held_drafts: dict[str, int]
class TodayFleetAgent(BaseModel):
name: str
role: str
team: str | None = None
task_title: str | None = None
class TodayFleet(BaseModel):
total: int
by_status: dict[str, int]
working: list[TodayFleetAgent]
class TodaySpend(BaseModel):
tokens_today: int
cost_today_usd: float
class TodayShip(BaseModel):
version: str
open_release_proposal: bool
ci_fix_tasks: int
class TelegramTodayResponse(BaseModel):
"""The whole brief in one response — the Mini App opens on this."""
needs_you: TodayNeedsYou
fleet: TodayFleet
spend: TodaySpend
ship: TodayShip
+142
View File
@@ -0,0 +1,142 @@
"""TgCockpitService — the Mini App's one-round-trip "Today" brief.
Composes existing read paths (TaskService listers, DashboardService's agent
snapshot, UsageService's day rollup) into a single phone-sized payload
answering "does anything need me?". Deliberately DB-only and cheap: no live
GitHub calls, no release-readiness snapshot (that path clones + shells out to
git), no orchestrator singleton — the ship-state red/green proxy is the set
of open ci_watch fix tasks, which exists precisely when a watched repo went
red.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import TaskTable
from roboco.foundation.policy.content import markers
from roboco.services.base import BaseService
from roboco.services.dashboard import get_dashboard_service
from roboco.services.task import get_task_service
from roboco.services.usage import get_usage_service
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.services.task import TaskService
# Phone-screen caps: the brief shows the top few and a count, never a feed.
_TASK_ITEM_CAP = 5
_WORKING_AGENT_CAP = 8
def _task_item(task: TaskTable) -> dict[str, Any]:
return {
"id": str(task.id),
"title": task.title,
"status": task.status.value if task.status else "",
"team": task.team.value if task.team else None,
"updated_at": task.updated_at or task.created_at,
}
class TgCockpitService(BaseService):
"""Read-only aggregate behind ``GET /api/telegram/today``."""
service_name = "tg_cockpit"
async def today(self) -> dict[str, Any]:
tasks = get_task_service(self.session)
needs_you = await self._needs_you(tasks)
return {
"needs_you": needs_you,
"fleet": await self._fleet(),
"spend": await self._spend(),
"ship": {
"version": settings.app_version,
"open_release_proposal": needs_you["held_drafts"]["release_proposals"]
> 0,
"ci_fix_tasks": len(await tasks.list_open_ci_watch_tasks()),
},
}
async def _needs_you(self, tasks: TaskService) -> dict[str, Any]:
awaiting = await tasks.list_awaiting_ceo_approval()
blocked = await tasks.list_blocked()
held = {
"release_proposals": len(await tasks.list_open_release_proposals()),
"x_posts": len(await tasks.list_open_x_posts()),
"video_posts": len(await tasks.list_open_video_post_drafts()),
"roadmap_items": await self._pending_roadmap_items(tasks),
}
return {
"total": len(awaiting) + len(blocked) + sum(held.values()),
"awaiting_ceo_count": len(awaiting),
"awaiting_ceo": [_task_item(t) for t in awaiting[:_TASK_ITEM_CAP]],
"blocked_count": len(blocked),
"blocked": [_task_item(t) for t in blocked[:_TASK_ITEM_CAP]],
"held_drafts": held,
}
async def _pending_roadmap_items(self, tasks: TaskService) -> int:
"""Proposed (still-undecided) items across every open roadmap cycle."""
pending = 0
for cycle in await tasks.list_open_roadmap_cycles():
payload = markers.get_roadmap_cycle(cycle) or {}
pending += sum(
1
for item in payload.get("items") or []
if item.get("status") == "proposed"
)
return pending
async def _fleet(self) -> dict[str, Any]:
snapshot = await get_dashboard_service(self.session).get_all_agent_status()
agents: list[dict[str, Any]] = snapshot.get("agents", [])
working = [a for a in agents if a.get("current_task_id")][:_WORKING_AGENT_CAP]
titles = await self._task_titles([a["current_task_id"] for a in working])
return {
"total": snapshot.get("total", len(agents)),
"by_status": snapshot.get("by_status", {}),
"working": [
{
"name": a.get("name", ""),
"role": a.get("role", ""),
"team": a.get("team"),
"task_title": titles.get(str(a["current_task_id"])),
}
for a in working
],
}
async def _task_titles(self, task_ids: list[Any]) -> dict[str, str]:
if not task_ids:
return {}
ids: list[UUID] = [tid for tid in task_ids if tid is not None]
result = await self.session.execute(
select(TaskTable.id, TaskTable.title).where(TaskTable.id.in_(ids))
)
return {str(row.id): row.title for row in result}
async def _spend(self) -> dict[str, Any]:
# Mirrors the CEO dashboard overview: a usage hiccup degrades the
# brief to zeros instead of failing the whole endpoint.
try:
summary = await get_usage_service(self.session).get_today_summary()
return {
"tokens_today": int(summary.get("tokens_today", 0)),
"cost_today_usd": float(summary.get("cost_today_usd", 0.0)),
}
except Exception: # pragma: no cover - defensive degradation
self.log.warning("today-brief usage summary failed", exc_info=True)
return {"tokens_today": 0, "cost_today_usd": 0.0}
def get_tg_cockpit_service(session: AsyncSession) -> TgCockpitService:
"""Construct a TgCockpitService bound to ``session``."""
return TgCockpitService(session)
+187
View File
@@ -0,0 +1,187 @@
"""TgCockpitService coverage: the /telegram/today aggregate composes
needs-you counts, fleet snapshot, spend, and ship state from seeded rows —
and degrades to an all-zeros brief on an empty company.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.db.tables import AgentTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
Team,
)
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 (
RELEASE_MANAGER_SOURCE,
ROADMAP_SOURCE,
VIDEO_POST_SOURCE,
X_POST_SOURCE,
)
from roboco.services.tg_cockpit import get_tg_cockpit_service
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
CI_WATCH_SOURCE = "ci_watch"
# 1 awaiting + 1 blocked + 4 held drafts (release/x/video/roadmap-item).
EXPECTED_NEEDS_YOU_TOTAL = 6
async def _seed_system_agent(session: AsyncSession) -> None:
if await session.get(AgentTable, SYSTEM_UUID) is None:
session.add(
AgentTable(
id=SYSTEM_UUID,
name="system",
slug="system",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
def _task(
title: str,
task_status: TS,
*,
source: str | None = None,
team: Team = Team.BACKEND,
) -> TaskTable:
return TaskTable(
id=uuid4(),
title=title,
description="A description long enough to satisfy any length floor.",
acceptance_criteria=["it is visible on the Today brief"],
status=task_status,
priority=2,
task_type=TT.ADMINISTRATIVE,
nature=TN.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
created_by=SYSTEM_UUID,
team=team,
source=source,
confirmed_by_human=True,
)
async def _seed_working_agent(session: AsyncSession, current_task_id: UUID) -> None:
session.add(
AgentTable(
id=uuid4(),
name="be-dev-1",
slug="be-dev-1",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
current_task_id=current_task_id,
)
)
await session.flush()
@pytest.mark.asyncio
async def test_today_is_all_zeros_on_an_empty_company(
db_session: AsyncSession,
) -> None:
brief = await get_tg_cockpit_service(db_session).today()
assert brief["needs_you"]["total"] == 0
assert brief["needs_you"]["awaiting_ceo"] == []
assert brief["needs_you"]["blocked"] == []
assert brief["fleet"]["working"] == []
assert brief["spend"] == {"tokens_today": 0, "cost_today_usd": 0.0}
assert brief["ship"]["version"] == settings.app_version
assert brief["ship"]["open_release_proposal"] is False
assert brief["ship"]["ci_fix_tasks"] == 0
@pytest.mark.asyncio
async def test_today_composes_needs_you_fleet_and_ship(
db_session: AsyncSession,
) -> None:
await _seed_system_agent(db_session)
awaiting = _task("Root PR ready", TS.AWAITING_CEO_APPROVAL)
blocked = _task("Stuck on infra", TS.BLOCKED)
x_draft = _task("X draft", TS.PENDING, source=X_POST_SOURCE, team=Team.BOARD)
video_draft = _task(
"Video draft", TS.PENDING, source=VIDEO_POST_SOURCE, team=Team.BOARD
)
release_prop = _task(
"Release 0.26.0", TS.PENDING, source=RELEASE_MANAGER_SOURCE, team=Team.BOARD
)
ci_fix = _task("Fix red CI", TS.PENDING, source=CI_WATCH_SOURCE)
cycle = _task("Roadmap cycle", TS.PENDING, source=ROADMAP_SOURCE, team=Team.BOARD)
for row in (
awaiting,
blocked,
x_draft,
video_draft,
release_prop,
ci_fix,
cycle,
):
db_session.add(row)
await db_session.flush()
markers.set_roadmap_cycle(
cycle,
{
"goal": "g",
"items": [
{"id": "item-0", "status": "proposed"},
{"id": "item-1", "status": "approved"},
],
},
)
await _seed_working_agent(db_session, awaiting.id)
brief = await get_tg_cockpit_service(db_session).today()
needs = brief["needs_you"]
assert needs["awaiting_ceo_count"] == 1
assert needs["awaiting_ceo"][0]["title"] == "Root PR ready"
assert needs["awaiting_ceo"][0]["status"] == "awaiting_ceo_approval"
assert needs["blocked_count"] == 1
assert needs["held_drafts"] == {
"release_proposals": 1,
"x_posts": 1,
"video_posts": 1,
"roadmap_items": 1,
}
assert needs["total"] == EXPECTED_NEEDS_YOU_TOTAL
working = brief["fleet"]["working"]
assert len(working) == 1
assert working[0]["name"] == "be-dev-1"
assert working[0]["task_title"] == "Root PR ready"
assert brief["ship"]["open_release_proposal"] is True
assert brief["ship"]["ci_fix_tasks"] == 1