feat(marketing): project-branded drafts + project badges on the X/video queues (#570)

Item B+C of the video/X per-project targeting spec, plus the
company_goals.company_name field they depend on (migration 075).

- CompanyGoalsService.resolve_product_name is the single fallback chain
  (project name -> charter company_name -> RoboCo); XEngine and
  VideoEngine both call it and their prompt builders are pure functions
  taking product_name — release posts/videos stop hardcoding RoboCo.
- The X and video queue responses carry project_slug/project_name via one
  shared unloaded-guard helper (api/schemas/project_fields.py); both
  panel queues render a shared ProjectBadge so multi-project drafts are
  tellable apart.
- Business -> Goals editor gains the company-name input.
- Fixes a pre-existing test-isolation leak: the company-goals routes test
  commits the charter singleton into the session-scoped test DB and
  polluted later suites; it now deletes the row on teardown.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 19:11:03 +02:00
committed by GitHub
co-authored by Renn F
parent 388bab2488
commit 7e01c0cecf
30 changed files with 750 additions and 40 deletions
@@ -0,0 +1,32 @@
"""Add company_goals.company_name — brands X/video drafting prompts.
CEO-authored product/company name (mirrors ``brand_voice``, migration 061):
feeds ``XEngine``/``VideoEngine``'s product-name resolution as the fallback
below a project's own name and above the "RoboCo" literal default. Additive
and inert until the CEO sets it in the Business -> Goals editor.
Revision ID: 075_company_goals_company_name
Revises: 074_telegram_credentials
Create Date: 2026-07-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "075_company_goals_company_name"
down_revision = "074_telegram_credentials"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.add_column(
"company_goals",
sa.Column("company_name", sa.Text(), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("company_goals", "company_name")
@@ -50,6 +50,7 @@ function buildGoals(overrides: Partial<CompanyGoals> = {}): CompanyGoals {
constraints: [],
operating_policy: {},
brand_voice: "",
company_name: "",
updated_at: null,
updated_by: null,
...overrides,
+19 -1
View File
@@ -249,6 +249,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const [northStar, setNorthStar] = useState<string | null>(null);
const [brandVoice, setBrandVoice] = useState<string | null>(null);
const [companyName, setCompanyName] = useState<string | null>(null);
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<
Record<string, unknown>[] | null
@@ -257,6 +258,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const northStarVal = northStar ?? goals.north_star ?? "";
const brandVoiceVal = brandVoice ?? goals.brand_voice ?? "";
const companyNameVal = companyName ?? goals.company_name ?? "";
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
const objectivesVal = objectives ?? goals.objectives ?? [];
const policyVal = policy ?? goals.operating_policy ?? {};
@@ -267,6 +269,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
setNorthStar(null);
setBrandVoice(null);
setCompanyName(null);
setConstraints(null);
setObjectives(null);
setPolicy(null);
@@ -281,6 +284,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
saveMutation.mutate({
north_star: northStarVal,
brand_voice: brandVoiceVal,
company_name: companyNameVal,
objectives: objectivesVal,
constraints: constraintsVal
.split("\n")
@@ -333,6 +337,20 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
/>
</div>
{/* Company / product name */}
<div className="space-y-2">
<HelpTip label="Brands X/video marketing drafts when a project name isn't available — falls back to 'RoboCo' when both are unset">
<Label htmlFor="company-name">Company / product name</Label>
</HelpTip>
<Input
id="company-name"
value={companyNameVal}
disabled={saving}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="RoboCo"
/>
</div>
{/* Constraints */}
<div className="space-y-2">
<HelpTip label="Hard rules injected into every agent's briefing (e.g. licensing, no external data egress)">
@@ -373,7 +391,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
</div>
{/* Save */}
<HelpTip label="Saves north star, brand voice, objectives, constraints, and operating policy in one update">
<HelpTip label="Saves north star, brand voice, company/product name, objectives, constraints, and operating policy in one update">
<span className="inline-block">
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
@@ -141,6 +141,32 @@ describe("VideoPostQueue", () => {
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
});
it("renders a project badge when project_slug/project_name is present", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "Acme Robotics v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "Acme Robotics v0.19.0 is here!",
tiktok_caption: "New Acme Robotics drop!",
mp4_paths: {
vertical: "/fake/vertical.mp4",
square: "/fake/square.mp4",
},
project_slug: "acme-robotics",
project_name: "Acme Robotics",
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText("Acme Robotics")).toBeInTheDocument();
});
it("shows the fetched title and script instead of dropping them", async () => {
render(withQueryClient(<VideoPostQueue />));
expect(
@@ -93,6 +93,26 @@ describe("XPostQueue", () => {
expect(screen.getByText(/Playbook curation/)).toBeInTheDocument();
});
it("renders a project badge when project_slug/project_name is present", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "x-4",
source: "x_post",
title: "X post: release v0.18.0",
status: "pending",
body: "Acme Robotics v0.18.0 just shipped!",
char_count: 36,
release_version: "0.18.0",
project_slug: "acme-robotics",
project_name: "Acme Robotics",
},
] as XPost[]);
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Acme Robotics")).toBeInTheDocument();
});
it("disables only the row being approved, not every row's Approve", async () => {
render(withQueryClient(<XPostQueue />));
@@ -0,0 +1,36 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
/**
* Shared project (repository) badge — renders nothing when neither field is
* present. Used by both x-post-queue.tsx and video-post-queue.tsx, whose
* queue rows are otherwise identical here except for the tooltip wording.
*
* @example
* ```tsx
* <ProjectBadge
* slug={post.project_slug}
* name={post.project_name}
* label="The project (repository) this draft targets"
* />
* ```
*/
export function ProjectBadge({
slug,
name,
label,
}: {
slug?: string | null;
name?: string | null;
/** Tooltip text — phrase it per caller ("draft" vs "video"). */
label: string;
}) {
if (!slug && !name) return null;
return (
<HelpTip label={label}>
<Badge variant="outline">{name || slug}</Badge>
</HelpTip>
);
}
@@ -35,6 +35,7 @@ import { HelpTip } from "@/components/ui/help-tip";
import { ProjectSelector } from "@/components/projects/project-selector";
import { useProjects } from "@/hooks/use-projects";
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
import { ProjectBadge } from "@/components/dashboard/project-badge";
import { CheckCircle2, Film, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
@@ -222,6 +223,11 @@ function VideoPostRow({
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
<ProjectBadge
slug={post.project_slug}
name={post.project_name}
label="The project (repository) this video targets"
/>
{post.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{post.occasion}</Badge>
@@ -24,6 +24,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { ProjectBadge } from "@/components/dashboard/project-badge";
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
@@ -108,6 +109,11 @@ function XPostRow({
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
<ProjectBadge
slug={post.project_slug}
name={post.project_name}
label="The project (repository) this draft targets"
/>
{post.release_version && (
<HelpTip label="The release this post announces">
<Badge variant="outline">v{post.release_version}</Badge>
+2
View File
@@ -6,6 +6,7 @@ export interface CompanyGoals {
constraints: string[];
operating_policy: Record<string, unknown>;
brand_voice: string;
company_name: string;
updated_at?: string | null;
updated_by?: string | null;
}
@@ -18,6 +19,7 @@ export type CompanyGoalsUpdate = Partial<
| "constraints"
| "operating_policy"
| "brand_voice"
| "company_name"
>
>;
+6
View File
@@ -29,6 +29,8 @@ export interface VideoPost {
// "not stale" everywhere below, so the re-render control degrades safely.
composition_id?: string | null;
render_status?: string | null; // null | "rendered" | "failed"
project_slug?: string | null;
project_name?: string | null;
}
// One in-flight source=video authoring task — GET /video/pipeline
@@ -46,6 +48,8 @@ export interface VideoPipelineItem {
render_attempts: number;
max_attempts: number;
render_error: string | null;
project_slug?: string | null;
project_name?: string | null;
}
export interface VideoPostExecuteResult {
@@ -69,6 +73,8 @@ export interface VideoPostHistoryEntry {
posted: Record<string, string>; // platform -> posted id
acted_at: string;
source_task_id?: string | null; // the authoring task this draft rendered from
project_slug?: string | null;
project_name?: string | null;
}
export interface VideoRequestResult {
+4
View File
@@ -29,6 +29,8 @@ export interface XPost {
mention?: XMentionRef | null;
feature?: XFeatureRef | null;
reject_reason?: string | null;
project_slug?: string | null;
project_name?: string | null;
}
export interface XPostExecuteResult {
@@ -51,6 +53,8 @@ export interface XPostHistoryEntry {
tweet_id?: string | null;
reject_reason?: string | null;
acted_at: string;
project_slug?: string | null;
project_name?: string | null;
}
export interface XCredentialsStatus {
+10
View File
@@ -14,6 +14,7 @@ from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import FileResponse, StreamingResponse
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.project_fields import task_project_fields
from roboco.api.schemas.video import (
TikTokCredentialsSetRequest,
TikTokCredentialsStatus,
@@ -143,6 +144,7 @@ def _status_value(task: TaskTable) -> str:
def _to_response(task: TaskTable) -> VideoPostResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPostResponse(
task_id=str(task.id),
source=task.source,
@@ -156,6 +158,8 @@ def _to_response(task: TaskTable) -> VideoPostResponse:
reject_reason=markers.get_video_reject_reason(task),
mp4_paths=dict(draft.get("mp4_paths") or {}),
source_task_id=draft.get("source_task_id"),
project_slug=project_slug,
project_name=project_name,
)
@@ -185,6 +189,7 @@ async def list_video_posts(
def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPipelineItemResponse(
task_id=str(task.id),
title=task.title,
@@ -195,6 +200,8 @@ def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
render_status=draft.get("render_status"),
render_attempts=int(draft.get("render_attempts", 0)),
render_error=draft.get("render_error"),
project_slug=project_slug,
project_name=project_name,
)
@@ -300,6 +307,7 @@ def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPostHistoryResponse(
task_id=str(task.id),
source=task.source,
@@ -314,6 +322,8 @@ def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
posted=_posted_ids(draft),
acted_at=task.updated_at or task.created_at,
source_task_id=draft.get("source_task_id"),
project_slug=project_slug,
project_name=project_name,
)
+7
View File
@@ -8,6 +8,7 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.project_fields import task_project_fields
from roboco.api.schemas.x import (
XCredentialsSetRequest,
XCredentialsStatus,
@@ -46,6 +47,7 @@ def _to_response(task: "TaskTable") -> XPostResponse:
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
project_slug, project_name = task_project_fields(task)
return XPostResponse(
task_id=str(task.id),
source=task.source,
@@ -57,6 +59,8 @@ def _to_response(task: "TaskTable") -> XPostResponse:
mention=XMentionRefModel(**mention) if mention else None,
feature=XFeatureRefModel(**feature) if feature else None,
reject_reason=markers.get_x_reject_reason(task),
project_slug=project_slug,
project_name=project_name,
)
@@ -74,6 +78,7 @@ def _to_history_response(task: "TaskTable") -> XPostHistoryResponse:
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
project_slug, project_name = task_project_fields(task)
return XPostHistoryResponse(
task_id=str(task.id),
source=task.source,
@@ -87,6 +92,8 @@ def _to_history_response(task: "TaskTable") -> XPostHistoryResponse:
tweet_id=markers.get_x_posted_tweet_id(task),
reject_reason=markers.get_x_reject_reason(task),
acted_at=task.updated_at or task.created_at,
project_slug=project_slug,
project_name=project_name,
)
+2
View File
@@ -13,6 +13,7 @@ class CompanyGoalsResponse(BaseModel):
constraints: list[str]
operating_policy: dict[str, Any]
brand_voice: str = ""
company_name: str = ""
updated_at: str | None = None
updated_by: str | None = None
@@ -25,3 +26,4 @@ class CompanyGoalsUpdate(BaseModel):
constraints: list[str] | None = Field(default=None)
operating_policy: dict[str, Any] | None = Field(default=None)
brand_voice: str | None = Field(default=None)
company_name: str | None = Field(default=None)
+28
View File
@@ -0,0 +1,28 @@
"""Shared (project_slug, project_name) response-builder helper.
Both the X and video engine queues surface which project a held draft/video
targets. Extracted here so the X and video routes' five response builders call
ONE implementation instead of five inline copies the same
``sa_inspect(task).unloaded`` guard convention ``task_to_response`` uses.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import inspect as sa_inspect
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
def task_project_fields(task: TaskTable) -> tuple[str | None, str | None]:
"""(project_slug, project_name), or (None, None).
A freshly-created task can have an unloaded ``project`` relationship, so a
sync attribute access would raise MissingGreenlet checked via
``sa_inspect(task).unloaded`` before ever touching ``task.project``.
"""
if "project" in sa_inspect(task).unloaded or task.project is None:
return None, None
return task.project.slug, task.project.name
+6
View File
@@ -42,6 +42,8 @@ class VideoPostResponse(BaseModel):
reject_reason: str | None = None
mp4_paths: dict[str, str] = Field(default_factory=dict)
source_task_id: str | None = None # the authoring task this draft rendered from
project_slug: str | None = None
project_name: str | None = None
class VideoPostApproveRequest(BaseModel):
@@ -84,6 +86,8 @@ class VideoPostHistoryResponse(BaseModel):
posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id
acted_at: datetime
source_task_id: str | None = None # the authoring task this draft rendered from
project_slug: str | None = None
project_name: str | None = None
class VideoPipelineItemResponse(BaseModel):
@@ -103,6 +107,8 @@ class VideoPipelineItemResponse(BaseModel):
render_attempts: int = 0
max_attempts: int = MAX_VIDEO_RENDER_ATTEMPTS
render_error: str | None = None
project_slug: str | None = None
project_name: str | None = None
class TikTokCredentialsStatus(BaseModel):
+4
View File
@@ -36,6 +36,8 @@ class XPostResponse(BaseModel):
mention: XMentionRefModel | None = None
feature: XFeatureRefModel | None = None
reject_reason: str | None = None
project_slug: str | None = None
project_name: str | None = None
class XPostApproveRequest(BaseModel):
@@ -73,6 +75,8 @@ class XPostHistoryResponse(BaseModel):
tweet_id: str | None = None
reject_reason: str | None = None
acted_at: datetime
project_slug: str | None = None
project_name: str | None = None
class XCredentialsStatus(BaseModel):
+5
View File
@@ -1716,6 +1716,11 @@ class CompanyGoalsTable(Base):
# JSON blob, so it gets the same discoverability. Feeds XEngine._voice_guide
# and the Head of Marketing's briefing; empty until the CEO sets it.
brand_voice: Mapped[str] = mapped_column(Text, nullable=False, default="")
# CEO-authored product/company name — brands X/video drafting prompts
# when a project name isn't available (mirrors brand_voice's shape).
# Feeds XEngine/VideoEngine's product-name resolution; "RoboCo" is the
# final fallback when this is also unset.
company_name: Mapped[str] = mapped_column(Text, nullable=False, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
+21
View File
@@ -19,6 +19,8 @@ from roboco.services.base import BaseService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable
# Canonical single-row marker — the charter is a singleton.
SINGLETON_ID = UUID("00000000-0000-0000-0000-000000000000")
@@ -28,6 +30,7 @@ _EMPTY: dict[str, Any] = {
"constraints": [],
"operating_policy": {},
"brand_voice": "",
"company_name": "",
"updated_at": None,
"updated_by": None,
}
@@ -64,11 +67,28 @@ class CompanyGoalsService(BaseService):
row.operating_policy = data["operating_policy"]
if "brand_voice" in data:
row.brand_voice = data["brand_voice"]
if "company_name" in data:
row.company_name = data["company_name"]
if updated_by is not None:
row.updated_by = updated_by
await self.session.flush()
return self._to_dict(row)
async def resolve_product_name(self, project: ProjectTable | None) -> str:
"""The shared product-name fallback chain: ``project``'s own name,
else this charter's ``company_name``, else the "RoboCo" literal.
The single source of this resolution XEngine and VideoEngine both
call it (rather than each keeping its own copy) so a drafted post/
video is never unbranded when neither identity source is set, and the
fallback order can't drift between the two callers.
"""
if project is not None and project.name:
return project.name
charter = await self.get()
company_name = (charter.get("company_name") or "").strip()
return company_name or "RoboCo"
@staticmethod
def _to_dict(row: CompanyGoalsTable) -> dict[str, Any]:
return {
@@ -77,6 +97,7 @@ class CompanyGoalsService(BaseService):
"constraints": row.constraints or [],
"operating_policy": row.operating_policy or {},
"brand_voice": row.brand_voice or "",
"company_name": row.company_name or "",
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": str(row.updated_by) if row.updated_by else None,
}
+30 -16
View File
@@ -129,30 +129,32 @@ def _first_changelog_bullet(changelog: str) -> str:
return highlights[0] if highlights else ""
def _fallback_release_script(version: str, changelog: str) -> str:
def _fallback_release_script(version: str, changelog: str, product_name: str) -> str:
highlight = _first_changelog_bullet(changelog)
lead = f": {highlight}" if highlight else ""
return f"RoboCo v{version} just shipped{lead}."
return f"{product_name} v{version} just shipped{lead}."
def _release_video_prompt(version: str, changelog: str) -> str:
def _release_video_prompt(version: str, changelog: str, product_name: str) -> str:
return (
"You are RoboCo's marketing team, writing a short voiceover script "
"for a bespoke motion-graphics video announcing a release. Plain "
"text, 2-3 short sentences, energetic but factual — no invented "
"facts.\n\n"
f"Write the script for RoboCo v{version}, based on this CHANGELOG "
f"entry:\n{changelog[:1000]}\n"
f"You are {product_name}'s marketing team, writing a short voiceover "
"script for a bespoke motion-graphics video announcing a release. "
"Plain text, 2-3 short sentences, energetic but factual — no "
"invented facts.\n\n"
f"Write the script for {product_name} v{version}, based on this "
f"CHANGELOG entry:\n{changelog[:1000]}\n"
)
def _release_video_brief(version: str, changelog: str, highlights: list[str]) -> str:
def _release_video_brief(
version: str, changelog: str, highlights: list[str], product_name: str
) -> str:
"""The structured release brief: the (capped) CHANGELOG section for this
version plus its highlights list replaces the old one-liner-as-
description. The LLM script stays a separate ``script`` prop suggestion,
never the whole brief."""
section = changelog[:_CHANGELOG_BRIEF_CHARS].strip() or "(no changelog entry)"
parts = [f"RoboCo v{version} release notes:", section]
parts = [f"{product_name} v{version} release notes:", section]
if highlights:
bullets = "\n".join(f"- {h}" for h in highlights)
parts.append(f"Highlights:\n{bullets}")
@@ -455,9 +457,17 @@ class VideoEngine(BaseService):
"""
if not (settings.video_engine_enabled and settings.video_on_release):
return None
script = await self._draft_release_script(version, changelog)
project = (
await get_project_service(self.session).get(project_id)
if project_id is not None
else None
)
product_name = await get_company_goals_service(
self.session
).resolve_product_name(project)
script = await self._draft_release_script(version, changelog, product_name)
highlights = _changelog_highlights(changelog)
brief = _release_video_brief(version, changelog, highlights)
brief = _release_video_brief(version, changelog, highlights, product_name)
return await self.open_video_task(
occasion=f"release {version}",
script=script,
@@ -467,16 +477,20 @@ class VideoEngine(BaseService):
project_id=project_id,
)
async def _draft_release_script(self, version: str, changelog: str) -> str:
async def _draft_release_script(
self, version: str, changelog: str, product_name: str
) -> str:
try:
draft = await _chat(_release_video_prompt(version, changelog))
draft = await _chat(_release_video_prompt(version, changelog, product_name))
except Exception as exc:
self.log.warning(
"video-engine: local-model script draft failed (fallback template)",
error=str(exc),
)
draft = None
return (draft or "").strip() or _fallback_release_script(version, changelog)
return (draft or "").strip() or _fallback_release_script(
version, changelog, product_name
)
# ---- held draft (materialized once a render pass produces MP4s) -------
+38 -18
View File
@@ -78,11 +78,14 @@ if TYPE_CHECKING:
_CHAT_TIMEOUT_SECONDS = 60.0
_MIN_MENTION_CHARS = 3
_HOM_VOICE = (
"You are RoboCo's Head of Marketing, posting on the company's X (Twitter) "
"account. Confident and concise, no emoji spam, no hashtags unless truly "
"apt. Speak as 'we'. Plain text only, no markdown, no thread — one post."
)
def _hom_voice(product_name: str) -> str:
return (
f"You are {product_name}'s Head of Marketing, posting on the company's "
"X (Twitter) account. Confident and concise, no emoji spam, no "
"hashtags unless truly apt. Speak as 'we'. Plain text only, no "
"markdown, no thread — one post."
)
def _clamp_tweet(text: str) -> str:
@@ -93,16 +96,20 @@ def _clamp_tweet(text: str) -> str:
return collapsed[: MAX_TWEET_CHARS - 1].rstrip() + ""
def _fallback_release_body(version: str, highlights: list[str]) -> str:
def _fallback_release_body(
version: str, highlights: list[str], product_name: str
) -> str:
lead = highlights[0] if highlights else "assorted improvements"
return f"RoboCo v{version} is out: {lead}"
return f"{product_name} v{version} is out: {lead}"
def _release_prompt(version: str, highlights: list[str], voice: str) -> str:
def _release_prompt(
version: str, highlights: list[str], voice: str, product_name: str
) -> str:
bullets = "\n".join(f"- {h}" for h in highlights[:5]) or "- routine improvements"
return (
f"{voice}\n\n"
f"Draft ONE tweet (max 280 characters) announcing that RoboCo "
f"Draft ONE tweet (max 280 characters) announcing that {product_name} "
f"v{version} just shipped. Lead with the most user-visible change.\n\n"
f"Highlights:\n{bullets}\n"
)
@@ -249,7 +256,7 @@ class XEngine(BaseService):
return await get_project_service(self.session).get(project_id)
return await self._roboco_project()
async def _voice_guide(self) -> str:
async def _voice_guide(self, product_name: str) -> str:
"""Baseline house style plus the CEO's brand-voice sample, when set.
The CEO-supplied sample lives in the company charter (``company_goals.
@@ -259,10 +266,11 @@ class XEngine(BaseService):
"""
charter = await get_company_goals_service(self.session).get()
brand_voice = (charter.get("brand_voice") or "").strip()
hom_voice = _hom_voice(product_name)
if not brand_voice:
return _HOM_VOICE
return hom_voice
return (
f"{_HOM_VOICE}\n\n"
f"{hom_voice}\n\n"
f"Additional brand-voice direction from the CEO:\n{brand_voice}"
)
@@ -306,7 +314,10 @@ class XEngine(BaseService):
version=version,
)
return None
body = await self._draft_release_body(version, highlights)
product_name = await get_company_goals_service(
self.session
).resolve_product_name(project)
body = await self._draft_release_body(version, highlights, product_name)
task = await self._originate_post(
title=f"X post: release v{version}",
body=body,
@@ -318,17 +329,23 @@ class XEngine(BaseService):
self.log.info("x-engine: release post drafted (held for CEO)", version=version)
return task
async def _draft_release_body(self, version: str, highlights: list[str]) -> str:
voice = await self._voice_guide()
async def _draft_release_body(
self, version: str, highlights: list[str], product_name: str
) -> str:
voice = await self._voice_guide(product_name)
try:
draft = await _chat(_release_prompt(version, highlights, voice))
draft = await _chat(
_release_prompt(version, highlights, voice, product_name)
)
except Exception as exc:
self.log.warning(
"x-engine: local-model draft failed (fallback template)",
error=str(exc),
)
draft = None
body = (draft or "").strip() or _fallback_release_body(version, highlights)
body = (draft or "").strip() or _fallback_release_body(
version, highlights, product_name
)
return _clamp_tweet(body)
# ---- mentions (periodic poll) ------------------------------------------
@@ -443,7 +460,10 @@ class XEngine(BaseService):
return task
async def _draft_reply_body(self, screened_mention_text: str) -> str:
voice = await self._voice_guide()
# Reply drafts are always from RoboCo's own X account (company-scoped
# by design, unlike a release/spotlight post which can target any
# project) — the literal is intentional, not a missed thread.
voice = await self._voice_guide("RoboCo")
try:
draft = await _chat(_reply_prompt(screened_mention_text, voice))
except Exception as exc:
+32 -1
View File
@@ -3,22 +3,40 @@
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import HTTPException
from roboco.api.routes.company_goals import get_company_goals, update_company_goals
from roboco.api.schemas.company_goals import CompanyGoalsUpdate
from roboco.db.tables import CompanyGoalsTable
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from sqlalchemy import delete
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
@pytest_asyncio.fixture(autouse=True)
async def _cleanup_company_goals(db_session: Any) -> AsyncIterator[None]:
yield
# update_company_goals() calls db.commit() (real behavior — see the route
# docstring), so a CEO update here persists past the per-test rollback
# into every later test in this session-scoped DB run. Delete the
# singleton row so it reads back to empty defaults for whatever runs
# next (mirrors the ceo_client teardown in test_release_routes.py).
await db_session.execute(delete(CompanyGoalsTable))
await db_session.commit()
@pytest.mark.asyncio
async def test_get_returns_charter_to_any_agent(db_session: Any) -> None:
resp = await get_company_goals(db_session, _agent(AgentRole.DEVELOPER))
@@ -52,6 +70,19 @@ async def test_ceo_can_update_and_persist_brand_voice(db_session: Any) -> None:
assert again.brand_voice == "Confident, dry wit."
@pytest.mark.asyncio
async def test_ceo_can_update_and_persist_company_name(db_session: Any) -> None:
ceo = _agent(AgentRole.CEO)
resp = await update_company_goals(
CompanyGoalsUpdate(company_name="Acme Robotics"), db_session, ceo
)
assert resp.company_name == "Acme Robotics"
# Persisted and readable by a non-CEO agent (round-trips through the
# Pydantic response schema, not just the service-layer dict).
again = await get_company_goals(db_session, _agent(AgentRole.QA))
assert again.company_name == "Acme Robotics"
@pytest.mark.asyncio
async def test_non_ceo_cannot_update() -> None:
# The CEO check fires before any DB access, so a dummy session suffices.
+12
View File
@@ -414,6 +414,7 @@ async def test_list_posts_returns_open_draft(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
project = await db_session.get(ProjectTable, task.project_id)
resp = await ceo_client.get("/api/video/posts")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
@@ -425,6 +426,9 @@ async def test_list_posts_returns_open_draft(
"square": "/render/out/1-square.mp4",
"vertical": "/render/out/1-vertical.mp4",
}
assert project is not None
assert body[0]["project_slug"] == project.slug
assert body[0]["project_name"] == project.name
@pytest.mark.asyncio
@@ -471,6 +475,7 @@ async def test_pipeline_lists_non_terminal_authoring_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
project = await db_session.get(ProjectTable, task.project_id)
resp = await ceo_client.get("/api/video/pipeline")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
@@ -481,6 +486,9 @@ async def test_pipeline_lists_non_terminal_authoring_task(
assert row["render_attempts"] == 0
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
assert row["render_error"] is None
assert project is not None
assert row["project_slug"] == project.slug
assert row["project_name"] == project.name
@pytest.mark.asyncio
@@ -706,6 +714,7 @@ async def test_history_returns_posted_and_rejected_newest_first(
json={"reason": "wrong occasion"},
)
posted = await _seed_draft(db_session, platforms=["x"])
posted_project = await db_session.get(ProjectTable, posted.project_id)
creds_svc = get_x_credentials_service(db_session)
await creds_svc.set_credentials(
api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
@@ -736,6 +745,9 @@ async def test_history_returns_posted_and_rejected_newest_first(
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
assert posted_row["status"] == "completed"
assert posted_row["posted"] == {"x": "xid42"}
assert posted_project is not None
assert posted_row["project_slug"] == posted_project.slug
assert posted_row["project_name"] == posted_project.name
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
assert rejected_row["status"] == "cancelled"
assert rejected_row["reject_reason"] == "wrong occasion"
+8
View File
@@ -132,6 +132,7 @@ async def test_list_posts_returns_open_draft(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
project = await db_session.get(ProjectTable, task.project_id)
resp = await ceo_client.get("/api/x/posts")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
@@ -139,6 +140,9 @@ async def test_list_posts_returns_open_draft(
assert body[0]["task_id"] == str(task.id)
assert body[0]["body"] == "draft body"
assert body[0]["release_version"] == "0.17.0"
assert project is not None
assert body[0]["project_slug"] == project.slug
assert body[0]["project_name"] == project.name
@pytest.mark.asyncio
@@ -204,6 +208,7 @@ async def test_history_returns_posted_and_rejected_newest_first(
f"/api/x/posts/{rejected.id}/reject", json={"reason": "off-brand tone"}
)
posted = await _seed_draft(db_session)
posted_project = await db_session.get(ProjectTable, posted.project_id)
with (
patch(
"roboco.services.x_post_service.build_x_client",
@@ -224,6 +229,9 @@ async def test_history_returns_posted_and_rejected_newest_first(
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
assert posted_row["status"] == "completed"
assert posted_row["tweet_id"] == "42"
assert posted_project is not None
assert posted_row["project_slug"] == posted_project.slug
assert posted_row["project_name"] == posted_project.name
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
assert rejected_row["status"] == "cancelled"
assert rejected_row["reject_reason"] == "off-brand tone"
@@ -0,0 +1,93 @@
"""``roboco/api/routes/video.py`` response-builder wiring for project_slug/
project_name. The sa_inspect(task).unloaded guard branches themselves are
covered once on the shared helper in tests/unit/api/schemas/test_project_fields.py
this only asserts the three builders actually populate the response from it
(loaded case; a real ORM task always resolves the "loaded" branch since
``project`` is lazy="joined")."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
from roboco.api.routes.video import (
_to_history_response,
_to_pipeline_item,
_to_response,
)
def _stub_task(*, with_project: bool = False) -> Any:
"""A TaskTable stand-in matching the three response builders' reads."""
return SimpleNamespace(
id="task-1",
source="video_post",
title="Video post: release 1.0.0",
status="pending",
pr_number=None,
orchestration_markers=None,
project=(
SimpleNamespace(slug="acme-robotics", name="Acme Robotics")
if with_project
else None
),
updated_at=None,
created_at="2026-07-18T00:00:00+00:00",
)
def _loaded_inspector() -> MagicMock:
inspector = MagicMock()
inspector.unloaded = set()
return inspector
def test_to_response_includes_project_fields_when_loaded() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_response(_stub_task(with_project=True))
assert resp.project_slug == "acme-robotics"
assert resp.project_name == "Acme Robotics"
def test_to_pipeline_item_includes_project_fields_when_loaded() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_pipeline_item(_stub_task(with_project=True))
assert resp.project_slug == "acme-robotics"
assert resp.project_name == "Acme Robotics"
def test_to_pipeline_item_omits_project_fields_when_project_unset() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_pipeline_item(_stub_task(with_project=False))
assert resp.project_slug is None
assert resp.project_name is None
def test_to_history_response_includes_project_fields_when_loaded() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_history_response(_stub_task(with_project=True))
assert resp.project_slug == "acme-robotics"
assert resp.project_name == "Acme Robotics"
def test_to_history_response_omits_project_fields_when_project_unset() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_history_response(_stub_task(with_project=False))
assert resp.project_slug is None
assert resp.project_name is None
@@ -0,0 +1,79 @@
"""``roboco/api/routes/x.py`` response-builder wiring for project_slug/
project_name. The sa_inspect(task).unloaded guard branches themselves are
covered once on the shared helper in tests/unit/api/schemas/test_project_fields.py
this only asserts _to_response/_to_history_response actually populate
the response from it (loaded case; a real ORM task always resolves the
"loaded" branch since ``project`` is lazy="joined")."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
from roboco.api.routes.x import _to_history_response, _to_response
def _stub_task(*, with_project: bool = False) -> Any:
"""A TaskTable stand-in matching _to_response/_to_history_response's reads."""
return SimpleNamespace(
id="task-1",
source="x_post",
title="X post: release v1.0.0",
status="pending",
description="",
orchestration_markers=None,
project=(
SimpleNamespace(slug="acme-robotics", name="Acme Robotics")
if with_project
else None
),
updated_at=None,
created_at="2026-07-18T00:00:00+00:00",
)
def _loaded_inspector() -> MagicMock:
inspector = MagicMock()
inspector.unloaded = set()
return inspector
def test_to_response_includes_project_fields_when_loaded() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_response(_stub_task(with_project=True))
assert resp.project_slug == "acme-robotics"
assert resp.project_name == "Acme Robotics"
def test_to_response_omits_project_fields_when_project_unset() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_response(_stub_task(with_project=False))
assert resp.project_slug is None
assert resp.project_name is None
def test_to_history_response_includes_project_fields_when_loaded() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_history_response(_stub_task(with_project=True))
assert resp.project_slug == "acme-robotics"
assert resp.project_name == "Acme Robotics"
def test_to_history_response_omits_project_fields_when_project_unset() -> None:
with patch(
"roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(),
):
resp = _to_history_response(_stub_task(with_project=False))
assert resp.project_slug is None
assert resp.project_name is None
@@ -0,0 +1,52 @@
"""``task_project_fields`` — the shared (project_slug, project_name)
response-builder helper the X and video routes' five builders all call.
Mirrors tests/unit/api/test_schemas_tasks.py's task_to_response project_slug
coverage for the same sa_inspect(task).unloaded guard convention."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
from roboco.api.schemas.project_fields import task_project_fields
def _stub_task(*, with_project: bool = False) -> Any:
return SimpleNamespace(
project=(
SimpleNamespace(slug="acme-robotics", name="Acme Robotics")
if with_project
else None
),
)
def test_omits_fields_when_project_unloaded() -> None:
stub = _stub_task(with_project=False)
fake_inspector = MagicMock()
fake_inspector.unloaded = {"project"}
with patch(
"roboco.api.schemas.project_fields.sa_inspect", return_value=fake_inspector
):
assert task_project_fields(stub) == (None, None)
def test_omits_fields_when_project_id_unset() -> None:
stub = _stub_task(with_project=False)
fake_inspector = MagicMock()
fake_inspector.unloaded = set() # loaded, but task.project is None
with patch(
"roboco.api.schemas.project_fields.sa_inspect", return_value=fake_inspector
):
assert task_project_fields(stub) == (None, None)
def test_returns_slug_and_name_when_loaded() -> None:
stub = _stub_task(with_project=True)
fake_inspector = MagicMock()
fake_inspector.unloaded = set()
with patch(
"roboco.api.schemas.project_fields.sa_inspect", return_value=fake_inspector
):
assert task_project_fields(stub) == ("acme-robotics", "Acme Robotics")
@@ -2,6 +2,7 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
@@ -29,6 +30,7 @@ async def test_get_returns_empty_defaults_when_unset(db_session: Any) -> None:
assert goals["constraints"] == []
assert goals["operating_policy"] == {}
assert goals["brand_voice"] == ""
assert goals["company_name"] == ""
@pytest.mark.asyncio
@@ -89,3 +91,70 @@ async def test_brand_voice_untouched_by_partial_upsert(db_session: Any) -> None:
goals = await svc.get()
assert goals["brand_voice"] == "Speak as 'we'."
assert goals["north_star"] == "Ship a delightful product"
@pytest.mark.asyncio
async def test_company_name_roundtrips(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"company_name": "Acme Robotics"})
goals = await svc.get()
assert goals["company_name"] == "Acme Robotics"
@pytest.mark.asyncio
async def test_company_name_untouched_by_partial_upsert(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"company_name": "Acme Robotics"})
# Mirrors brand_voice's partial-update contract.
await svc.upsert({"north_star": "Ship a delightful product"})
goals = await svc.get()
assert goals["company_name"] == "Acme Robotics"
assert goals["north_star"] == "Ship a delightful product"
# --------------------------------------------------------------------------- #
# resolve_product_name — the shared fallback chain XEngine and VideoEngine
# both brand their drafting prompts with (project name -> company_name ->
# "RoboCo"). A SimpleNamespace stands in for a ProjectTable: the method only
# reads ``.name``, so a full project/agent seed adds nothing here.
# --------------------------------------------------------------------------- #
def _project_stub(name: str) -> Any:
"""A ProjectTable stand-in for resolve_product_name, which only reads
``.name`` returning ``Any`` (not the SimpleNamespace type) so it type-
checks against the real ``ProjectTable | None`` parameter with no cast."""
return SimpleNamespace(name=name)
@pytest.mark.asyncio
async def test_resolve_product_name_uses_project_name(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
name = await svc.resolve_product_name(_project_stub("Acme Robotics"))
assert name == "Acme Robotics"
@pytest.mark.asyncio
async def test_resolve_product_name_falls_back_to_company_name(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"company_name": "Acme Robotics"})
name = await svc.resolve_product_name(None)
assert name == "Acme Robotics"
@pytest.mark.asyncio
async def test_resolve_product_name_ignores_a_project_with_no_name(
db_session: Any,
) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"company_name": "Acme Robotics"})
name = await svc.resolve_product_name(_project_stub(""))
assert name == "Acme Robotics"
@pytest.mark.asyncio
async def test_resolve_product_name_defaults_to_roboco(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"company_name": ""})
name = await svc.resolve_product_name(None)
assert name == "RoboCo"
+43
View File
@@ -675,6 +675,49 @@ async def test_draft_release_video_brief_contains_brand_voice_when_set(
assert "Dry wit, never an exclamation point." in draft["brief"]
# --------------------------------------------------------------------------- #
# Product-name resolution (release-video prompts brand off the target
# project, not a hardcoded "RoboCo" literal). The fallback-chain unit
# coverage (project name -> company_name -> "RoboCo") lives on the shared
# helper, CompanyGoalsService.resolve_product_name, in
# test_company_goals_service.py — this only asserts the end-to-end wiring
# through draft_release_video.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_draft_release_video_uses_project_name_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
acme = ProjectTable(
name="Acme Robotics",
slug="acme-robotics",
git_url="https://github.com/x/acme.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
db_session.add(acme)
await db_session.flush()
_mock_local_model(monkeypatch, None) # force the deterministic fallback template
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(
version="1.0.0", changelog=_CHANGELOG, project_id=cast("UUID", acme.id)
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "Acme Robotics" in draft["script"]
assert "RoboCo" not in draft["script"]
assert "Acme Robotics" in draft["brief"]
assert "RoboCo" not in draft["brief"]
# --------------------------------------------------------------------------- #
# brief enrichment shared by every occasion — brand voice + motion pointer
# (spec Task 2: "Feed the video brief")
+53 -4
View File
@@ -1334,8 +1334,8 @@ async def test_voice_guide_falls_back_when_brand_voice_unset(
) -> 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
voice = await engine._voice_guide("RoboCo")
assert voice == x_engine_module._hom_voice("RoboCo")
@pytest.mark.asyncio
@@ -1346,6 +1346,55 @@ async def test_voice_guide_appends_brand_voice_when_set(
{"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
voice = await engine._voice_guide("RoboCo")
assert x_engine_module._hom_voice("RoboCo") in voice
assert "Dry wit, never an exclamation point." in voice
@pytest.mark.asyncio
async def test_voice_guide_uses_the_given_product_name(
db_session: AsyncSession,
) -> None:
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
voice = await engine._voice_guide("Acme Robotics")
assert "Acme Robotics" in voice
assert "RoboCo" not in voice
# --------------------------------------------------------------------------- #
# Product-name resolution (release-post prompts brand off the target project,
# not a hardcoded "RoboCo" literal). The fallback-chain unit coverage
# (project name -> company_name -> "RoboCo") lives on the shared helper,
# CompanyGoalsService.resolve_product_name, in test_company_goals_service.py —
# this only asserts the end-to-end wiring through draft_release_post.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_draft_release_post_uses_project_name_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
acme = ProjectTable(
name="Acme Robotics",
slug="acme-robotics",
git_url="https://github.com/x/acme.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
db_session.add(acme)
await db_session.flush()
_mock_local_model(monkeypatch, None) # force the deterministic fallback template
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
task = await engine.draft_release_post(
version=_VERSION, highlights=["feat: x"], project_id=cast("UUID", acme.id)
)
assert task is not None
body = markers.get_x_draft_body(task)
assert body is not None
assert "Acme Robotics" in body
assert "RoboCo" not in body