Merge master (Prompter #80) into lifecycle-residual-hardening

This commit is contained in:
Renn F
2026-06-08 05:45:07 +02:00
24 changed files with 3840 additions and 0 deletions
@@ -0,0 +1,37 @@
"""Add prompter origin tracking columns to tasks table.
Adds `source` (varchar 50, default 'manual') and `confirmed_by_human`
(boolean, default false) to support the Prompter conversational assistant
feature. Prompter-originated tasks require human confirmation before entering
the workflow.
Revision ID: 023_add_prompter_tracking_columns
Revises: 022_default_branch_master
Create Date: 2026-06-07
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "023_add_prompter_tracking_columns"
down_revision = "022_default_branch_master"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("source", sa.String(length=50), server_default="manual", nullable=False),
)
op.add_column(
"tasks",
sa.Column("confirmed_by_human", sa.Boolean(), server_default=sa.false(), nullable=False),
)
def downgrade() -> None:
op.drop_column("tasks", "confirmed_by_human")
op.drop_column("tasks", "source")
+145
View File
@@ -0,0 +1,145 @@
"""Add prompter_sessions, prompter_messages, and task_drafts tables.
Adds three new tables to support the Prompter conversational assistant
feature with DB-persisted conversation history and task draft tracking:
- prompter_sessions: links a conversation session to an authenticated agent
- prompter_messages: stores the full message history (user + assistant turns)
- task_drafts: stores structured task drafts extracted from conversations;
links to a real Task once the human confirms via /confirm endpoint
Revision ID: 024_add_prompter_tables
Revises: 023_add_prompter_tracking_columns
Create Date: 2026-06-07
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision = "024_add_prompter_tables"
down_revision = "023_add_prompter_tracking_columns"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- prompter_sessions -----------------------------------------------
op.create_table(
"prompter_sessions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column(
"agent_id",
UUID(as_uuid=True),
sa.ForeignKey("agents.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
sa.Enum(
"active",
"draft_ready",
"confirmed",
"abandoned",
name="promptersessionstatus",
),
nullable=False,
server_default="active",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=True,
onupdate=sa.func.now(),
),
)
op.create_index("ix_prompter_sessions_agent_id", "prompter_sessions", ["agent_id"])
op.create_index(
"ix_prompter_sessions_status", "prompter_sessions", ["status"]
)
# --- prompter_messages -----------------------------------------------
op.create_table(
"prompter_messages",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
UUID(as_uuid=True),
sa.ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
sa.Enum("user", "assistant", "system", name="promptermessagerole"),
nullable=False,
),
sa.Column("content", sa.Text, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index(
"ix_prompter_messages_session_id", "prompter_messages", ["session_id"]
)
op.create_index(
"ix_prompter_messages_session_created",
"prompter_messages",
["session_id", "created_at"],
)
# --- task_drafts -----------------------------------------------------
op.create_table(
"task_drafts",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
UUID(as_uuid=True),
sa.ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("draft_data", JSONB, nullable=False),
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"task_id",
UUID(as_uuid=True),
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=True,
onupdate=sa.func.now(),
),
)
op.create_index("ix_task_drafts_session_id", "task_drafts", ["session_id"])
op.create_index("ix_task_drafts_task_id", "task_drafts", ["task_id"])
def downgrade() -> None:
op.drop_table("task_drafts")
op.drop_index("ix_prompter_messages_session_created", "prompter_messages")
op.drop_index("ix_prompter_messages_session_id", "prompter_messages")
op.drop_table("prompter_messages")
op.drop_index("ix_prompter_sessions_status", "prompter_sessions")
op.drop_index("ix_prompter_sessions_agent_id", "prompter_sessions")
op.drop_table("prompter_sessions")
op.execute("DROP TYPE IF EXISTS promptersessionstatus")
op.execute("DROP TYPE IF EXISTS promptermessagerole")
@@ -0,0 +1,93 @@
"use client";
import { Sparkles } from "lucide-react";
import { usePrompter } from "@/hooks/use-prompter";
import {
ChatMessages,
ChatComposer,
ConfirmDialog,
SuccessCard,
} from "@/components/prompter";
export default function PrompterPage() {
const {
state,
messages,
isSending,
editableDraft,
createdTaskId,
createdTaskTitle,
createdTaskTeam,
send,
openReview,
closeReview,
keepChatting,
updateDraft,
isValidForLaunch,
launchTask,
startAnother,
isLaunching,
} = usePrompter();
const isComposerDisabled =
state === "launching" || state === "success";
return (
<div className="flex h-full flex-col">
{/* Page header */}
<div className="flex items-center gap-3 border-b px-6 py-4">
<Sparkles className="h-5 w-5 text-primary" />
<div>
<h1 className="text-lg font-semibold">Task Assistant</h1>
<p className="text-xs text-muted-foreground">
Describe your idea and I&apos;ll help you create a structured task
</p>
</div>
</div>
{/* Chat area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Success overlay in chat area */}
{state === "success" &&
createdTaskId &&
createdTaskTitle &&
createdTaskTeam ? (
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
<div className="w-full max-w-md">
<SuccessCard
taskId={createdTaskId}
taskTitle={createdTaskTitle}
team={createdTaskTeam}
onStartAnother={startAnother}
/>
</div>
</div>
) : (
<ChatMessages
messages={messages}
onOpenReview={openReview}
onKeepChatting={keepChatting}
/>
)}
{/* Composer */}
<ChatComposer
onSend={send}
disabled={isComposerDisabled}
isSending={isSending}
/>
</div>
{/* Confirmation dialog (portal) */}
<ConfirmDialog
open={state === "review_modal" || state === "launching"}
draft={editableDraft}
onClose={closeReview}
onUpdate={updateDraft}
onConfirm={launchTask}
isLaunching={isLaunching}
isValid={isValidForLaunch()}
/>
</div>
);
}
+2
View File
@@ -21,6 +21,7 @@ import {
GitBranch,
Database,
Cpu,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -33,6 +34,7 @@ const navItems = [
// Work Management
{ title: "Tasks", href: "/tasks", icon: ListTodo },
{ title: "Kanban", href: "/kanban", icon: Kanban },
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
// Development
{ title: "Projects", href: "/projects", icon: FolderGit2 },
@@ -0,0 +1,70 @@
"use client";
import { useState, useRef, KeyboardEvent } from "react";
import { Send, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
onSend: (text: string) => Promise<void> | void;
disabled?: boolean;
isSending?: boolean;
placeholder?: string;
}
export function ChatComposer({
onSend,
disabled = false,
isSending = false,
placeholder = "Describe the task you want to create… (Enter to send, Shift+Enter for newline)",
}: ChatComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isDisabled = disabled || isSending || !value.trim();
const handleSend = async () => {
const text = value.trim();
if (!text || isSending || disabled) return;
setValue("");
await onSend(text);
// Refocus after send
textareaRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// Shift+Enter falls through to default (inserts newline)
};
return (
<div className="flex items-end gap-2 border-t bg-background px-4 py-3">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || isSending}
rows={2}
className="flex-1 resize-none text-sm"
/>
<Button
size="icon"
onClick={handleSend}
disabled={isDisabled}
className="mb-0.5 shrink-0"
aria-label="Send message"
>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useRef } from "react";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ChatMessage } from "@/hooks/use-prompter";
import { DraftProposalCard } from "./draft-proposal-card";
interface ChatMessagesProps {
messages: ChatMessage[];
onOpenReview: () => void;
onKeepChatting: () => void;
}
export function ChatMessages({
messages,
onOpenReview,
onKeepChatting,
}: ChatMessagesProps) {
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom whenever messages change
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center text-muted-foreground px-8">
<p className="text-lg font-semibold">What would you like to build?</p>
<p className="text-sm max-w-md">
Describe your task idea. I&apos;ll help you refine it into a structured task with acceptance
criteria ready to hand off to the team.
</p>
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
{messages.map((msg) => {
if (msg.role === "user") {
return (
<div key={msg.id} className="flex justify-end">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-sm text-primary-foreground">
{msg.content}
</div>
</div>
);
}
if (msg.role === "error") {
return (
<div key={msg.id} className="flex justify-start">
<div className="flex max-w-[70%] items-start gap-2 rounded-2xl rounded-tl-sm border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{msg.content}</span>
</div>
</div>
);
}
// Assistant message
return (
<div key={msg.id} className="flex flex-col gap-2">
<div className="flex justify-start">
<div
className={cn(
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm",
msg.draft && "max-w-[85%]"
)}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
{/* Inline draft proposal card when LLM offers a draft */}
{msg.draft && (
<div className="flex justify-start">
<div className="w-full max-w-[85%]">
<DraftProposalCard
draft={msg.draft}
onKeepChatting={onKeepChatting}
onOpenReview={onOpenReview}
/>
</div>
</div>
)}
</div>
);
})}
{/* Scroll anchor */}
<div ref={bottomRef} />
</div>
);
}
@@ -0,0 +1,212 @@
"use client";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AcceptanceCriteriaEditor } from "@/components/tasks/acceptance-criteria-editor";
import { MarkdownEditor } from "@/components/tasks/markdown-editor";
import { Team, TaskType, Complexity } from "@/types";
import type { EditableDraft } from "@/hooks/use-prompter";
interface ConfirmDialogProps {
open: boolean;
draft: EditableDraft;
onClose: () => void;
onUpdate: (updates: Partial<EditableDraft>) => void;
onConfirm: () => Promise<void> | void;
isLaunching: boolean;
isValid: boolean;
}
const WARNING_BANNER_ID = "prompter-warning-banner";
export function ConfirmDialog({
open,
draft,
onClose,
onUpdate,
onConfirm,
isLaunching,
isValid,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isLaunching) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Review &amp; Confirm Task</DialogTitle>
</DialogHeader>
{/* Warning banner */}
<div
id={WARNING_BANNER_ID}
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
This will create a real task and notify the team. It cannot be undone from this screen.
</span>
</div>
{/* Form fields — NOT wrapped in a <form> to prevent Enter-key submission bypass */}
<div className="space-y-5 py-2">
{/* Title */}
<div className="space-y-1.5">
<Label htmlFor="prompter-title">
Title <span className="text-destructive">*</span>
</Label>
<Input
id="prompter-title"
value={draft.title}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Task title"
disabled={isLaunching}
/>
</div>
{/* Description */}
<MarkdownEditor
label="Description"
value={draft.description}
onChange={(v) => onUpdate({ description: v })}
placeholder="Describe what needs to be done…"
required
minLength={20}
/>
{/* Acceptance Criteria */}
<AcceptanceCriteriaEditor
criteria={draft.acceptance_criteria}
onChange={(criteria) => onUpdate({ acceptance_criteria: criteria })}
/>
{/* Metadata row */}
<div className="grid grid-cols-3 gap-4">
{/* Team */}
<div className="space-y-1.5">
<Label>
Team <span className="text-destructive">*</span>
</Label>
<Select
value={draft.team}
onValueChange={(v) => onUpdate({ team: v as Team })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue placeholder="Select team" />
</SelectTrigger>
<SelectContent>
{Object.values(Team).map((t) => (
<SelectItem key={t} value={t}>
{t.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-1.5">
<Label>Priority</Label>
<Select
value={String(draft.priority)}
onValueChange={(v) => onUpdate({ priority: Number(v) })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Low</SelectItem>
<SelectItem value="1">Medium</SelectItem>
<SelectItem value="2">High</SelectItem>
<SelectItem value="3">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
{/* Task Type */}
<div className="space-y-1.5">
<Label>Type</Label>
<Select
value={draft.task_type || ""}
onValueChange={(v) => onUpdate({ task_type: v as TaskType })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{Object.values(TaskType).map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Complexity */}
<div className="space-y-1.5">
<Label>Estimated Complexity</Label>
<Select
value={draft.estimated_complexity || ""}
onValueChange={(v) => onUpdate({ estimated_complexity: v as Complexity })}
disabled={isLaunching}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
{Object.values(Complexity).map((c) => (
<SelectItem key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={isLaunching}
>
Back
</Button>
<Button
onClick={onConfirm}
disabled={!isValid || isLaunching}
aria-describedby={WARNING_BANNER_ID}
>
{isLaunching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating
</>
) : (
"Confirm & Launch"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,108 @@
"use client";
import { MessageCircle, ClipboardCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { DraftProposal } from "@/lib/api/prompter";
interface DraftProposalCardProps {
draft: DraftProposal;
onKeepChatting: () => void;
onOpenReview: () => void;
}
const PRIORITY_LABELS: Record<number, string> = {
0: "Low",
1: "Medium",
2: "High",
3: "Urgent",
};
export function DraftProposalCard({
draft,
onKeepChatting,
onOpenReview,
}: DraftProposalCardProps) {
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "High";
return (
<Card className="border-primary/30 bg-primary/5">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold leading-tight">
{draft.title}
</CardTitle>
<div className="flex flex-wrap gap-1 shrink-0">
{draft.team && (
<Badge variant="secondary" className="text-xs">
{draft.team}
</Badge>
)}
<Badge variant="outline" className="text-xs">
{priorityLabel}
</Badge>
{draft.task_type && (
<Badge variant="outline" className="text-xs">
{draft.task_type}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="pb-3 space-y-3">
{/* Description excerpt */}
{draft.description && (
<p className="text-sm text-muted-foreground line-clamp-3">
{draft.description}
</p>
)}
{/* Acceptance criteria */}
{draft.acceptance_criteria.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">
Acceptance criteria ({draft.acceptance_criteria.length})
</p>
<ul className="space-y-1">
{draft.acceptance_criteria.slice(0, 4).map((criterion, i) => (
<li key={i} className="flex items-start gap-2 text-xs">
<span className="mt-0.5 h-3 w-3 shrink-0 rounded-full border border-primary/50 flex items-center justify-center">
<span className="h-1.5 w-1.5 rounded-full bg-primary/50" />
</span>
<span className="text-foreground line-clamp-2">{criterion}</span>
</li>
))}
{draft.acceptance_criteria.length > 4 && (
<li className="text-xs text-muted-foreground pl-5">
+{draft.acceptance_criteria.length - 4} more
</li>
)}
</ul>
</div>
)}
</CardContent>
<CardFooter className="gap-2 pt-0">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={onKeepChatting}
>
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
Keep Chatting
</Button>
<Button
size="sm"
className="flex-1"
onClick={onOpenReview}
>
<ClipboardCheck className="mr-1.5 h-3.5 w-3.5" />
Review &amp; Confirm
</Button>
</CardFooter>
</Card>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { ChatMessages } from "./chat-messages";
export { ChatComposer } from "./chat-composer";
export { DraftProposalCard } from "./draft-proposal-card";
export { ConfirmDialog } from "./confirm-dialog";
export { SuccessCard } from "./success-card";
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { CheckCircle2, ExternalLink, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { Team } from "@/types";
interface SuccessCardProps {
taskId: string;
taskTitle: string;
team: Team;
onStartAnother: () => void;
}
export function SuccessCard({
taskId,
taskTitle,
team,
onStartAnother,
}: SuccessCardProps) {
return (
<Card className="border-green-500/30 bg-green-500/5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<CardTitle className="text-sm font-semibold text-green-700 dark:text-green-400">
Task Created Successfully
</CardTitle>
</div>
</CardHeader>
<CardContent className="pb-3 space-y-2">
<p className="text-sm font-medium">{taskTitle}</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{team.replace("_", " ")}
</Badge>
<span className="text-xs text-muted-foreground">ID: {taskId.slice(0, 8)}</span>
</div>
</CardContent>
<CardFooter className="gap-2 pt-0">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href={`/tasks/${taskId}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
View Task
</Link>
</Button>
<Button size="sm" className="flex-1" onClick={onStartAnother}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Start Another
</Button>
</CardFooter>
</Card>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { prompterApi, type DraftProposal } from "@/lib/api/prompter";
import { getErrorMessage } from "@/lib/api/client";
import { useCreateTask } from "@/hooks/use-tasks";
import type { TaskCreate, Team, TaskType, Complexity } from "@/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PrompterState =
| "empty"
| "chatting"
| "draft_preview"
| "review_modal"
| "launching"
| "success";
export type MessageRole = "user" | "assistant" | "error";
export interface ChatMessage {
id: string;
role: MessageRole;
content: string;
/** Present only on an assistant message that contains a draft proposal */
draft?: DraftProposal;
}
export interface EditableDraft {
title: string;
description: string;
acceptance_criteria: string[];
team: Team | "";
priority: number;
task_type: TaskType | "";
estimated_complexity: Complexity | "";
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function usePrompter() {
const createTask = useCreateTask();
const [state, setState] = useState<PrompterState>("empty");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [createdTaskId, setCreatedTaskId] = useState<string | null>(null);
const [createdTaskTitle, setCreatedTaskTitle] = useState<string | null>(null);
const [createdTaskTeam, setCreatedTaskTeam] = useState<Team | null>(null);
/** Draft as shown in the draft-preview card */
const [draftProposal, setDraftProposal] = useState<DraftProposal | null>(null);
/** Editable copy used in the confirmation dialog */
const [editableDraft, setEditableDraft] = useState<EditableDraft>({
title: "",
description: "",
acceptance_criteria: [],
team: "",
priority: 2,
task_type: "",
estimated_complexity: "",
});
// Keep a ref to sessionId for callbacks to avoid stale closures
const sessionIdRef = useRef<string | null>(null);
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
const addMessage = useCallback((msg: Omit<ChatMessage, "id">) => {
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setMessages((prev) => [...prev, { ...msg, id }]);
return id;
}, []);
// -----------------------------------------------------------------------
// Send a chat message
// -----------------------------------------------------------------------
const send = useCallback(
async (text: string) => {
if (!text.trim() || isSending) return;
setIsSending(true);
setState("chatting");
// Add user message to chat
addMessage({ role: "user", content: text.trim() });
try {
let sid = sessionIdRef.current;
// Create session on first message
if (!sid) {
const { session_id } = await prompterApi.createSession();
sid = session_id;
sessionIdRef.current = sid;
setSessionId(sid);
}
// Send message and get reply
const response = await prompterApi.sendMessage(sid, text.trim());
if (response.draft) {
// LLM produced a draft — add assistant message with embedded draft
addMessage({
role: "assistant",
content: response.reply,
draft: response.draft,
});
setDraftProposal(response.draft);
setEditableDraft({
title: response.draft.title,
description: response.draft.description,
acceptance_criteria: response.draft.acceptance_criteria,
team: response.draft.team ?? "",
priority: response.draft.priority ?? 2,
task_type: response.draft.task_type ?? "",
estimated_complexity: response.draft.estimated_complexity ?? "",
});
setState("draft_preview");
} else {
// Plain text reply
addMessage({ role: "assistant", content: response.reply });
setState("chatting");
}
} catch (err) {
const msg = getErrorMessage(err);
addMessage({
role: "error",
content: msg,
});
setState("chatting");
} finally {
setIsSending(false);
}
},
[isSending, addMessage]
);
// -----------------------------------------------------------------------
// Review & Confirm actions
// -----------------------------------------------------------------------
const openReview = useCallback(() => {
setState("review_modal");
}, []);
const closeReview = useCallback(() => {
setState("draft_preview");
}, []);
const keepChatting = useCallback(() => {
setState("chatting");
}, []);
const updateDraft = useCallback((updates: Partial<EditableDraft>) => {
setEditableDraft((prev) => ({ ...prev, ...updates }));
}, []);
// -----------------------------------------------------------------------
// Validation
// -----------------------------------------------------------------------
const isValidForLaunch = useCallback((): boolean => {
return (
editableDraft.title.trim().length > 0 &&
editableDraft.description.trim().length >= 20 &&
editableDraft.acceptance_criteria.length > 0 &&
editableDraft.team !== ""
);
}, [editableDraft]);
// -----------------------------------------------------------------------
// Launch (create task)
// -----------------------------------------------------------------------
const launchTask = useCallback(async () => {
if (!isValidForLaunch()) return;
setState("launching");
const payload: TaskCreate = {
title: editableDraft.title.trim(),
description: editableDraft.description.trim(),
acceptance_criteria: editableDraft.acceptance_criteria,
team: editableDraft.team as Team,
priority: editableDraft.priority,
...(editableDraft.task_type ? { task_type: editableDraft.task_type as TaskType } : {}),
...(editableDraft.estimated_complexity
? { estimated_complexity: editableDraft.estimated_complexity as Complexity }
: {}),
};
try {
const task = await createTask.mutateAsync(payload);
setCreatedTaskId(task.id);
setCreatedTaskTitle(task.title);
setCreatedTaskTeam(task.team as Team);
toast.success("Task created successfully!");
setState("success");
} catch (err) {
const msg = getErrorMessage(err);
toast.error(`Failed to create task: ${msg}`);
setState("review_modal");
}
}, [editableDraft, isValidForLaunch, createTask]);
// -----------------------------------------------------------------------
// Reset to start another conversation
// -----------------------------------------------------------------------
const startAnother = useCallback(() => {
setMessages([]);
setSessionId(null);
sessionIdRef.current = null;
setDraftProposal(null);
setEditableDraft({
title: "",
description: "",
acceptance_criteria: [],
team: "",
priority: 2,
task_type: "",
estimated_complexity: "",
});
setCreatedTaskId(null);
setCreatedTaskTitle(null);
setCreatedTaskTeam(null);
setState("empty");
}, []);
return {
// State
state,
messages,
sessionId,
isSending,
draftProposal,
editableDraft,
createdTaskId,
createdTaskTitle,
createdTaskTeam,
// Actions
send,
openReview,
closeReview,
keepChatting,
updateDraft,
isValidForLaunch,
launchTask,
startAnother,
isLaunching: createTask.isPending,
};
}
+65
View File
@@ -0,0 +1,65 @@
import api from "./client";
import type { Team, TaskType, Complexity } from "@/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DraftProposal {
title: string;
description: string;
acceptance_criteria: string[];
team: Team;
priority?: number;
task_type?: TaskType;
estimated_complexity?: Complexity;
}
export interface ChatResponse {
reply: string;
draft?: DraftProposal | null;
session_id: string;
}
export interface CreateSessionResponse {
session_id: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
export const prompterApi = {
/**
* Create a new prompter session, returning a session ID.
*/
createSession: async (): Promise<CreateSessionResponse> => {
const { data } = await api.post<CreateSessionResponse>("/prompter/sessions");
return data;
},
/**
* Send a chat message in an existing session.
* Returns the assistant reply and, if ready, a draft task proposal.
*/
sendMessage: async (
sessionId: string,
message: string
): Promise<ChatResponse> => {
const { data } = await api.post<ChatResponse>(
`/prompter/sessions/${sessionId}/chat`,
{ message }
);
return data;
},
/**
* Fetch the current draft for a session (if the LLM has produced one).
*/
getDraft: async (sessionId: string): Promise<DraftProposal | null> => {
const { data } = await api.get<{ draft: DraftProposal | null }>(
`/prompter/sessions/${sessionId}/draft`
);
return data.draft;
},
};
+8
View File
@@ -30,6 +30,7 @@ from roboco.api.routes.optimal import router as optimal_router
from roboco.api.routes.orchestrator import router as orchestrator_router
from roboco.api.routes.product import router as product_router
from roboco.api.routes.project import router as project_router
from roboco.api.routes.prompter import router as prompter_router
from roboco.api.routes.provider import router as provider_router
from roboco.api.routes.sessions import router as sessions_router
from roboco.api.routes.stream import router as stream_router
@@ -311,6 +312,13 @@ def create_app() -> FastAPI:
tags=["Providers"],
)
# Prompter — conversational task drafting assistant
app.include_router(
prompter_router,
prefix=f"{api_prefix}/prompter",
tags=["Prompter"],
)
# Work Sessions
app.include_router(
work_session_router,
+287
View File
@@ -0,0 +1,287 @@
"""
Prompter API Routes
Session-based conversational assistant endpoints for drafting tasks:
- POST /api/prompter/sessions : create a new session
- POST /api/prompter/sessions/{id}/messages : send user message, get AI reply
- GET /api/prompter/sessions/{id}/draft : get structured task draft
- POST /api/prompter/sessions/{id}/confirm : confirm draft → create real task
Legacy stateless endpoints (retained for backward compatibility):
- POST /api/prompter/chat : back-and-forth conversation (stateless)
- POST /api/prompter/draft : structured task draft generation (stateless)
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.prompter import (
ChatMessage,
PrompterChatRequest,
PrompterChatResponse,
PrompterDraftRequest,
PrompterDraftResponse,
PrompterDraftTask,
PrompterMessageRequest,
PrompterMessageResponse,
PrompterSessionCreateRequest,
PrompterSessionResponse,
TaskConfirmRequest,
TaskDraftResponse,
)
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import ConfirmOverrides, get_prompter_service
router = APIRouter()
def _translate_error(e: ServiceError) -> HTTPException:
"""Service errors → HTTP status."""
if isinstance(e, NotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": "not_found", "message": e.message},
)
if isinstance(e, ValidationError):
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "validation_error",
"message": e.message,
"field": e.field,
},
)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "internal_error", "message": e.message},
)
# =============================================================================
# SESSION-BASED ENDPOINTS
# =============================================================================
@router.post(
"/sessions",
response_model=PrompterSessionResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_session(
data: PrompterSessionCreateRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> PrompterSessionResponse:
"""Create a new Prompter conversation session linked to the authenticated agent."""
service = get_prompter_service(db)
try:
session = await service.create_session(
agent_id=agent.agent_id,
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
return PrompterSessionResponse(
id=session.id, # type: ignore[arg-type]
agent_id=session.agent_id, # type: ignore[arg-type]
status=session.status,
created_at=session.created_at,
updated_at=session.updated_at,
)
@router.post(
"/sessions/{session_id}/messages",
response_model=list[PrompterMessageResponse],
)
async def send_message(
session_id: UUID,
data: PrompterMessageRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> list[PrompterMessageResponse]:
"""
Accept a user message, append it and an AI assistant response to the
conversation, and return the updated message list.
"""
service = get_prompter_service(db)
try:
messages = await service.send_message(
session_id=session_id,
agent_id=agent.agent_id,
content=data.content,
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
return [
PrompterMessageResponse(
id=msg.id, # type: ignore[arg-type]
session_id=msg.session_id, # type: ignore[arg-type]
role=msg.role,
content=msg.content,
created_at=msg.created_at,
)
for msg in messages
]
@router.get(
"/sessions/{session_id}/draft",
response_model=TaskDraftResponse,
)
async def get_draft(
session_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskDraftResponse:
"""
Return a structured task draft extracted from conversation history via LLM.
The draft contains: title, description, acceptance_criteria, team,
task_type, nature, and estimated_complexity.
"""
service = get_prompter_service(db)
try:
draft_record = await service.get_or_generate_draft(
session_id=session_id,
agent_id=agent.agent_id,
)
except ServiceError as e:
raise _translate_error(e) from e
# Parse the stored draft_data into PrompterDraftTask for validation
try:
draft_task = PrompterDraftTask(**draft_record.draft_data)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"error": "draft_schema_error",
"message": f"Stored draft did not match schema: {exc}",
"raw_draft": draft_record.draft_data,
},
) from exc
return TaskDraftResponse(
id=draft_record.id, # type: ignore[arg-type]
session_id=draft_record.session_id, # type: ignore[arg-type]
draft=draft_task,
confirmed_at=draft_record.confirmed_at,
task_id=draft_record.task_id, # type: ignore[arg-type]
created_at=draft_record.created_at,
)
@router.post(
"/sessions/{session_id}/confirm",
response_model=dict,
status_code=status.HTTP_201_CREATED,
)
async def confirm_draft(
session_id: UUID,
data: TaskConfirmRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> dict:
"""
Validate the draft and create a real Task using the existing TaskService.
Returns the created task ID.
"""
service = get_prompter_service(db)
try:
task_id = await service.confirm_draft(
session_id=session_id,
agent_id=agent.agent_id,
confirm_overrides=ConfirmOverrides(
project_id=data.project_id,
product_id=data.product_id,
assigned_to=data.assigned_to,
extra=data.overrides,
),
)
except ServiceError as e:
raise _translate_error(e) from e
return {"task_id": str(task_id)}
# =============================================================================
# LEGACY STATELESS ENDPOINTS (backward compatibility)
# =============================================================================
@router.post("/chat", response_model=PrompterChatResponse)
async def prompter_chat(
data: PrompterChatRequest,
_agent: CurrentAgentContext,
) -> PrompterChatResponse:
"""
Continue a Prompter conversation (stateless).
The frontend sends the full conversation history (including the new user
message). The assistant replies, optionally signalling that enough context
has been gathered to generate a draft (`draft_ready=True`).
"""
service = get_prompter_service()
try:
result = await service.chat(
messages=[msg.model_dump() for msg in data.messages],
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
return PrompterChatResponse(
message=result["message"],
draft_ready=result["draft_ready"],
)
@router.post("/draft", response_model=PrompterDraftResponse)
async def prompter_draft(
data: PrompterDraftRequest,
_agent: CurrentAgentContext,
) -> PrompterDraftResponse:
"""
Generate a structured task draft from conversation context (stateless).
The frontend sends the full conversation history. The backend calls the
LLM to produce a JSON draft conforming to the TaskCreate schema.
"""
service = get_prompter_service()
try:
result = await service.draft(
messages=[msg.model_dump() for msg in data.messages],
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
draft_raw = result["draft"]
try:
draft = PrompterDraftTask(**draft_raw)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"error": "draft_schema_error",
"message": f"Generated draft did not match schema: {e}",
"raw_draft": draft_raw,
},
) from e
return PrompterDraftResponse(
draft=draft,
reasoning=result["reasoning"],
)
def _messages_to_dicts(messages: list[ChatMessage]) -> list[dict[str, str]]:
"""Convert ChatMessage list to dict list (internal helper)."""
return [msg.model_dump() for msg in messages]
+11
View File
@@ -167,6 +167,14 @@ async def create_task(
) from None
assigned_to_uuid = cast("UUID", agent_row.id)
# Prompter origin tracking: enforce human confirmation gate so
# LLM-drafted tasks cannot bypass review and enter the workflow.
if data.source == "prompter" and not data.confirmed_by_human:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Prompter-originated tasks require human confirmation",
)
service = get_task_service(db)
req = TaskCreateRequest(
title=data.title,
@@ -187,6 +195,9 @@ async def create_task(
task_type=data.task_type,
project_id=data.project_id,
product_id=data.product_id,
# Prompter origin tracking
source=data.source,
confirmed_by_human=data.confirmed_by_human,
)
task = await service.create(req)
await db.commit()
+232
View File
@@ -0,0 +1,232 @@
"""
Prompter API Schemas
Request/response models for the conversational Prompter assistant
that helps users draft tasks through natural language.
Includes both the session-based schemas (for the DB-persisted approach)
and the legacy stateless schemas retained for backward compatibility.
"""
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from roboco.models.base import (
Complexity,
TaskNature,
TaskType,
Team,
)
# =============================================================================
# SHARED MESSAGE SCHEMA
# =============================================================================
class ChatMessage(BaseModel):
"""A single message in the Prompter conversation."""
role: str = Field(..., description="One of: user, assistant, system")
content: str = Field(..., min_length=1, description="Message text")
@field_validator("role")
@classmethod
def _valid_role(cls, v: str) -> str:
if v not in {"user", "assistant", "system"}:
raise ValueError("role must be one of: user, assistant, system")
return v
# =============================================================================
# SESSION-BASED SCHEMAS (acceptance-criteria-required names)
# =============================================================================
class PrompterSessionCreateRequest(BaseModel):
"""Request body for POST /api/prompter/sessions."""
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional bootstrap context (project_id, team, etc.)",
)
class PrompterSessionResponse(BaseModel):
"""Response for session creation and retrieval."""
id: UUID
agent_id: UUID
status: str
created_at: datetime
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class PrompterMessageRequest(BaseModel):
"""Request body for POST /api/prompter/sessions/{id}/messages."""
content: str = Field(..., min_length=1, description="The user's message text")
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional per-turn context overrides",
)
class PrompterMessageResponse(BaseModel):
"""A single message record returned to the client."""
id: UUID
session_id: UUID
role: str
content: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class TaskConfirmRequest(BaseModel):
"""Request body for POST /api/prompter/sessions/{id}/confirm.
Allows the frontend to pass overrides that should be applied
to the draft before the real task is created.
"""
project_id: UUID | None = Field(
default=None,
description="Override project_id from the draft (required if draft omits it)",
)
product_id: UUID | None = Field(
default=None,
description="Override product_id from the draft",
)
assigned_to: str | None = Field(
default=None,
description="Agent slug or UUID to assign the task to",
)
overrides: dict[str, Any] = Field(
default_factory=dict,
description="Additional fields to override in the draft before task creation",
)
# =============================================================================
# DRAFT TASK SCHEMA (shared between session and legacy paths)
# =============================================================================
class PrompterDraftTask(BaseModel):
"""A task draft produced by the Prompter.
Mirrors TaskCreate fields so the frontend can POST /api/tasks
with confirmed_by_human=True after human review.
"""
title: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., min_length=20)
acceptance_criteria: list[str] = Field(..., min_length=1)
team: Team = Field(...)
priority: int = Field(default=2, ge=0, le=3)
task_type: TaskType = Field(...)
nature: TaskNature = Field(...)
estimated_complexity: Complexity = Field(...)
project_id: str | None = Field(
default=None,
description=(
"Project UUID as string; exactly one of project_id or "
"product_id must be set"
),
)
product_id: str | None = Field(
default=None,
description=(
"Product UUID as string; exactly one of project_id or "
"product_id must be set"
),
)
assigned_to: str | None = Field(
default=None,
description="Agent slug or UUID to assign the task to",
)
target_date: str | None = Field(
default=None,
description="ISO-8601 target completion date",
)
# Provenance — always set by the prompter backend
source: str = "prompter"
confirmed_by_human: bool = False
class TaskDraftResponse(BaseModel):
"""Response for GET /api/prompter/sessions/{id}/draft."""
id: UUID
session_id: UUID
draft: PrompterDraftTask
confirmed_at: datetime | None = None
task_id: UUID | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# =============================================================================
# LEGACY STATELESS SCHEMAS (retained for backward compatibility)
# =============================================================================
class PrompterChatRequest(BaseModel):
"""Request to continue a Prompter conversation (stateless)."""
messages: list[ChatMessage] = Field(
...,
min_length=1,
description="Conversation history including the new user message",
)
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional context (project_id, team, prior drafts, etc.)",
)
class PrompterChatResponse(BaseModel):
"""Response from the Prompter chat endpoint (stateless)."""
message: str = Field(..., description="Assistant's reply")
conversation_id: str | None = Field(
default=None, description="Client-managed conversation identifier"
)
draft_ready: bool = Field(
default=False,
description=(
"True when the assistant believes enough context exists to draft a task"
),
)
class PrompterDraftRequest(BaseModel):
"""Request to generate a task draft from conversation context (stateless)."""
messages: list[ChatMessage] = Field(
..., min_length=1, description="Full conversation used as drafting context"
)
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional overrides (project_id, team, assigned_to, etc.)",
)
class PrompterDraftResponse(BaseModel):
"""Response from the Prompter draft endpoint (stateless)."""
draft: PrompterDraftTask = Field(..., description="Structured task draft")
reasoning: str = Field(
default="",
description="Assistant's explanation of how the draft was derived",
)
model_config = ConfigDict(from_attributes=True)
+6
View File
@@ -330,6 +330,10 @@ class TaskResponse(BaseModel):
pr_number: int | None = None
pr_url: str | None = None
# Prompter origin tracking
source: str = "manual"
confirmed_by_human: bool = False
model_config = ConfigDict(from_attributes=True)
@@ -676,6 +680,8 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
branch_name=getattr(task, "branch_name", None),
pr_number=getattr(task, "pr_number", None),
pr_url=getattr(task, "pr_url", None),
source=getattr(task, "source", "manual"),
confirmed_by_human=getattr(task, "confirmed_by_human", False),
)
+138
View File
@@ -359,6 +359,14 @@ class TaskTable(Base):
Boolean, nullable=False, default=False
)
# Prompter origin tracking: tasks drafted by the Prompter LLM assistant
# require human confirmation before entering the workflow. The task creation
# route enforces that prompter-originated tasks cannot bypass human review.
source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual")
confirmed_by_human: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# Relationships
creator: Mapped["AgentTable"] = relationship(
"AgentTable", foreign_keys=[created_by], lazy="joined"
@@ -1807,3 +1815,133 @@ class GatewayTriggerTable(Base):
Index("ix_gateway_triggers_created_at", "created_at"),
Index("ix_gateway_triggers_kind_decision", "trigger_kind", "decision"),
)
# =============================================================================
# PROMPTER TABLES
# =============================================================================
class PrompterSessionTable(Base):
"""A Prompter conversation session owned by an agent."""
__tablename__ = "prompter_sessions"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
agent_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="CASCADE"),
nullable=False,
)
status: Mapped[str] = mapped_column(
Enum(
"active",
"draft_ready",
"confirmed",
"abandoned",
name="promptersessionstatus",
),
nullable=False,
default="active",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships
messages: Mapped[list["PrompterMessageTable"]] = relationship(
"PrompterMessageTable",
back_populates="session",
order_by="PrompterMessageTable.created_at",
cascade="all, delete-orphan",
lazy="select",
)
drafts: Mapped[list["TaskDraftTable"]] = relationship(
"TaskDraftTable",
back_populates="session",
cascade="all, delete-orphan",
lazy="select",
)
__table_args__ = (
Index("ix_prompter_sessions_agent_id", "agent_id"),
Index("ix_prompter_sessions_status", "status"),
)
class PrompterMessageTable(Base):
"""A single message turn within a Prompter conversation session."""
__tablename__ = "prompter_messages"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
session_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
nullable=False,
)
role: Mapped[str] = mapped_column(
Enum("user", "assistant", "system", name="promptermessagerole"),
nullable=False,
)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
# Relationships
session: Mapped["PrompterSessionTable"] = relationship(
"PrompterSessionTable", back_populates="messages"
)
__table_args__ = (
Index("ix_prompter_messages_session_id", "session_id"),
Index("ix_prompter_messages_session_created", "session_id", "created_at"),
)
class TaskDraftTable(Base):
"""A structured task draft extracted from a Prompter conversation."""
__tablename__ = "task_drafts"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
session_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
nullable=False,
)
draft_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
confirmed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
# Relationships
session: Mapped["PrompterSessionTable"] = relationship(
"PrompterSessionTable", back_populates="drafts"
)
__table_args__ = (
Index("ix_task_drafts_session_id", "session_id"),
Index("ix_task_drafts_task_id", "task_id"),
)
+18
View File
@@ -265,6 +265,16 @@ class Task(TimestampMixin):
description="True after QA inspects inline diff via claim_review.",
)
# Prompter origin tracking
source: str = Field(
default="manual",
description="Origin of the task: 'manual', 'prompter', etc.",
)
confirmed_by_human: bool = Field(
default=False,
description="Whether a human has confirmed this prompter-originated task.",
)
# NOTE: Task state mutations should be performed through TaskService,
# not directly on the model. See roboco/services/task.py for:
# - claim(), start(), block(), pause(), resume()
@@ -325,6 +335,10 @@ class TaskCreate(RobocoBase):
project_id: UUID | None = None
product_id: UUID | None = None
# Prompter origin tracking
source: str = Field(default="manual")
confirmed_by_human: bool = Field(default=False)
@model_validator(mode="after")
def _project_or_product(self) -> "TaskCreate":
if self.project_id is None and self.product_id is None:
@@ -400,3 +414,7 @@ class TaskCreateRequest:
# Ordering and dependencies
sequence: int = 0 # Order within siblings (lower = first)
dependency_ids: list[UUID] = field(default_factory=list)
# Prompter origin tracking
source: str = "manual"
confirmed_by_human: bool = False
+625
View File
@@ -0,0 +1,625 @@
"""
Prompter Service
Conversational LLM assistant that helps users draft tasks.
Uses Anthropic Claude for natural-language interaction and
structured JSON draft generation.
Provides both a session-based approach (DB-persisted) and a
legacy stateless interface for backward compatibility.
"""
from __future__ import annotations
import contextlib
import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4
import structlog
from anthropic import AsyncAnthropic
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import (
PrompterMessageTable,
PrompterSessionTable,
TaskDraftTable,
TaskTable,
)
from roboco.models.base import Complexity, TaskNature, TaskType, Team
from roboco.models.task import TaskCreateRequest
from roboco.services.base import NotFoundError, ServiceError, ValidationError
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Input types
# ---------------------------------------------------------------------------
@dataclass
class ConfirmOverrides:
"""Optional overrides applied when confirming a draft to create a task."""
project_id: UUID | None = None
product_id: UUID | None = None
assigned_to: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
_PROMPTER_SYSTEM_PROMPT = (
"You are the RoboCo Prompter — a conversational assistant that helps "
"users draft tasks for an AI agentic company.\n\n"
"Your job is to:\n"
"1. Ask clarifying questions to gather requirements.\n"
"2. Keep the conversation focused on producing a well-scoped task.\n"
"3. When you believe you have enough context, signal that a draft is "
"ready.\n"
"4. Never create the task yourself — only help the user articulate what "
"needs to be built.\n\n"
"Key rules:\n"
"- Be concise but thorough.\n"
"- Always ask for acceptance criteria if the user hasn't provided them.\n"
"- Suggest a team (backend, frontend, ux_ui) based on the work "
"described.\n"
"- Estimate complexity (low, medium, high) and task type (code, "
"documentation, research, planning, design, administrative).\n"
"- Determine nature (technical vs non_technical).\n"
"- If the user describes a bug, suggest a code task with technical "
"nature.\n"
"- If the user describes a feature, determine whether it's backend, "
"frontend, or UX/UI work.\n\n"
"When you have enough information to produce a complete draft, say so "
"explicitly with 'I have enough information to draft a task' or "
"'ready to draft'."
)
_DRAFT_SYSTEM_PROMPT = (
"You are the RoboCo Prompter — an expert at converting conversations "
"into structured task drafts.\n\n"
"Given a conversation between a user and the Prompter assistant, "
"produce a JSON task draft that conforms to the RoboCo task schema.\n\n"
"Required fields:\n"
"- title: concise, actionable task title (max 200 chars)\n"
"- description: detailed description, min 20 chars, explaining what "
"needs to be done\n"
"- acceptance_criteria: list of strings, each a verifiable criterion "
"(min 1)\n"
"- team: one of backend, frontend, ux_ui\n"
"- task_type: one of code, documentation, research, planning, design, "
"administrative\n"
"- nature: one of technical, non_technical\n"
"- estimated_complexity: one of low, medium, high\n"
"- priority: integer 0-3 (0=P0 highest, 3=P3 lowest)\n\n"
"Optional fields:\n"
"- project_id: UUID string if known from context\n"
"- product_id: UUID string if known from context (only one of "
"project_id/product_id should be set)\n"
"- assigned_to: agent slug or UUID if the user specified one\n"
"- target_date: ISO-8601 date string if mentioned\n\n"
'Always set source="prompter" and confirmed_by_human=false.\n\n'
"Return ONLY valid JSON matching the PrompterDraftTask schema. No "
"markdown, no preamble."
)
class PrompterService:
"""Service for Prompter chat, session management, and structured draft generation.
Accepts an optional SQLAlchemy ``AsyncSession`` for the session-based
(DB-persisted) interface. When no session is provided, only the legacy
stateless ``chat()`` and ``draft()`` methods are available.
"""
def __init__(self, db: AsyncSession | None = None) -> None:
self.log = logger.bind(component="prompter_service")
self._client: AsyncAnthropic | None = None
self._db = db
def _get_client(self) -> AsyncAnthropic:
"""Lazy-init Anthropic client."""
if self._client is None:
api_key = settings.anthropic_api_key
if not api_key:
raise ServiceError("Anthropic API key not configured")
self._client = AsyncAnthropic(api_key=api_key)
return self._client
@property
def _session(self) -> AsyncSession:
"""Return DB session, raising if not configured."""
if self._db is None:
raise ServiceError(
"PrompterService was created without a DB session; "
"session-based methods are unavailable"
)
return self._db
# -----------------------------------------------------------------------
# Session-based interface
# -----------------------------------------------------------------------
async def create_session(
self,
agent_id: UUID,
context: dict[str, Any] | None = None, # noqa: ARG002
) -> PrompterSessionTable:
"""Create a new Prompter conversation session."""
session = PrompterSessionTable(
id=uuid4(),
agent_id=agent_id,
status="active",
created_at=datetime.now(UTC),
)
self._session.add(session)
await self._session.flush()
self.log.info("Prompter session created", session_id=str(session.id))
return session
async def send_message(
self,
session_id: UUID,
agent_id: UUID,
content: str,
context: dict[str, Any] | None = None,
) -> list[PrompterMessageTable]:
"""
Append a user message, call the LLM for a reply, persist both,
and return all messages in the session.
"""
session = await self._get_session(session_id, agent_id)
# Persist the user message first
user_msg = PrompterMessageTable(
id=uuid4(),
session_id=session_id,
role="user",
content=content,
created_at=datetime.now(UTC),
)
self._session.add(user_msg)
await self._session.flush()
# Load full conversation history for the LLM call
history = await self._load_messages(session_id)
chat_messages = [{"role": m.role, "content": m.content} for m in history]
# Call the LLM
llm_reply = await self._llm_chat(
messages=chat_messages,
context=context,
)
# Persist the assistant reply
assistant_msg = PrompterMessageTable(
id=uuid4(),
session_id=session_id,
role="assistant",
content=llm_reply["message"],
created_at=datetime.now(UTC),
)
self._session.add(assistant_msg)
# Update session status if draft is ready
if llm_reply["draft_ready"] and session.status == "active":
session.status = "draft_ready"
await self._session.flush()
self.log.info(
"Message processed",
session_id=str(session_id),
draft_ready=llm_reply["draft_ready"],
)
# Return all messages in order
return await self._load_messages(session_id)
async def get_or_generate_draft(
self,
session_id: UUID,
agent_id: UUID,
) -> TaskDraftTable:
"""
Return an existing draft for the session, or generate one via LLM
if none exists yet.
"""
await self._get_session(session_id, agent_id)
# Check for an existing draft
result = await self._session.execute(
select(TaskDraftTable)
.where(TaskDraftTable.session_id == session_id)
.order_by(TaskDraftTable.created_at.desc())
.limit(1)
)
existing = result.scalar_one_or_none()
if existing is not None:
return existing
# No draft yet — generate one from conversation history
history = await self._load_messages(session_id)
if not history:
raise ValidationError(
message=(
"Cannot generate a draft from an empty conversation; "
"send at least one message first."
),
field="messages",
)
chat_messages = [{"role": m.role, "content": m.content} for m in history]
draft_result = await self._llm_draft(
messages=chat_messages,
)
draft_record = TaskDraftTable(
id=uuid4(),
session_id=session_id,
draft_data=draft_result["draft"],
created_at=datetime.now(UTC),
)
self._session.add(draft_record)
await self._session.flush()
return draft_record
async def confirm_draft(
self,
session_id: UUID,
agent_id: UUID,
confirm_overrides: ConfirmOverrides | None = None,
) -> UUID:
"""
Validate the draft and create a real Task via the TaskService.
Returns the newly created task's UUID.
"""
session_rec = await self._get_session(session_id, agent_id)
ov = confirm_overrides or ConfirmOverrides()
# Get or generate the draft
draft_record = await self.get_or_generate_draft(session_id, agent_id)
draft_data: dict[str, Any] = dict(draft_record.draft_data)
# Apply overrides
if ov.project_id is not None:
draft_data["project_id"] = str(ov.project_id)
if ov.product_id is not None:
draft_data["product_id"] = str(ov.product_id)
if ov.assigned_to is not None:
draft_data["assigned_to"] = ov.assigned_to
if ov.extra:
draft_data.update(ov.extra)
# Resolve project/product IDs
resolved_project_id: UUID | None = None
resolved_product_id: UUID | None = None
if draft_data.get("project_id"):
try:
resolved_project_id = UUID(str(draft_data["project_id"]))
except ValueError as exc:
raise ValidationError(
message=f"Invalid project_id UUID: {draft_data['project_id']}",
field="project_id",
) from exc
if draft_data.get("product_id"):
try:
resolved_product_id = UUID(str(draft_data["product_id"]))
except ValueError as exc:
raise ValidationError(
message=f"Invalid product_id UUID: {draft_data['product_id']}",
field="product_id",
) from exc
if resolved_project_id is None and resolved_product_id is None:
raise ValidationError(
message=(
"The draft must have either project_id or product_id set. "
"Pass one via the confirm request body."
),
field="project_id",
)
# Validate and coerce required fields
try:
team = Team(draft_data["team"])
task_type = TaskType(draft_data["task_type"])
nature = TaskNature(draft_data["nature"])
complexity = Complexity(draft_data["estimated_complexity"])
except (KeyError, ValueError) as exc:
raise ValidationError(
message=f"Draft has invalid or missing required fields: {exc}",
field="draft",
) from exc
# Resolve assigned_to as UUID if possible
resolved_assigned_to: UUID | None = None
if draft_data.get("assigned_to"):
with contextlib.suppress(ValueError):
resolved_assigned_to = UUID(str(draft_data["assigned_to"]))
req = TaskCreateRequest(
title=draft_data["title"],
description=draft_data["description"],
acceptance_criteria=draft_data["acceptance_criteria"],
team=team,
created_by=agent_id,
task_type=task_type,
nature=nature,
estimated_complexity=complexity,
priority=int(draft_data.get("priority", 2)),
assigned_to=resolved_assigned_to,
project_id=resolved_project_id,
product_id=resolved_product_id,
source="prompter",
confirmed_by_human=True,
)
# Import TaskService lazily to avoid circular imports
from roboco.services.task import get_task_service
task_service = get_task_service(self._session)
task: TaskTable = await task_service.create(req)
# Mark draft as confirmed
now = datetime.now(UTC)
draft_record.confirmed_at = now
draft_record.task_id = task.id
session_rec.status = "confirmed"
await self._session.flush()
self.log.info(
"Draft confirmed — task created",
session_id=str(session_id),
task_id=str(task.id),
)
return task.id # type: ignore[return-value]
# -----------------------------------------------------------------------
# Private helpers (session-based)
# -----------------------------------------------------------------------
async def _get_session(
self, session_id: UUID, agent_id: UUID
) -> PrompterSessionTable:
"""Load and authorize a PrompterSession."""
result = await self._session.execute(
select(PrompterSessionTable).where(PrompterSessionTable.id == session_id)
)
rec = result.scalar_one_or_none()
if rec is None:
raise NotFoundError(f"Prompter session {session_id} not found")
if rec.agent_id != agent_id:
raise ServiceError(
f"Session {session_id} does not belong to agent {agent_id}"
)
return rec
async def _load_messages(self, session_id: UUID) -> list[PrompterMessageTable]:
"""Return all messages for a session ordered by creation time."""
result = await self._session.execute(
select(PrompterMessageTable)
.where(PrompterMessageTable.session_id == session_id)
.order_by(PrompterMessageTable.created_at)
)
return list(result.scalars().all())
# -----------------------------------------------------------------------
# Shared LLM helpers
# -----------------------------------------------------------------------
async def _llm_chat(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 2048,
) -> dict[str, Any]:
"""Call the LLM for a chat response. Returns {message, draft_ready}."""
client = self._get_client()
user_prompt = _build_chat_prompt(messages, context)
try:
response = await client.messages.create(
model=model,
max_tokens=max_tokens,
system=_PROMPTER_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
except Exception as e:
self.log.error("Prompter chat LLM call failed", error=str(e))
raise ServiceError(f"LLM chat failed: {e}") from e
content = _extract_text(response)
if not content:
raise ServiceError("LLM returned empty content")
return {
"message": content,
"draft_ready": _detect_draft_ready(content),
}
async def _llm_draft(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 4096,
) -> dict[str, Any]:
"""Call the LLM to generate a structured draft. Returns {draft, reasoning}."""
client = self._get_client()
user_prompt = _build_draft_prompt(messages, context)
try:
response = await client.messages.create(
model=model,
max_tokens=max_tokens,
system=_DRAFT_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
except Exception as e:
self.log.error("Prompter draft LLM call failed", error=str(e))
raise ServiceError(f"LLM draft generation failed: {e}") from e
content = _extract_text(response)
if not content:
raise ServiceError("LLM returned empty content for draft")
try:
draft_data = json.loads(content)
except json.JSONDecodeError as e:
self.log.warning("Draft JSON parse failed", content_preview=content[:200])
raise ValidationError(
message=f"Draft response was not valid JSON: {e}",
field="draft",
) from e
draft_data["source"] = "prompter"
draft_data["confirmed_by_human"] = False
return {
"draft": draft_data,
"reasoning": _build_reasoning(messages, draft_data),
}
# -----------------------------------------------------------------------
# Legacy stateless interface
# -----------------------------------------------------------------------
async def chat(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 2048,
) -> dict[str, Any]:
"""Continue a Prompter conversation (stateless)."""
return await self._llm_chat(
messages=messages,
context=context,
model=model,
max_tokens=max_tokens,
)
async def draft(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 4096,
) -> dict[str, Any]:
"""Generate a structured task draft from conversation context (stateless)."""
return await self._llm_draft(
messages=messages,
context=context,
model=model,
max_tokens=max_tokens,
)
# ---------------------------------------------------------------------------
# Module-level helpers (pure functions, no state)
# ---------------------------------------------------------------------------
def _build_chat_prompt(
messages: list[dict[str, str]],
context: dict[str, Any] | None,
) -> str:
lines: list[str] = []
if context:
lines.append("Context:")
for key, value in context.items():
lines.append(f" {key}: {value}")
lines.append("")
lines.append("Conversation:")
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
lines.append(f"{role}: {content}")
lines.append("")
lines.append(
"Continue the conversation as the Prompter assistant. "
"If you have enough information to draft a complete task, "
"say so explicitly."
)
return "\n".join(lines)
def _build_draft_prompt(
messages: list[dict[str, str]],
context: dict[str, Any] | None,
) -> str:
lines: list[str] = []
lines.append(
"Produce a JSON task draft from the following conversation. "
"Return ONLY valid JSON — no markdown, no preamble."
)
if context:
lines.append("")
lines.append("Overrides:")
for key, value in context.items():
lines.append(f" {key}: {value}")
lines.append("")
lines.append("Conversation:")
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
lines.append(f"{role}: {content}")
return "\n".join(lines)
def _extract_text(response: Any) -> str:
text_parts: list[str] = []
for block in getattr(response, "content", []):
if hasattr(block, "text"):
text_parts.append(block.text)
return "\n".join(text_parts).strip()
def _detect_draft_ready(content: str) -> bool:
signals = [
"i have enough information",
"ready to generate a draft",
"ready to draft",
"i can now draft",
"draft_ready=true",
"draft ready",
]
lower = content.lower()
return any(sig in lower for sig in signals)
def _build_reasoning(
messages: list[dict[str, str]],
draft_data: dict[str, Any],
) -> str:
title = draft_data.get("title", "Untitled")
team = draft_data.get("team", "unknown")
complexity = draft_data.get("estimated_complexity", "unknown")
return (
f"Draft generated from conversation of {len(messages)} messages. "
f"Proposed task '{title}' for team {team} "
f"with complexity {complexity}."
)
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def get_prompter_service(db: AsyncSession | None = None) -> PrompterService:
"""Create a PrompterService instance.
Pass ``db`` for the session-based interface; omit for the stateless
legacy interface.
"""
return PrompterService(db=db)
+3
View File
@@ -568,6 +568,9 @@ class TaskService(BaseService):
task_type=req.task_type,
project_id=req.project_id,
product_id=req.product_id,
# Prompter origin tracking
source=req.source,
confirmed_by_human=req.confirmed_by_human,
)
self.session.add(task)
await self.session.flush()
+790
View File
@@ -0,0 +1,790 @@
"""Prompter API route integration tests.
Covers both the new session-based endpoints:
POST /sessions, POST /sessions/{id}/messages, GET /sessions/{id}/draft,
POST /sessions/{id}/confirm
And the legacy stateless endpoints:
POST /chat, POST /draft
"""
from __future__ import annotations
import json
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.prompter import router as prompter_router
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models.base import AgentRole, AgentStatus
from roboco.models.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
# Expected message counts in multi-turn tests
_SINGLE_TURN_MSGS = 2 # 1 user + 1 assistant
_DOUBLE_TURN_MSGS = 4 # 2 user + 2 assistant
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def prompter_client(
db_session: AsyncSession,
) -> AsyncIterator[dict[str, Any]]:
agent = AgentTable(
id=uuid4(),
name="DevAgent",
slug=f"dev-agent-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
app = FastAPI()
app.include_router(prompter_router, prefix="/api/prompter")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=agent.id, # type: ignore[arg-type]
role=AgentRole.DEVELOPER,
team=None,
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "agent": agent, "db": db_session}
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def project_fixture(db_session: AsyncSession) -> ProjectTable:
"""Create a minimal project for task creation in confirm tests."""
project = ProjectTable(
id=uuid4(),
name="Test Project",
slug=f"test-project-{uuid4().hex[:8]}",
git_url="https://github.com/test/repo.git",
git_branch="main",
)
db_session.add(project)
await db_session.flush()
return project
_HDR = {"X-Agent-ID": "be-dev-1", "X-Agent-Role": "developer"}
# =============================================================================
# Session-based endpoint tests
# =============================================================================
@pytest.mark.asyncio
async def test_create_session_success(prompter_client: dict) -> None:
"""POST /sessions creates a new session linked to the agent."""
client = prompter_client["client"]
response = await client.post(
"/api/prompter/sessions",
json={},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert "id" in body
assert body["status"] == "active"
assert "agent_id" in body
assert "created_at" in body
@pytest.mark.asyncio
async def test_create_session_with_context(prompter_client: dict) -> None:
"""POST /sessions accepts optional bootstrap context."""
client = prompter_client["client"]
response = await client.post(
"/api/prompter/sessions",
json={"context": {"team": "backend", "project_id": str(uuid4())}},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "active"
@pytest.mark.asyncio
async def test_send_message_success(prompter_client: dict) -> None:
"""POST /sessions/{id}/messages appends user+assistant messages."""
client = prompter_client["client"]
# Create session
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
mock_response = MagicMock()
mock_response.content = [MagicMock(text="Great! Let's gather requirements.")]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=mock_response,
):
response = await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "I need a new feature"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
messages = response.json()
assert len(messages) == _SINGLE_TURN_MSGS
roles = [m["role"] for m in messages]
assert "user" in roles
assert "assistant" in roles
assert messages[-1]["content"] == "Great! Let's gather requirements."
@pytest.mark.asyncio
async def test_send_message_marks_draft_ready(prompter_client: dict) -> None:
"""draft_ready signal in LLM response updates session status."""
client = prompter_client["client"]
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
mock_response = MagicMock()
mock_response.content = [
MagicMock(
text=(
"I have enough information to draft a task now. "
"Ready to draft when you are."
)
)
]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=mock_response,
):
response = await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "Add a login page with MFA support"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
messages = response.json()
assert len(messages) == _SINGLE_TURN_MSGS
@pytest.mark.asyncio
async def test_send_message_not_found(prompter_client: dict) -> None:
"""POST /sessions/{id}/messages with unknown session → 404."""
client = prompter_client["client"]
response = await client.post(
f"/api/prompter/sessions/{uuid4()}/messages",
json={"content": "Hello"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_get_draft_generates_from_conversation(prompter_client: dict) -> None:
"""GET /sessions/{id}/draft generates a draft via LLM."""
client = prompter_client["client"]
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
draft_json = {
"title": "Add login page",
"description": "Implement a secure login page with email and password",
"acceptance_criteria": [
"User can enter email and password",
"Invalid credentials show error message",
],
"team": "frontend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
"priority": 2,
}
chat_response = MagicMock()
chat_response.content = [MagicMock(text="Tell me more about the requirements.")]
draft_response = MagicMock()
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=chat_response,
):
await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "I need a login page"},
headers=_HDR,
)
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=draft_response,
):
response = await client.get(
f"/api/prompter/sessions/{session_id}/draft",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["draft"]["title"] == "Add login page"
assert body["draft"]["source"] == "prompter"
assert body["confirmed_at"] is None
assert body["draft"]["confirmed_by_human"] is False
assert body["session_id"] == session_id
@pytest.mark.asyncio
async def test_get_draft_cached(prompter_client: dict) -> None:
"""GET /sessions/{id}/draft returns the cached draft on subsequent calls."""
client = prompter_client["client"]
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
draft_json = {
"title": "Add login page",
"description": "Implement a secure login page with email and password",
"acceptance_criteria": ["User can enter credentials"],
"team": "frontend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
"priority": 2,
}
chat_response = MagicMock()
chat_response.content = [MagicMock(text="Got it.")]
draft_response = MagicMock()
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=chat_response,
):
await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "I need a login page"},
headers=_HDR,
)
call_count = 0
async def _mock_create(**_kwargs: Any) -> MagicMock:
nonlocal call_count
call_count += 1
return draft_response
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
side_effect=_mock_create,
):
await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR)
second_response = await client.get(
f"/api/prompter/sessions/{session_id}/draft", headers=_HDR
)
assert second_response.status_code == HTTPStatus.OK
# LLM should only be called once (draft is cached)
assert call_count == 1
@pytest.mark.asyncio
async def test_get_draft_empty_session_returns_400(prompter_client: dict) -> None:
"""GET /sessions/{id}/draft with no messages → 400."""
client = prompter_client["client"]
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
response = await client.get(
f"/api/prompter/sessions/{session_id}/draft",
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_confirm_draft_creates_task(
prompter_client: dict, project_fixture: ProjectTable
) -> None:
"""POST /sessions/{id}/confirm validates draft and creates a real task."""
client = prompter_client["client"]
project_id = str(project_fixture.id)
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
draft_json = {
"title": "Add login page",
"description": "Implement a secure login page with email and password",
"acceptance_criteria": ["User can enter credentials"],
"team": "frontend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
"priority": 2,
}
chat_response = MagicMock()
chat_response.content = [MagicMock(text="Got it.")]
draft_response = MagicMock()
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=chat_response,
):
await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "I need a login page"},
headers=_HDR,
)
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=draft_response,
):
await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR)
confirm_response = await client.post(
f"/api/prompter/sessions/{session_id}/confirm",
json={"project_id": project_id},
headers=_HDR,
)
assert confirm_response.status_code == HTTPStatus.CREATED
body = confirm_response.json()
assert "task_id" in body
assert body["task_id"] is not None
@pytest.mark.asyncio
async def test_confirm_draft_requires_project_or_product(
prompter_client: dict,
) -> None:
"""POST /sessions/{id}/confirm without project_id/product_id → 400."""
client = prompter_client["client"]
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
draft_json = {
"title": "Add login page",
"description": "Implement a secure login page with email and password",
"acceptance_criteria": ["User can enter credentials"],
"team": "frontend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
"priority": 2,
}
chat_response = MagicMock()
chat_response.content = [MagicMock(text="Got it.")]
draft_response = MagicMock()
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=chat_response,
):
await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "I need a login page"},
headers=_HDR,
)
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=draft_response,
):
await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR)
confirm_response = await client.post(
f"/api/prompter/sessions/{session_id}/confirm",
json={},
headers=_HDR,
)
assert confirm_response.status_code == HTTPStatus.BAD_REQUEST
# =============================================================================
# Full happy path integration test
# =============================================================================
@pytest.mark.asyncio
async def test_full_happy_path(
prompter_client: dict, project_fixture: ProjectTable
) -> None:
"""Full happy path: create session → send messages → get draft → confirm task."""
client = prompter_client["client"]
project_id = str(project_fixture.id)
# Step 1: Create session
step1 = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
assert step1.status_code == HTTPStatus.CREATED
session_id = step1.json()["id"]
# Step 2: Send messages
chat_mock = MagicMock()
chat_mock.content = [
MagicMock(text="Please describe the acceptance criteria for this feature.")
]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=chat_mock,
):
step2a = await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "I need a dark mode toggle for the UI"},
headers=_HDR,
)
assert step2a.status_code == HTTPStatus.OK
chat_mock2 = MagicMock()
chat_mock2.content = [
MagicMock(text="I have enough information to draft a task now.")
]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=chat_mock2,
):
step2b = await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "Preference is persisted across sessions"},
headers=_HDR,
)
assert step2b.status_code == HTTPStatus.OK
messages = step2b.json()
assert len(messages) == _DOUBLE_TURN_MSGS
# Step 3: Get draft
draft_json = {
"title": "Add dark mode toggle",
"description": "Implement a dark mode toggle so users can switch themes",
"acceptance_criteria": [
"User can toggle light/dark mode",
"Preference is persisted across sessions",
],
"team": "frontend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "low",
"priority": 2,
}
draft_mock = MagicMock()
draft_mock.content = [MagicMock(text=json.dumps(draft_json))]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=draft_mock,
):
step3 = await client.get(
f"/api/prompter/sessions/{session_id}/draft", headers=_HDR
)
assert step3.status_code == HTTPStatus.OK
draft_body = step3.json()
assert draft_body["draft"]["title"] == "Add dark mode toggle"
# Step 4: Confirm draft → creates task
step4 = await client.post(
f"/api/prompter/sessions/{session_id}/confirm",
json={"project_id": project_id},
headers=_HDR,
)
assert step4.status_code == HTTPStatus.CREATED
task_body = step4.json()
assert "task_id" in task_body
assert task_body["task_id"] is not None
# =============================================================================
# Legacy stateless endpoint tests (backward compatibility)
# =============================================================================
@pytest.mark.asyncio
async def test_prompter_chat_success(prompter_client: dict) -> None:
client = prompter_client["client"]
mock_response = MagicMock()
mock_response.content = [MagicMock(text="Great! Let's gather requirements.")]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=mock_response,
):
response = await client.post(
"/api/prompter/chat",
json={
"messages": [{"role": "user", "content": "I need a new feature"}],
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["message"] == "Great! Let's gather requirements."
assert body["draft_ready"] is False
@pytest.mark.asyncio
async def test_prompter_chat_draft_ready(prompter_client: dict) -> None:
client = prompter_client["client"]
mock_response = MagicMock()
mock_response.content = [
MagicMock(
text=(
"I have enough information. draft_ready=true."
" Ready to generate a draft."
)
)
]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=mock_response,
):
response = await client.post(
"/api/prompter/chat",
json={
"messages": [
{"role": "user", "content": "I need a new feature"},
{"role": "assistant", "content": "Tell me more"},
{"role": "user", "content": "Add a login page"},
],
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["draft_ready"] is True
@pytest.mark.asyncio
async def test_prompter_chat_llm_failure(prompter_client: dict) -> None:
client = prompter_client["client"]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
side_effect=Exception("Anthropic API unavailable"),
):
response = await client.post(
"/api/prompter/chat",
json={
"messages": [{"role": "user", "content": "Hello"}],
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
body = response.json()
assert "LLM chat failed" in body["detail"]["message"]
@pytest.mark.asyncio
async def test_prompter_draft_success(prompter_client: dict) -> None:
client = prompter_client["client"]
draft_json = {
"title": "Add login page",
"description": "Implement a secure login page with email and password",
"acceptance_criteria": [
"User can enter email and password",
"Invalid credentials show error message",
],
"team": "frontend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
"priority": 2,
}
mock_response = MagicMock()
mock_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=mock_response,
):
response = await client.post(
"/api/prompter/draft",
json={
"messages": [
{"role": "user", "content": "I need a login page"},
],
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["draft"]["title"] == "Add login page"
assert body["draft"]["source"] == "prompter"
assert body["draft"]["confirmed_by_human"] is False
assert "reasoning" in body
@pytest.mark.asyncio
async def test_prompter_draft_invalid_json_from_llm(prompter_client: dict) -> None:
client = prompter_client["client"]
mock_response = MagicMock()
mock_response.content = [MagicMock(text="not valid json")]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=mock_response,
):
response = await client.post(
"/api/prompter/draft",
json={
"messages": [{"role": "user", "content": "Hello"}],
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert "Draft response was not valid JSON" in body["detail"]["message"]
@pytest.mark.asyncio
async def test_prompter_draft_schema_mismatch(prompter_client: dict) -> None:
client = prompter_client["client"]
bad_draft = {
"title": "x",
"description": "too short",
}
mock_response = MagicMock()
mock_response.content = [MagicMock(text=json.dumps(bad_draft))]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
return_value=mock_response,
):
response = await client.post(
"/api/prompter/draft",
json={
"messages": [{"role": "user", "content": "Hello"}],
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
body = response.json()
assert "draft_schema_error" in body["detail"]["error"]
@pytest.mark.asyncio
async def test_prompter_draft_llm_failure(prompter_client: dict) -> None:
client = prompter_client["client"]
with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create",
new_callable=AsyncMock,
side_effect=Exception("Anthropic API unavailable"),
):
response = await client.post(
"/api/prompter/draft",
json={
"messages": [{"role": "user", "content": "Hello"}],
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
body = response.json()
assert "LLM draft generation failed" in body["detail"]["message"]
@pytest.mark.asyncio
async def test_prompter_chat_empty_messages(prompter_client: dict) -> None:
client = prompter_client["client"]
response = await client.post(
"/api/prompter/chat",
json={"messages": []},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_prompter_chat_invalid_role(prompter_client: dict) -> None:
client = prompter_client["client"]
response = await client.post(
"/api/prompter/chat",
json={"messages": [{"role": "invalid", "content": "hi"}]},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_prompter_draft_empty_messages(prompter_client: dict) -> None:
client = prompter_client["client"]
response = await client.post(
"/api/prompter/draft",
json={"messages": []},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
+212
View File
@@ -0,0 +1,212 @@
"""Unit tests for Prompter API schemas.
Covers schema validation for both the session-based and legacy schemas.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError as PydanticValidationError
from roboco.api.schemas.prompter import (
ChatMessage,
PrompterChatRequest,
PrompterDraftTask,
PrompterMessageRequest,
PrompterSessionCreateRequest,
TaskConfirmRequest,
)
# =============================================================================
# ChatMessage
# =============================================================================
def test_chat_message_valid_roles() -> None:
for role in ("user", "assistant", "system"):
msg = ChatMessage(role=role, content="Hello")
assert msg.role == role
def test_chat_message_invalid_role() -> None:
with pytest.raises(PydanticValidationError) as exc_info:
ChatMessage(role="admin", content="Hello")
assert "role must be one of" in str(exc_info.value)
def test_chat_message_empty_content() -> None:
with pytest.raises(PydanticValidationError):
ChatMessage(role="user", content="")
# =============================================================================
# PrompterSessionCreateRequest
# =============================================================================
def test_session_create_request_defaults() -> None:
req = PrompterSessionCreateRequest()
assert req.context == {}
def test_session_create_request_with_context() -> None:
req = PrompterSessionCreateRequest(context={"team": "backend"})
assert req.context == {"team": "backend"}
# =============================================================================
# PrompterMessageRequest
# =============================================================================
def test_message_request_valid() -> None:
req = PrompterMessageRequest(content="I need a feature")
assert req.content == "I need a feature"
assert req.context == {}
def test_message_request_empty_content() -> None:
with pytest.raises(PydanticValidationError):
PrompterMessageRequest(content="")
def test_message_request_with_context() -> None:
req = PrompterMessageRequest(content="Hello", context={"key": "value"})
assert req.context["key"] == "value"
# =============================================================================
# TaskConfirmRequest
# =============================================================================
def test_task_confirm_request_all_optional() -> None:
req = TaskConfirmRequest()
assert req.project_id is None
assert req.product_id is None
assert req.assigned_to is None
assert req.overrides == {}
def test_task_confirm_request_with_project() -> None:
pid = uuid4()
req = TaskConfirmRequest(project_id=pid)
assert req.project_id == pid
# =============================================================================
# PrompterDraftTask
# =============================================================================
def test_draft_task_valid() -> None:
draft = PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
assert draft.title == "Add login page"
assert draft.source == "prompter"
assert draft.confirmed_by_human is False
def test_draft_task_title_too_long() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="x" * 201,
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_description_too_short() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="short", # <20 chars
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_empty_acceptance_criteria() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=[],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_invalid_team() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="infra", # invalid
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_priority_bounds() -> None:
# Valid bounds
for p in (0, 1, 2, 3):
d = PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
priority=p,
)
assert d.priority == p
# Out of bounds
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
priority=4,
)
# =============================================================================
# PrompterChatRequest (legacy)
# =============================================================================
def test_chat_request_requires_messages() -> None:
with pytest.raises(PydanticValidationError):
PrompterChatRequest(messages=[])
def test_chat_request_valid() -> None:
req = PrompterChatRequest(messages=[ChatMessage(role="user", content="Hello")])
assert len(req.messages) == 1
assert req.context == {}
+354
View File
@@ -0,0 +1,354 @@
"""Unit tests for PrompterService.
Tests the service layer logic with mocked LLM calls. Uses an in-memory
async session (via conftest fixtures) for DB-backed tests.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable
from roboco.models.base import AgentRole, AgentStatus
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import (
PrompterService,
_build_chat_prompt,
_build_draft_prompt,
_build_reasoning,
_detect_draft_ready,
_extract_text,
get_prompter_service,
)
# =============================================================================
# Pure function tests (no DB)
# =============================================================================
def test_detect_draft_ready_signals() -> None:
signals = [
"I have enough information to proceed",
"Ready to generate a draft now.",
"ready to draft the task",
"i can now draft this.",
"draft_ready=true",
"The task is draft ready",
]
for text in signals:
assert _detect_draft_ready(text), f"Expected True for: {text!r}"
def test_detect_draft_ready_negative() -> None:
not_signals = [
"Tell me more about the feature.",
"Could you clarify the acceptance criteria?",
"Let's continue the conversation.",
]
for text in not_signals:
assert not _detect_draft_ready(text), f"Expected False for: {text!r}"
def test_extract_text_with_blocks() -> None:
block1 = MagicMock()
block1.text = "Hello, "
block2 = MagicMock()
block2.text = "world!"
response = MagicMock()
response.content = [block1, block2]
result = _extract_text(response)
assert result == "Hello, \nworld!"
def test_extract_text_empty_response() -> None:
response = MagicMock()
response.content = []
assert _extract_text(response) == ""
def test_extract_text_no_text_attr() -> None:
block = MagicMock(spec=[]) # no 'text' attribute
response = MagicMock()
response.content = [block]
assert _extract_text(response) == ""
def test_build_chat_prompt_basic() -> None:
messages = [
{"role": "user", "content": "I need a feature"},
{"role": "assistant", "content": "Tell me more"},
]
prompt = _build_chat_prompt(messages, None)
assert "user: I need a feature" in prompt
assert "assistant: Tell me more" in prompt
assert "Continue the conversation" in prompt
def test_build_chat_prompt_with_context() -> None:
messages = [{"role": "user", "content": "hello"}]
prompt = _build_chat_prompt(messages, {"team": "backend"})
assert "Context:" in prompt
assert "team: backend" in prompt
def test_build_draft_prompt() -> None:
messages = [{"role": "user", "content": "I need a login page"}]
prompt = _build_draft_prompt(messages, None)
assert "valid JSON" in prompt
assert "user: I need a login page" in prompt
def test_build_reasoning() -> None:
messages = [{"role": "user", "content": "Hello"}] * 3
draft = {"title": "My Task", "team": "backend", "estimated_complexity": "medium"}
reasoning = _build_reasoning(messages, draft)
assert "My Task" in reasoning
assert "backend" in reasoning
assert "medium" in reasoning
assert "3 messages" in reasoning
# =============================================================================
# Factory
# =============================================================================
def test_get_prompter_service_no_db() -> None:
service = get_prompter_service()
assert isinstance(service, PrompterService)
assert service._db is None
def test_get_prompter_service_raises_without_db_for_session_methods() -> None:
service = get_prompter_service()
with pytest.raises(ServiceError, match="DB session"):
_ = service._session
# =============================================================================
# Stateless chat / draft (with mocked LLM)
# =============================================================================
@pytest.mark.asyncio
async def test_chat_success_with_mock_llm() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [MagicMock(text="Great, let's continue!")]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await service.chat(
messages=[{"role": "user", "content": "I need a feature"}]
)
assert result["message"] == "Great, let's continue!"
assert result["draft_ready"] is False
@pytest.mark.asyncio
async def test_chat_draft_ready_signal() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [
MagicMock(text="I have enough information. Ready to draft.")
]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await service.chat(
messages=[{"role": "user", "content": "I need a feature"}]
)
assert result["draft_ready"] is True
@pytest.mark.asyncio
async def test_chat_raises_on_empty_response() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [] # Empty content blocks
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
with pytest.raises(ServiceError, match="LLM returned empty content"):
await service.chat(messages=[{"role": "user", "content": "Hello"}])
@pytest.mark.asyncio
async def test_chat_raises_on_llm_error() -> None:
service = get_prompter_service()
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(
side_effect=Exception("API unavailable")
)
mock_get_client.return_value = mock_client
with pytest.raises(ServiceError, match="LLM chat failed"):
await service.chat(messages=[{"role": "user", "content": "Hello"}])
@pytest.mark.asyncio
async def test_draft_success_with_mock_llm() -> None:
service = get_prompter_service()
draft_data = {
"title": "Add login",
"description": "Implement login functionality with JWT tokens",
"acceptance_criteria": ["User can log in"],
"team": "backend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
}
mock_response = MagicMock()
mock_response.content = [MagicMock(text=json.dumps(draft_data))]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await service.draft(
messages=[{"role": "user", "content": "I need a login feature"}]
)
assert result["draft"]["title"] == "Add login"
assert result["draft"]["source"] == "prompter"
assert result["draft"]["confirmed_by_human"] is False
assert "reasoning" in result
@pytest.mark.asyncio
async def test_draft_raises_on_invalid_json() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [MagicMock(text="Not JSON at all")]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
with pytest.raises(ValidationError, match="not valid JSON"):
await service.draft(messages=[{"role": "user", "content": "Hello"}])
@pytest.mark.asyncio
async def test_draft_raises_on_llm_error() -> None:
service = get_prompter_service()
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(
side_effect=Exception("API unavailable")
)
mock_get_client.return_value = mock_client
with pytest.raises(ServiceError, match="LLM draft generation failed"):
await service.draft(messages=[{"role": "user", "content": "Hello"}])
# =============================================================================
# API key validation
# =============================================================================
def test_get_client_raises_without_api_key() -> None:
service = get_prompter_service()
service._client = None # Force fresh init
with patch("roboco.services.prompter.settings") as mock_settings:
mock_settings.anthropic_api_key = None
with pytest.raises(ServiceError, match="Anthropic API key not configured"):
service._get_client()
# =============================================================================
# Session-based: create_session (DB-backed via conftest)
# =============================================================================
@pytest.mark.asyncio
async def test_create_session_db(db_session: Any) -> None:
"""create_session persists a PrompterSessionTable row."""
service = get_prompter_service(db=db_session)
agent = AgentTable(
id=uuid4(),
name="TestAgent",
slug=f"test-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type]
assert session.id is not None
assert session.status == "active"
assert session.agent_id == agent.id
@pytest.mark.asyncio
async def test_get_session_not_found(db_session: Any) -> None:
"""_get_session raises NotFoundError for unknown session ID."""
service = get_prompter_service(db=db_session)
with pytest.raises(NotFoundError):
await service._get_session(uuid4(), uuid4())
@pytest.mark.asyncio
async def test_get_draft_empty_session_raises(db_session: Any) -> None:
"""get_or_generate_draft raises ValidationError if no messages exist."""
service = get_prompter_service(db=db_session)
agent = AgentTable(
id=uuid4(),
name="TestAgent",
slug=f"test-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type]
with pytest.raises(ValidationError, match="empty conversation"):
await service.get_or_generate_draft(
session_id=session.id, # type: ignore[arg-type]
agent_id=agent.id, # type: ignore[arg-type]
)