Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)

* feat(a2a): live view — watch fleet conversations, CEO chime-in, reply budget

A2A_MESSAGE_SENT published from A2AService.send (excerpt-capped) and
fanned through the existing /ws/system bridge; CEO-only admin REST for
conversations/messages + a reply route on the publish-bearing send path;
panel /a2a page with live transcript and a composer gated on task-linked
conversations. The matrix gains its one asymmetric rule: CEO may message
anyone, nobody may target the CEO — and agent replies inside a
CEO-opened conversation are hard-budgeted to one per CEO message
(per conversation, per agent), rejected with wait-don't-retry guidance.
Built subagent-driven (Sonnet 5), reviewed; v1 seams documented in the
map delta.

* feat(prompter): intake remembers the task history

Intake spawns now carry a per-project chronological digest of recent
tasks (capped: 15 lines/project, 4000 chars total — ~300-1000 tokens)
merged into the ambient layer, and the interviewer gets a bounded
search_past_tasks tool (one shared implementation behind the grok MCP
tool and the Claude SDK in-process tool) to check precedent
mid-conversation. Informational memory only — the sequencing analyzer
keeps ownership of ordering. Built subagent-driven (Sonnet 5), reviewed;
pre-existing conventions-ambient MegaTask-scope gap flagged, untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 00:07:55 +02:00
committed by GitHub
co-authored by Renn F
parent 48f2944086
commit da563487b8
39 changed files with 3808 additions and 27 deletions
+4
View File
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added
- **Prompter memory — intake remembers the task history.** An intake session's prompt now carries a compact chronological digest of the scoped project's recent tasks (per-project for MegaTask scopes; hard-capped at ~1,000 tokens worst case, typically ~300), and the interviewer gains a `search_past_tasks` tool (bounded, both runtimes share one implementation) to check precedent mid-conversation — so a new task can be described and sequenced against what actually happened before. Informational only: the sequencing analyzer keeps ownership of ordering.
- **A2A live view — watch the fleet talk, and chime in.** New panel page (`/a2a`): live conversation list + transcript, updated in real time via a new `A2A_MESSAGE_SENT` event fanned through the existing `/ws/system` bridge (frames carry capped excerpts; full bodies stay on REST). The CEO can reply into any task-linked conversation as themselves. **Agent→CEO communication is reply-only and hard-budgeted in code**: no agent may initiate toward the CEO (stateless matrix block), and inside a conversation the CEO has posted in, each agent may send at most one message per CEO message (per-conversation, per-agent — 1:1:1 with multiple agents), with a rejection envelope that says to wait rather than retry. CEO→agent stays unrestricted — the one asymmetric rule in the matrix.
- **e2e scenario 4 — the MegaTask umbrella.** Seeds an umbrella + two dependency-linked root-subtasks; proves the sequencing hold (RS2's `i_will_plan` rejected `unmet_dependency` while RS1 is live), completes RS1 through the entire real chain (dev→QA→doc→PM→gate→CEO `approve-and-merge` to master), verifies the hold lifts, completes RS2, and closes the umbrella through its branchless path — Main-PM `complete` escalates, `POST /tasks/{id}/ceo-approve` (notes ≥ 20) finishes it, and the umbrella never carries a PR. Six scenarios now cover the full company loop in ~50s.
- **The PR-gate turn cut — assembled parents auto-submit to the reviewer.** When every child of an assembled parent is terminal, the orchestrator used to spawn the PM just to call `submit_up`/`submit_root` — a whole agent turn whose substance (freshness rebase, integrity check, PR open) is deterministic gate code. The closure dispatcher now runs the REAL submit verb through the internal API as the owning PM (`_try_auto_submit`); the task lands in `awaiting_pr_review` and the reviewer dispatch takes it with no PM turn spent. Every gate is intact: a submit rejection (freshness/integrity — the case that genuinely needs judgment) falls back to the classic PM closure spawn, `pr_fail` still routes `needs_revision` to the PM, and the PM keeps the final merge turn. Branchless coordination parents (MegaTask umbrellas) never auto-submit. Gated by `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (default **on**); each auto-submit leaves a `task.auto_submitted` audit row.
+10
View File
@@ -602,3 +602,13 @@ Slices touched: orchestrator (1), tests (1, 2), taskservice + api-routes-schemas
1. **Five dead comms panel components deleted** (422 lines; audit-verified zero consumers; MessageComposer/MessageTypeBadge stay).
2. **e2e scenario 4 (MegaTask umbrella)**`tests/e2e_smoke/test_megatask_umbrella.py` + arcs helpers (`wire_dependency` via the real sequencing edge, `seed_cell_and_dev`, `set_branch_name`). Proves: sequencing hold (`unmet_dependency` on RS2's i_will_plan), serial root merges to master, umbrella branchless close via ceo-approve (never approve-and-merge).
3. **PRODUCT FIX: batch root-subtask completion wall**`_main_pm_complete_guard` (_impl.py ~6708) + `escalate_to_ceo` (task.py ~5321) refused ALL parented tasks; both now consult `is_batch_root_subtask`. Live root-subtasks previously closed only via CEO god-mode. Regression tests in test_choreographer_pm.py + test_task_service_transitions.py. First product bug found BY the harness (subagent-built, Sonnet 5, reviewed).
---
## Delta 2026-07-03 (2) — A2A live view (branch `feat/wave-2b`, SDD/Sonnet 5, reviewed)
Backend: `EventType.A2A_MESSAGE_SENT` published from `A2AService.send` (excerpt-capped payload), `websocket_bridge` forwarder → `/ws/system` `a2a.message` frames; admin REST (`/a2a/chat/admin/conversations{,/{id}/messages}`, CEO-only) + CEO reply route via the publish-bearing `send` path. `agents_config.can_a2a_direct`: `from ceo` → allowed to anyone (the one asymmetry); `to ceo` stays blocked at creation. **Reply budget** (`_enforce_ceo_reply_budget` in `send_chat_message`): agent msgs ≥ CEO msgs in the conversation ⇒ reject; three-layer enforcement (matrix block → reply-channel resolve → budget). Panel: `/a2a` page + components, `use-a2a-live` hook over `useWebSocket("/system")`, composer gated on task-linked conversations. v1 seams flagged: legacy raw REST chat sends don't publish the live event; agent→CEO reply lookup matches topic-less conversations only; deduped re-send re-publishes an identical frame (idempotent consumer).
---
## Delta 2026-07-03 (3) — prompter memory (branch `feat/wave-2b`, SDD/Sonnet 5, reviewed)
`TaskService.list_recent_for_project` (recency = coalesce(completed,updated,created)); pure digest builders in `prompter.py` (15 lines/project, 70-char titles, 4000-char total cap); orchestrator `_resolve_history_digest_ambient` (+`_resolve_intake_ambient` merge, best-effort/non-blocking) injected at `_spawn_intake_container``_generate_composed_prompt(ambient=…)`; `GET /prompter/live/{id}/search-tasks` (session-liveness = trust boundary, q 2200, limit ≤10); `query_past_tasks`/`format_search_results` shared by the grok MCP tool AND the Claude SDK in-process tool (full parity, one implementation). FLAGGED pre-existing gap (untouched): `_resolve_conventions_ambient` doesn't cover the MegaTask `project_ids` scope — conventions ambient absent on MegaTask intakes.
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AdminConversationSummary, A2AChatMessage } from "@/lib/api/a2a";
const {
useA2AConversations,
useA2AMessages,
useA2ALiveStream,
invalidateQueries,
a2aLiveKeys,
} = vi.hoisted(() => ({
useA2AConversations: vi.fn(),
useA2AMessages: vi.fn(),
useA2ALiveStream: vi.fn(),
invalidateQueries: vi.fn(),
a2aLiveKeys: {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams("conversation=conv-1"),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-websocket", () => ({
useA2ALiveStream,
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQueryClient: vi.fn(() => ({ invalidateQueries })),
};
});
vi.mock("@/components/ui/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import A2APage from "../page";
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-2222-3333-4444-555555555555",
status: "active",
message_count: 2,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: "preview",
created_at: "2026-07-01T08:00:00Z",
updated_at: "2026-07-02T09:00:00Z",
...overrides,
};
}
function buildMessage(): A2AChatMessage {
return {
id: "m1",
conversation_id: "conv-1",
from_agent: "be-qa",
content: "transcript body text",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: "2026-07-02T09:00:00Z",
edited_at: null,
};
}
describe("A2APage", () => {
beforeEach(() => {
invalidateQueries.mockReset();
useA2AConversations.mockReturnValue({
data: { items: [buildConversation()], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
useA2AMessages.mockReturnValue({
data: { items: [buildMessage()], total: 1, has_more: false },
isLoading: false,
refetch: vi.fn(),
});
useA2ALiveStream.mockReturnValue({
lastMessage: null,
isConnected: true,
});
});
it("shows the transcript pane and composer for a task-linked conversation", () => {
render(<A2APage />);
expect(screen.getByText("transcript body text")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
screen.getByText(
/direct A2A message from you to the selected participant/i,
),
).toBeInTheDocument();
expect(screen.getByText("Live")).toBeInTheDocument();
});
it("keeps the composer for a closed but task-linked conversation", () => {
// The watched conversation's status must NOT gate the composer — the reply
// lands in the CEO's own direct thread with the participant.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ status: "closed" })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(<A2APage />);
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
});
it("hides the composer and explains why for a task-less conversation", () => {
// task_id === null is the authoritative signal that the backend's reply
// route would 400 (replies require a task link), so the pane is read-only.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ task_id: null })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(<A2APage />);
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no linked task, so a reply can't be sent/i),
).toBeInTheDocument();
});
it("invalidates conversations + selected messages on a matching a2a.message frame", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-1",
message_id: "m9",
from_agent: "be-dev-1",
to_agent: "be-qa",
body_excerpt: "capped",
timestamp: "2026-07-02T10:00:00Z",
},
isConnected: true,
});
render(<A2APage />);
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
});
it("only invalidates the conversation list for frames of other conversations", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-other",
timestamp: "2026-07-02T10:00:00Z",
},
isConnected: false,
});
render(<A2APage />);
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
expect(screen.getByText("Offline")).toBeInTheDocument();
});
});
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { Suspense, useCallback, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
} from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket";
import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import { MessagesSquare, Radio, RefreshCw } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
function EmptyPanel({
icon: Icon,
message,
}: {
icon: typeof MessagesSquare;
message: string;
}) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{message}</p>
</div>
</div>
);
}
function A2APageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const selectedId = searchParams.get("conversation");
const {
data: conversationData,
isLoading: loadingConversations,
error,
refetch: refetchConversations,
} = useA2AConversations();
const {
data: messagesData,
isLoading: loadingMessages,
refetch: refetchMessages,
} = useA2AMessages(selectedId);
// Live wiring: every persisted A2A message is announced on /ws/system as an
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
// frame's excerpt is capped by design, so REST stays the source of truth and
// react-query refetches the affected queries.
const { lastMessage, isConnected } = useA2ALiveStream();
useEffect(() => {
if (lastMessage?.type !== "a2a.message") return;
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
if (selectedId && lastMessage.conversation_id === selectedId) {
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(selectedId),
});
}
}, [lastMessage, queryClient, selectedId]);
const handleSelect = useCallback(
(id: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("conversation", id);
router.push(`/a2a?${params.toString()}`);
},
[router, searchParams],
);
const handleRefresh = () => {
refetchConversations();
if (selectedId) refetchMessages();
};
const conversations = conversationData?.items ?? [];
const selected = conversations.find((c) => c.id === selectedId) ?? null;
const messages = messagesData?.items ?? [];
const lastSender = lastSenderOf(messages);
const isOffline =
error &&
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">A2A Live</h1>
<p className="text-muted-foreground">
Live agent-to-agent conversations watch and chime in
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 rounded-full",
isConnected
? "bg-emerald-500 animate-pulse"
: "bg-muted-foreground/40",
)}
/>
<span className="text-xs text-muted-foreground">
{isConnected ? "Live" : "Offline"}
</span>
</div>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load A2A Conversations"
description="Start the RoboCo orchestrator to view agent-to-agent chats."
onRetry={() => refetchConversations()}
/>
) : (
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
{/* Panel 1: Conversations */}
<Card className="col-span-12 lg:col-span-4 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Conversations</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<A2AConversationList
conversations={conversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
/>
</div>
</CardContent>
</Card>
{/* Panel 2: Transcript + composer */}
<Card className="col-span-12 lg:col-span-8 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
{selected ? (
<>
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{getAgentDisplayName(selected.agent_a)}
{" ↔ "}
{getAgentDisplayName(selected.agent_b)}
</span>
<Badge
variant={
selected.status === "active" ? "default" : "secondary"
}
className="text-xs"
>
{selected.status}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))} ago
</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<A2ATranscript
messages={messages}
isLoading={loadingMessages}
/>
</div>
{/* Reply composer. The backend's reply route rejects with
400 exactly when the watched conversation has no task
link (replies ride the gateway send path, which requires
one), so a task-less conversation is read-only say why
instead of letting the send bounce. Status does NOT gate
the composer: the CEO's reply lands in their own direct
thread with the participant, not in this conversation. */}
<div className="shrink-0 border-t -mx-3">
{selected.task_id ? (
<A2AReplyComposer
key={selected.id}
conversationId={selected.id}
agentA={selected.agent_a}
agentB={selected.agent_b}
lastSender={lastSender}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped to a
task).
</div>
)}
</div>
</>
) : (
<EmptyPanel
icon={MessagesSquare}
message="Select a conversation to watch it live"
/>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function A2APage() {
return (
<Suspense
fallback={
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-4 lg:gap-6">
<Card className="col-span-12 lg:col-span-4">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-12 lg:col-span-8" />
</div>
</div>
}
>
<A2APageContent />
</Suspense>
);
}
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { AdminConversationSummary } from "@/lib/api/a2a";
import { A2AConversationList } from "../a2a-conversation-list";
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-2222-3333-4444-555555555555",
status: "active",
message_count: 7,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: "Tests are green on the branch.",
created_at: "2026-07-01T08:00:00Z",
updated_at: "2026-07-02T09:00:00Z",
...overrides,
};
}
describe("A2AConversationList", () => {
it("renders participants, relative time, preview, status badge and task chip", () => {
render(
<A2AConversationList
conversations={[buildConversation()]}
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
/>,
);
// Participants via getAgentDisplayName ("{a} <-> {b}").
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
// Topic, preview, message count, relative timestamp.
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(
screen.getByText("Tests are green on the branch."),
).toBeInTheDocument();
expect(screen.getByText("7 msgs")).toBeInTheDocument();
expect(screen.getByText(/ago$/)).toBeInTheDocument();
// Status badge.
expect(screen.getByText("active")).toBeInTheDocument();
// Task chip links to the task page.
const chip = screen.getByRole("link", { name: /Task 11111111/ });
expect(chip).toHaveAttribute(
"href",
"/tasks/11111111-2222-3333-4444-555555555555",
);
});
it("fires onSelect with the conversation id on row click", () => {
const onSelect = vi.fn();
render(
<A2AConversationList
conversations={[buildConversation()]}
selectedId={null}
onSelect={onSelect}
isLoading={false}
/>,
);
fireEvent.click(screen.getByRole("button"));
expect(onSelect).toHaveBeenCalledWith("conv-1");
});
it("does not hijack row selection when the task chip is clicked", () => {
const onSelect = vi.fn();
render(
<A2AConversationList
conversations={[buildConversation()]}
selectedId={null}
onSelect={onSelect}
isLoading={false}
/>,
);
fireEvent.click(screen.getByRole("link", { name: /Task 11111111/ }));
expect(onSelect).not.toHaveBeenCalled();
});
it("shows the empty state when there are no conversations", () => {
render(
<A2AConversationList
conversations={[]}
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
/>,
);
expect(screen.getByText(/No A2A conversations yet/)).toBeInTheDocument();
});
});
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import React from "react";
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
vi.mock("@/hooks/use-a2a-live", () => ({
useReplyAsCeo: () => ({ mutate, isPending: false }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// Make the Select testable without Radix's portal/pointer machinery: each
// SelectItem renders a button carrying its value; clicking it invokes the
// nearest Select's onValueChange (scoped via context).
vi.mock("@/components/ui/select", () => {
const Ctx = React.createContext<(v: string) => void>(() => {});
return {
Select: ({
onValueChange,
children,
}: {
onValueChange?: (v: string) => void;
children: React.ReactNode;
}) => (
<Ctx.Provider value={onValueChange ?? (() => {})}>
{children}
</Ctx.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const onValueChange = React.useContext(Ctx);
return (
<button data-value={value} onClick={() => onValueChange(value)}>
{children}
</button>
);
},
};
});
import { A2AReplyComposer } from "../a2a-reply-composer";
function renderComposer(lastSender: string | null = "be-qa") {
return render(
<A2AReplyComposer
conversationId="conv-1"
agentA="be-dev-1"
agentB="be-qa"
lastSender={lastSender}
/>,
);
}
describe("A2AReplyComposer", () => {
beforeEach(() => {
mutate.mockReset();
});
it("disables Send when the textarea is empty", () => {
renderComposer();
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
});
it("sends { to_agent, content } defaulting to the last message's sender", () => {
renderComposer("be-qa");
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
target: { value: "Ship it" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: "conv-1",
to_agent: "be-qa",
content: "Ship it",
}),
expect.anything(),
);
});
it("falls back to agent_a when there is no last sender", () => {
renderComposer(null);
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
target: { value: "Status?" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ to_agent: "be-dev-1", content: "Status?" }),
expect.anything(),
);
});
it("sends to an explicitly selected participant", () => {
const { container } = renderComposer("be-qa");
container
.querySelector<HTMLButtonElement>('[data-value="be-dev-1"]')
?.click();
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
target: { value: "Over to you" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ to_agent: "be-dev-1" }),
expect.anything(),
);
});
it("states the pairwise seam honestly in the helper text", () => {
renderComposer();
// Guard the honesty note: the reply is a DIRECT CEO->participant message,
// not an injection into the watched transcript.
expect(
screen.getByText(
/direct A2A message from you to the selected participant/i,
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,78 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { A2AChatMessage } from "@/lib/api/a2a";
// react-markdown is heavyweight and irrelevant here — render bodies as-is.
vi.mock("@/components/ui/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
import { A2ATranscript } from "../a2a-transcript";
function buildMessage(overrides: Partial<A2AChatMessage>): A2AChatMessage {
return {
id: "m1",
conversation_id: "conv-1",
from_agent: "be-dev-1",
content: "hello",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: "2026-07-02T10:00:00Z",
edited_at: null,
...overrides,
};
}
describe("A2ATranscript", () => {
it("renders messages chronologically with sender names and timestamps", () => {
// Deliberately unordered payload: the later message first.
const { container } = render(
<A2ATranscript
messages={[
buildMessage({
id: "m2",
from_agent: "be-qa",
content: "second message body",
created_at: "2026-07-02T10:05:00Z",
}),
buildMessage({
id: "m1",
from_agent: "be-dev-1",
content: "first message body",
created_at: "2026-07-02T10:00:00Z",
}),
]}
isLoading={false}
/>,
);
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Backend QA")).toBeInTheDocument();
// Every message carries a relative timestamp.
expect(screen.getAllByText(/ago$/)).toHaveLength(2);
// Chronological order: oldest first regardless of payload order.
const text = container.textContent ?? "";
expect(text.indexOf("first message body")).toBeLessThan(
text.indexOf("second message body"),
);
});
it("shows the message kind as an outline badge when present", () => {
render(
<A2ATranscript
messages={[buildMessage({ message_kind: "escalation" })]}
isLoading={false}
/>,
);
expect(screen.getByText("escalation")).toBeInTheDocument();
});
it("shows the empty state when there are no messages", () => {
render(<A2ATranscript messages={[]} isLoading={false} />);
expect(
screen.getByText(/No messages in this conversation yet/),
).toBeInTheDocument();
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { lastSenderOf, pickDefaultRecipient } from "../a2a-utils";
describe("lastSenderOf", () => {
it("returns null for an empty transcript", () => {
expect(lastSenderOf([])).toBeNull();
});
it("returns the chronologically latest sender even when payload is unordered", () => {
expect(
lastSenderOf([
{ from_agent: "be-qa", created_at: "2026-07-02T10:05:00Z" },
{ from_agent: "be-dev-1", created_at: "2026-07-02T10:00:00Z" },
]),
).toBe("be-qa");
});
});
describe("pickDefaultRecipient", () => {
it("picks the last sender when they are a participant", () => {
expect(pickDefaultRecipient("be-dev-1", "be-qa", "be-qa")).toBe("be-qa");
});
it("falls back to agent_a when the transcript is empty", () => {
expect(pickDefaultRecipient("be-dev-1", "be-qa", null)).toBe("be-dev-1");
});
it("falls back to agent_a when the last sender is not a participant", () => {
expect(pickDefaultRecipient("be-dev-1", "be-qa", "fe-dev-1")).toBe(
"be-dev-1",
);
});
});
@@ -0,0 +1,124 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getAgentDisplayName } from "@/lib/agent-utils";
import type { AdminConversationSummary } from "@/lib/api/a2a";
import { formatDistanceToNow } from "date-fns";
import { ListTodo, MessagesSquare } from "lucide-react";
interface A2AConversationListProps {
conversations: AdminConversationSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
isLoading: boolean;
}
export function A2AConversationList({
conversations,
selectedId,
onSelect,
isLoading,
}: A2AConversationListProps) {
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
if (conversations.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No A2A conversations yet</p>
</div>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2 space-y-2">
{conversations.map((conversation) => (
<div
key={conversation.id}
role="button"
tabIndex={0}
onClick={() => onSelect(conversation.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(conversation.id);
}
}}
className={
"block w-full cursor-pointer p-3 rounded-lg border transition-all " +
(selectedId === conversation.id
? "bg-primary/10 border-primary"
: "bg-card hover:bg-muted/50 hover:border-primary/50")
}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">
{getAgentDisplayName(conversation.agent_a)}
{" ↔ "}
{getAgentDisplayName(conversation.agent_b)}
</div>
{conversation.topic && (
<div className="text-xs text-muted-foreground truncate mt-0.5">
{conversation.topic}
</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(
new Date(
conversation.last_message_at ?? conversation.created_at,
),
)}{" "}
ago
</div>
{conversation.last_message_preview && (
<p className="text-xs text-muted-foreground truncate mt-1">
{conversation.last_message_preview}
</p>
)}
{conversation.task_id && (
<Link
prefetch={false}
href={`/tasks/${conversation.task_id}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
>
<ListTodo className="h-3 w-3" />
Task {conversation.task_id.slice(0, 8)}
</Link>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Badge
variant={
conversation.status === "active" ? "default" : "secondary"
}
className="text-xs"
>
{conversation.status}
</Badge>
<span className="text-xs text-muted-foreground">
{conversation.message_count} msgs
</span>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Send } from "lucide-react";
import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { useReplyAsCeo } from "@/hooks/use-a2a-live";
import { pickDefaultRecipient } from "./a2a-utils";
interface A2AReplyComposerProps {
conversationId: string;
agentA: string;
agentB: string;
/** Slug of the sender of the latest transcript message (default recipient). */
lastSender: string | null;
disabled?: boolean;
}
export function A2AReplyComposer({
conversationId,
agentA,
agentB,
lastSender,
disabled,
}: A2AReplyComposerProps) {
const [content, setContent] = useState("");
// null = follow the default (last sender) until the CEO picks explicitly.
const [chosenRecipient, setChosenRecipient] = useState<string | null>(null);
const reply = useReplyAsCeo();
const recipient =
chosenRecipient ?? pickDefaultRecipient(agentA, agentB, lastSender);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = content.trim();
if (!trimmed || reply.isPending) return;
reply.mutate(
{ conversationId, to_agent: recipient, content: trimmed },
{
onSuccess: () => {
toast.success(`Reply sent to ${getAgentDisplayName(recipient)}`);
setContent("");
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
},
);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<form onSubmit={handleSubmit} className="p-4">
<div className="flex items-end gap-2">
<div className="flex-1">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Chime in... (Shift+Enter for new line)"
className="min-h-[60px] resize-none"
disabled={disabled || reply.isPending}
/>
</div>
<div className="flex flex-col gap-2">
<Select value={recipient} onValueChange={setChosenRecipient}>
<SelectTrigger className="w-auto min-w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[agentA, agentB].map((slug) => (
<SelectItem key={slug} value={slug}>
{getAgentDisplayName(slug)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="submit"
size="sm"
disabled={!content.trim() || disabled || reply.isPending}
>
<Send className="h-4 w-4 mr-1" />
Send
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Sends a direct A2A message from you to the selected participant. It
lands in your own conversation with that agent, not inside this
transcript.
</p>
</form>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useEffect, useRef } from "react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Markdown } from "@/components/ui/markdown";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import type { A2AChatMessage } from "@/lib/api/a2a";
import { formatDistanceToNow } from "date-fns";
import { MessagesSquare } from "lucide-react";
interface A2ATranscriptProps {
messages: A2AChatMessage[];
isLoading: boolean;
}
export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false);
// Chronological (oldest first) regardless of payload ordering.
const sorted = [...messages].sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
);
// Auto-scroll to bottom only once on initial load.
useEffect(() => {
if (scrollRef.current && sorted.length > 0 && !hasScrolledRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
hasScrolledRef.current = true;
}
}, [sorted.length]);
if (isLoading) {
return (
<div className="p-4 space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-12 w-full" />
</div>
</div>
))}
</div>
);
}
if (sorted.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No messages in this conversation yet</p>
</div>
</div>
);
}
return (
<div ref={scrollRef} className="h-full overflow-y-auto p-4">
<div className="space-y-3">
{sorted.map((message) => (
<div
key={message.id}
className="flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
>
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(message.from_agent)}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-semibold text-sm">
{getAgentDisplayName(message.from_agent)}
</span>
{message.message_kind && (
<Badge variant="outline" className="text-[10px]">
{message.message_kind}
</Badge>
)}
<span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(message.created_at))} ago
</span>
</div>
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Pure helpers for the A2A live view (extracted for direct unit testing).
*/
import type { A2AChatMessage } from "@/lib/api/a2a";
/**
* Slug of the sender of the chronologically latest message, or null when the
* transcript is empty. Sorts defensively the API contract is oldest-first,
* but the default-recipient pick must not depend on payload ordering.
*/
export function lastSenderOf(
messages: ReadonlyArray<Pick<A2AChatMessage, "from_agent" | "created_at">>,
): string | null {
if (messages.length === 0) return null;
const sorted = [...messages].sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
);
return sorted[sorted.length - 1].from_agent;
}
/**
* Default reply recipient: the participant who spoke last (the natural
* "answer them" target), falling back to agent_a when the transcript is empty
* or the last sender is not one of the two participants.
*/
export function pickDefaultRecipient(
agentA: string,
agentB: string,
lastSender: string | null,
): string {
if (lastSender === agentA || lastSender === agentB) return lastSender;
return agentA;
}
+2
View File
@@ -23,6 +23,7 @@ import {
Cpu,
Sparkles,
Building2,
Radio,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -50,6 +51,7 @@ export const navItems = [
// History
{ title: "Communications", href: "/communications", icon: MessageSquare },
{ title: "A2A Live", href: "/a2a", icon: Radio },
{ title: "Journals", href: "/journals", icon: BookOpen },
// System
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { a2aApi, type AdminReplyRequest } from "@/lib/api/a2a";
export const a2aLiveKeys = {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
};
// Conversation list — refreshed by WS `a2a.message` invalidation and the
// manual Refresh button; a short staleTime keeps remounts reasonably fresh.
export function useA2AConversations(limit?: number) {
return useQuery({
queryKey: [...a2aLiveKeys.conversations, limit ?? 50],
queryFn: () => a2aApi.listAdminConversations(limit),
staleTime: 30_000,
});
}
// Transcript for one conversation. WS frames for the selected conversation
// invalidate this key; full bodies always come from REST (excerpts are capped).
export function useA2AMessages(conversationId: string | null) {
return useQuery({
queryKey: a2aLiveKeys.messages(conversationId || ""),
queryFn: () => a2aApi.listAdminMessages(conversationId!),
enabled: !!conversationId,
staleTime: 30_000,
});
}
export interface ReplyAsCeoVariables extends AdminReplyRequest {
conversationId: string;
}
// CEO chime-in. The reply is a direct CEO->to_agent message (pairwise model);
// invalidate the list (the CEO<->agent conversation appears/updates there) and
// the watched transcript's messages.
export function useReplyAsCeo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ conversationId, ...reply }: ReplyAsCeoVariables) =>
a2aApi.replyAsCeo(conversationId, reply),
onSuccess: (_sent, variables) => {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(variables.conversationId),
});
},
});
}
+44
View File
@@ -44,6 +44,18 @@ export interface NotificationMessage {
timestamp?: string;
}
export interface A2ASystemMessage {
type: "connected" | "a2a.message";
conversation_id?: string;
message_id?: string;
task_id?: string;
from_agent?: string;
to_agent?: string;
skill?: string | null;
body_excerpt?: string;
timestamp?: string;
}
export interface SessionMessage {
type: "connected" | "message.new";
message_id?: string;
@@ -296,6 +308,38 @@ export function useNotificationStream() {
};
}
/**
* Subscribe to live A2A traffic on the operator stream (`/ws/system`).
*
* The bridge publishes an `a2a.message` frame for every persisted
* agent<->agent chat message. Frames carry a capped `body_excerpt` only
* consumers invalidate their REST queries for full bodies (the A2A live view
* idiom), never render the excerpt as the message.
*/
export function useA2ALiveStream() {
const {
state,
lastMessage,
messages,
clearMessages,
isConnected,
isConnecting,
} = useWebSocket<A2ASystemMessage>("/system", undefined, true);
// Filter to A2A frames (the system stream also carries rate-limit/usage).
const a2aMessages = messages.filter((m) => m.type === "a2a.message");
return {
state,
lastMessage,
a2aMessages,
allMessages: messages,
clearMessages,
isConnected,
isConnecting,
};
}
// =============================================================================
// Connection Status Hook (for UI indicators)
// =============================================================================
+153
View File
@@ -58,6 +58,57 @@ export interface A2AStreamChunk {
is_final: boolean;
}
/** Summary row for the CEO's admin view of an agent-to-agent conversation. */
export interface AdminConversationSummary {
id: string;
agent_a: string;
agent_b: string;
topic: string | null;
task_id: string | null;
status: string;
message_count: number;
last_message_at: string | null;
last_message_preview: string | null;
created_at: string;
updated_at: string;
}
/** One persisted A2A chat message (full body — WS frames only carry excerpts). */
export interface A2AChatMessage {
id: string;
conversation_id: string;
from_agent: string;
content: string;
message_kind: string;
response_to_id: string | null;
requires_response: boolean;
read_at: string | null;
created_at: string;
edited_at: string | null;
}
/**
* CEO reply payload. The backend sends a DIRECT CEO -> to_agent message (it
* lands in the CEO<->to_agent pairwise conversation), not an injection into
* the watched transcript.
*/
export interface AdminReplyRequest {
to_agent: string;
content: string;
skill?: string | null;
}
export interface AdminConversationListResponse {
items: AdminConversationSummary[];
total: number;
}
export interface AdminMessageListResponse {
items: A2AChatMessage[];
total: number;
has_more: boolean;
}
// =============================================================================
// API Client
// =============================================================================
@@ -197,4 +248,106 @@ export const a2aApi = {
const { data } = await api.get<A2AAgentCard>(`/a2a/agents/${agentId}/card`);
return data;
},
// ===========================================================================
// ADMIN (CEO) ENDPOINTS — A2A live view
// ===========================================================================
/**
* List agent<->agent conversations, most-recent-first (CEO-only).
*/
listAdminConversations: async (
limit: number = 50,
): Promise<AdminConversationListResponse> => {
if (isMockMode()) {
const now = new Date().toISOString();
return {
items: [
{
id: "mock-conversation-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: null,
status: "active",
message_count: 3,
last_message_at: now,
last_message_preview: "Tests are green on the branch.",
created_at: now,
updated_at: now,
},
],
total: 1,
};
}
const { data } = await api.get<AdminConversationListResponse>(
"/a2a/chat/admin/conversations",
{ params: { limit } },
);
return data;
},
/**
* List a conversation's messages, chronological oldest-first (CEO-only).
*/
listAdminMessages: async (
conversationId: string,
limit: number = 100,
): Promise<AdminMessageListResponse> => {
if (isMockMode()) {
const now = new Date().toISOString();
return {
items: [
{
id: "mock-a2a-message-1",
conversation_id: conversationId,
from_agent: "be-dev-1",
content: "Branch is ready for QA.",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: now,
edited_at: null,
},
],
total: 1,
has_more: false,
};
}
const { data } = await api.get<AdminMessageListResponse>(
`/a2a/chat/admin/conversations/${conversationId}/messages`,
{ params: { limit } },
);
return data;
},
/**
* Send a CEO reply. Lands in the CEO<->to_agent pairwise conversation (the
* A2A model is strictly pairwise), NOT inside the watched transcript.
*/
replyAsCeo: async (
conversationId: string,
request: AdminReplyRequest,
): Promise<A2AChatMessage> => {
if (isMockMode()) {
return {
id: `mock-reply-${Date.now()}`,
conversation_id: conversationId,
from_agent: "ceo",
content: request.content,
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: new Date().toISOString(),
edited_at: null,
};
}
const { data } = await api.post<A2AChatMessage>(
`/a2a/chat/admin/conversations/${conversationId}/reply`,
request,
);
return data;
},
};
+37 -5
View File
@@ -162,6 +162,11 @@ def _is_propose_batch(name: str) -> bool:
return name == "propose_batch" or name.endswith("__propose_batch")
def _is_search_past_tasks(name: str) -> bool:
"""True for the intake ``search_past_tasks`` tool, however namespaced."""
return name == "search_past_tasks" or name.endswith("__search_past_tasks")
def _batch_from_tool_input(tool_input: Any) -> dict[str, Any] | None:
"""Pull a MegaTask batch out of a ``propose_batch`` tool call's input.
@@ -419,6 +424,7 @@ def build_intake_options(
*,
system_prompt: str,
cwd: str,
session_id: str,
model: str | None = None,
) -> Any: # pragma: no cover - thin SDK construction
"""Build locked-down ``ClaudeAgentOptions`` for the intake session.
@@ -429,10 +435,14 @@ def build_intake_options(
- ``strict_mcp_config=True`` + ``setting_sources=[]`` ignore the host's
``~/.claude.json`` / ``settings.json``; use ONLY the MCP server below.
- ``permission_mode="dontAsk"`` (NOT ``bypassPermissions``) + a ``can_use_tool``
gate a hard allowlist (Read/Grep/Glob/Task + ``propose_draft``), no prompts.
gate a hard allowlist (Read/Grep/Glob/Task + ``propose_draft`` +
``propose_batch`` + ``search_past_tasks``), no prompts.
Draft emission: the agent calls the ``propose_draft`` MCP tool, which the
driver turns into a ``draft`` event deterministic, not a fragile text fence.
``search_past_tasks`` shares its HTTP + formatting logic with the grok-CLI
path via ``roboco.mcp.intake_server`` (``query_past_tasks`` /
``format_search_results``) one implementation, both runtimes.
NOTE: ``setting_sources=[]`` must be validated against the mounted-``~/.claude``
auth on the next smoke; if auth breaks, narrow it instead of removing it.
@@ -490,8 +500,27 @@ def build_intake_options(
]
}
@tool(
"search_past_tasks",
"Search past tasks by title/description/id-prefix. Use this "
"mid-conversation to check whether something like this has been built "
"before, or to find a predecessor to cite in a new draft's description "
"('follows up <short-id>'). Returns up to 10 compact results: short id, "
"title, status, team, date.",
{"query": str, "limit": int},
)
async def _search_past_tasks(args: dict[str, Any]) -> dict[str, Any]:
from roboco.mcp.intake_server import format_search_results, query_past_tasks
result = await query_past_tasks(
session_id, str(args.get("query", "")), limit=int(args.get("limit", 8) or 8)
)
return {"content": [{"type": "text", "text": format_search_results(result)}]}
server = create_sdk_mcp_server(
name="intake", version="1.0.0", tools=[_propose_draft, _propose_batch]
name="intake",
version="1.0.0",
tools=[_propose_draft, _propose_batch, _search_past_tasks],
)
async def _gate(tool_name: str, _input: dict[str, Any], _ctx: Any) -> Any:
@@ -499,6 +528,7 @@ def build_intake_options(
tool_name in _INTAKE_BASE_TOOLS
or _is_propose_draft(tool_name)
or _is_propose_batch(tool_name)
or _is_search_past_tasks(tool_name)
):
return PermissionResultAllow()
# The intake's job is to ask questions, so it reaches for AskUserQuestion
@@ -527,9 +557,10 @@ def build_intake_options(
return PermissionResultDeny(
message=(
f"{tool_name} is not available to the intake agent. Your only tools "
"are Read, Grep, Glob, Task, propose_draft, and propose_batch (for a "
"MegaTask). Ask the human inline; when the spec is ready, call "
"propose_draft (one task) or propose_batch (several)."
"are Read, Grep, Glob, Task, propose_draft, propose_batch (for a "
"MegaTask), and search_past_tasks. Ask the human inline; when the "
"spec is ready, call propose_draft (one task) or propose_batch "
"(several)."
)
)
@@ -541,6 +572,7 @@ def build_intake_options(
*_INTAKE_BASE_TOOLS,
"mcp__intake__propose_draft",
"mcp__intake__propose_batch",
"mcp__intake__search_past_tasks",
],
model=model,
include_partial_messages=True, # live token streaming
+1
View File
@@ -124,6 +124,7 @@ async def main() -> None: # pragma: no cover - needs the live container + SDK
options = build_intake_options(
system_prompt=system_prompt,
cwd=cwd,
session_id=session_id,
model=model,
)
+16 -2
View File
@@ -648,9 +648,23 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
from_team = get_agent_team(from_agent)
to_team = get_agent_team(to_agent)
# CEO is human - cannot A2A, use notifications
# The CEO (human, via the panel) may chime into any agent's A2A thread —
# the one asymmetric rule in this matrix: CEO may send, nobody may
# target CEO.
if from_role == "ceo":
return True, None
# CEO is human - agents can never INITIATE with the CEO. The only path in
# is a reply inside a conversation the CEO itself opened (enforced
# statefully in A2AService.send_chat_message's reply budget — this
# matrix stays stateless, so it blocks conversation *creation*
# unconditionally as defense-in-depth).
if to_role == "ceo":
return False, "CEO is human. Use notify() instead of A2A."
return (
False,
"CEO is human. You may only reply inside a conversation the "
"CEO opened — use notify() otherwise.",
)
# Board → board/main-pm (not CEO, not cells directly)
if from_role in ("product_owner", "head_marketing", "auditor"):
+195
View File
@@ -17,6 +17,7 @@ Endpoints:
import asyncio
import contextlib
from collections.abc import AsyncGenerator
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -27,10 +28,14 @@ from roboco.api.deps import (
CurrentAgentContext,
CurrentAgentSlug,
DbSession,
require_ceo_role,
require_pm_or_above,
)
from roboco.api.routes.v1._role_dep import require_any_authenticated_agent
from roboco.api.schemas.a2a_chat import (
AdminConversationListResponse,
AdminConversationSummaryResponse,
AdminReplyRequest,
ConversationCloseRequest,
ConversationCreateRequest,
ConversationListResponse,
@@ -48,6 +53,7 @@ from roboco.api.schemas.a2a_chat import (
from roboco.db.base import get_session_factory
from roboco.enforcement import A2AAccessDeniedError
from roboco.models.a2a import (
A2AConversation,
A2AConversationStatus,
A2ATask,
AgentCard,
@@ -823,6 +829,15 @@ async def send_chat_message(
"requires_response": data.requires_response,
},
)
except A2AAccessDeniedError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "A2A_ACCESS_DENIED",
"message": e.message,
"route_hint": e.route_hint,
},
) from None
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -889,3 +904,183 @@ async def get_task_conversations(
],
total=len(conversations),
)
# =============================================================================
# ADMIN / LIVE VIEW ENDPOINTS (CEO-only)
# =============================================================================
# The CEO's org-wide A2A live view: unlike the participant-scoped endpoints
# above, these read across every conversation regardless of who's a party to
# it, and let the CEO chime into an existing thread as itself.
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or reply to the A2A live view")
def _resolve_reply_target(conv: A2AConversation, to_agent: str) -> None:
"""Validate the CEO's reply target against the pairwise conversation.
Raises the appropriate 400 HTTPException kept out of the route handler
to keep its cyclomatic complexity low. A2A conversations are strictly
pairwise (no N-party thread), so the CEO must address one of the two
real participants; A2A is also scoped to a task by construction
(A2AService.send requires task_id), so an untethered conversation can't
be replied into via this path.
"""
if to_agent not in (conv.agent_a, conv.agent_b):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"{to_agent} is not a participant in this conversation "
f"(participants: {conv.agent_a}, {conv.agent_b})"
),
)
if conv.task_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Conversation has no linked task_id — A2A requires one",
)
@router.get("/chat/admin/conversations")
async def list_admin_conversations(
db: DbSession,
agent: CurrentAgentContext,
limit: int = Query(50, ge=1, le=100),
) -> AdminConversationListResponse:
"""CEO-only: list conversations across every agent pair, most-recent-first."""
_require_ceo(agent)
service = A2AService(db)
conversations = await service.list_conversations_admin(limit)
return AdminConversationListResponse(
items=[
AdminConversationSummaryResponse(
id=require_uuid(c.id),
agent_a=c.agent_a,
agent_b=c.agent_b,
topic=c.topic,
task_id=require_uuid(c.task_id) if c.task_id else None,
status=c.status,
message_count=c.message_count,
last_message_at=c.last_message_at,
last_message_preview=c.last_message_preview,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in conversations
],
total=len(conversations),
)
@router.get("/chat/admin/conversations/{conversation_id}/messages")
async def list_admin_chat_messages(
conversation_id: str,
db: DbSession,
agent: CurrentAgentContext,
limit: int = Query(100, ge=1, le=500),
before: datetime | None = None,
) -> MessageListResponse:
"""CEO-only: read any conversation's transcript, participant or not."""
_require_ceo(agent)
service = A2AService(db)
messages = await service.get_messages_admin(
conversation_id=require_uuid(conversation_id),
limit=limit + 1, # +1 to detect has_more
before=before,
)
has_more = len(messages) > limit
if has_more:
messages = messages[:limit]
return MessageListResponse(
items=[
MessageResponse(
id=require_uuid(m.id),
conversation_id=require_uuid(m.conversation_id),
from_agent=m.from_agent,
content=m.content,
message_kind=m.message_kind,
response_to_id=(
require_uuid(m.response_to_id) if m.response_to_id else None
),
requires_response=m.requires_response,
read_at=m.read_at,
created_at=m.created_at,
edited_at=m.edited_at,
)
for m in messages
],
total=len(messages),
has_more=has_more,
)
@router.post(
"/chat/admin/conversations/{conversation_id}/reply",
status_code=status.HTTP_201_CREATED,
)
@guard_deco.rate_limit(requests=60, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(prompt_injection_validator)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
@guard_deco.suspicious_detection(enabled=True)
async def reply_as_ceo(
conversation_id: str,
db: DbSession,
agent: CurrentAgentContext,
data: AdminReplyRequest,
) -> MessageResponse:
"""CEO-only: chime into an existing A2A conversation as itself.
The CEO addresses one of the conversation's two real participants (A2A
conversations are strictly pairwise) on the conversation's linked task.
"""
_require_ceo(agent)
service = A2AService(db)
conv = await service.get_conversation_admin(require_uuid(conversation_id))
if conv is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conversation not found: {conversation_id}",
)
_resolve_reply_target(conv, data.to_agent)
try:
msg = await service.send(
from_agent=agent.agent_id,
to_agent=data.to_agent,
task_id=require_uuid(conv.task_id),
body=data.content,
skill=data.skill,
)
except A2AAccessDeniedError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "A2A_ACCESS_DENIED",
"message": e.message,
"route_hint": e.route_hint,
},
) from None
await db.commit()
return MessageResponse(
id=require_uuid(msg.id),
conversation_id=require_uuid(msg.conversation_id),
from_agent=msg.from_agent,
content=msg.content,
message_kind=msg.message_kind,
response_to_id=require_uuid(msg.response_to_id) if msg.response_to_id else None,
requires_response=msg.requires_response,
read_at=msg.read_at,
created_at=msg.created_at,
edited_at=msg.edited_at,
)
+34 -2
View File
@@ -17,10 +17,10 @@ Phase 5.
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Annotated, Any
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sse_starlette import EventSourceResponse
from roboco.api.deps import (
@@ -366,3 +366,35 @@ async def re_interview(
async def relay_event(session_id: str, event: AgentEvent) -> dict[str, bool]:
"""Relay one agent event from the container onto the session's stream."""
return {"pushed": get_live_registry().push(session_id, event.model_dump())}
_SEARCH_TASKS_DEFAULT_LIMIT = 8
_SEARCH_TASKS_MAX_LIMIT = 10
@router.get("/live/{session_id}/search-tasks")
async def search_past_tasks(
session_id: str,
db: DbSession,
q: Annotated[str, Query(min_length=2, max_length=200)],
limit: Annotated[int, Query(ge=1, le=_SEARCH_TASKS_MAX_LIMIT)] = (
_SEARCH_TASKS_DEFAULT_LIMIT
),
) -> list[dict[str, Any]]:
"""Bounded compact task search for the intake agent's ``search_past_tasks``
tool "have we done something like this before?" mid-conversation.
Session-scoped as a trust boundary (the intake container has no agent
identity, matching ``/events``): a dead/unknown session is rejected so
only a live intake container can query.
"""
if not get_live_registry().is_alive(session_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No live intake session {session_id}",
)
from roboco.services.prompter import compact_task_rows
from roboco.services.task import get_task_service
rows = await get_task_service(db).search_tasks(q, limit=limit)
return compact_task_rows(rows)
+36
View File
@@ -158,3 +158,39 @@ class PairListResponse(BaseModel):
items: list[PairResponse]
total: int
# =============================================================================
# ADMIN / LIVE VIEW SCHEMAS (CEO-only)
# =============================================================================
class AdminConversationSummaryResponse(BaseModel):
"""Conversation summary for the CEO's cross-agent live view."""
id: UUID
agent_a: str
agent_b: str
topic: str | None
task_id: UUID | None
status: A2AConversationStatus
message_count: int
last_message_at: datetime | None
last_message_preview: str | None
created_at: datetime
updated_at: datetime
class AdminConversationListResponse(BaseModel):
"""List of admin conversation summaries."""
items: list[AdminConversationSummaryResponse]
total: int
class AdminReplyRequest(BaseModel):
"""Request for the CEO to chime into an existing A2A conversation."""
to_agent: str = Field(..., description="Which participant to address")
content: str = Field(..., min_length=1, max_length=10000)
skill: str | None = None
+29
View File
@@ -206,6 +206,32 @@ async def _handle_usage_event(event: Event) -> None:
)
async def _handle_a2a_message_event(event: Event) -> None:
"""Forward an A2A_MESSAGE_SENT event to operator /ws/system clients as an
`a2a.message` frame the CEO's live view of every agent-to-agent chat.
Carries only the excerpt the service already capped; the full body stays
readable via the admin REST endpoints.
"""
data = event.data
await manager.broadcast_system(
{
"type": "a2a.message",
"conversation_id": data.get("conversation_id"),
"message_id": data.get("message_id"),
"task_id": data.get("task_id"),
"from_agent": data.get("from_agent"),
"to_agent": data.get("to_agent"),
"skill": data.get("skill"),
"body_excerpt": data.get("body_excerpt", ""),
"timestamp": data.get("timestamp"),
}
)
logger.debug(
"A2A message forwarded to system WebSocket",
message_id=data.get("message_id"),
)
def register_websocket_bridge_handlers() -> None:
"""
Register event handlers that forward events to WebSocket clients.
@@ -240,6 +266,9 @@ def register_websocket_bridge_handlers() -> None:
# Message delivery -> channel + session WebSocket streams (live chat)
bus.subscribe(EventType.MESSAGE_SENT, _handle_message_event)
# A2A live chat -> operator system WebSocket (CEO live view)
bus.subscribe(EventType.A2A_MESSAGE_SENT, _handle_a2a_message_event)
logger.info("WebSocket bridge handlers registered")
+74
View File
@@ -29,6 +29,11 @@ from mcp.server.fastmcp import FastMCP
_TIMEOUT = 15.0
_SEARCH_QUERY_MIN_LEN = 2
_SEARCH_QUERY_MAX_LEN = 200
_SEARCH_DEFAULT_LIMIT = 8
_SEARCH_MAX_LIMIT = 10
mcp = FastMCP("roboco-intake")
@@ -100,6 +105,75 @@ async def post_batch(
)
async def query_past_tasks(
session_id: str,
query: str,
*,
limit: int = _SEARCH_DEFAULT_LIMIT,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
"""GET the bounded compact task-search results; never raises.
Module-level so it is unit-testable AND shared with the Claude SDK
driver's in-process tool (``intake_driver.py`` imports this directly) —
the HTTP + bounding logic for ``search_past_tasks`` lives in exactly one
place, used by both runtimes.
"""
q = (query or "").strip()[:_SEARCH_QUERY_MAX_LEN]
if len(q) < _SEARCH_QUERY_MIN_LEN:
return {"error": "query_too_short", "results": []}
bounded_limit = min(max(int(limit), 1), _SEARCH_MAX_LIMIT)
owns = client is None
http = client or httpx.AsyncClient(timeout=_TIMEOUT)
url = f"{_api_base()}/api/prompter/live/{session_id}/search-tasks"
try:
resp = await http.get(url, params={"q": q, "limit": bounded_limit})
except httpx.HTTPError as exc:
return {"error": "request_failed", "detail": str(exc), "results": []}
finally:
if owns:
await http.aclose()
if not resp.is_success:
try:
body = resp.json()
except (ValueError, json.JSONDecodeError):
body = None
return {"error": f"http_{resp.status_code}", "detail": body, "results": []}
return {"results": resp.json()}
def format_search_results(result: dict[str, Any]) -> str:
"""Render ``query_past_tasks``'s result dict as a compact, human/LLM-
readable list. Pure shared by both runtimes' tool wrappers."""
if "error" in result:
detail = result.get("detail") or result["error"]
return f"Could not search past tasks: {detail}"
results = result.get("results") or []
if not results:
return "No past tasks matched that search."
lines = [
f"- `{r['id'][:8]}` {r['title']} ({r['status']}, {r['team']}, {r['date']})"
for r in results
]
return "\n".join(lines)
@mcp.tool()
async def search_past_tasks(query: str, limit: int = _SEARCH_DEFAULT_LIMIT) -> str:
"""Search past tasks by title/description/id-prefix.
Use this mid-conversation to check "have we done something like this
before?" or find a predecessor to cite in a new draft's description
("follows up <short-id>"). Returns up to ``limit`` (max 10) compact
results: short id, title, status, team, date.
"""
session_id = os.environ.get("ROBOCO_PROMPTER_SESSION_ID", "")
if not session_id:
return "No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot search."
result = await query_past_tasks(session_id, query, limit=limit)
return format_search_results(result)
@mcp.tool()
async def propose_draft(draft: dict[str, Any]) -> str:
"""Submit the finished task draft for the human to review and confirm.
+18
View File
@@ -587,6 +587,24 @@ class A2AConversationSummary(RobocoBase):
)
class A2AConversationAdminSummary(RobocoBase):
"""Conversation summary for the CEO's cross-agent live view (no single-
participant perspective unlike A2AConversationSummary, this carries
both slugs rather than one "other_agent")."""
id: str
agent_a: str
agent_b: str
topic: str | None = None
task_id: str | None = None
status: A2AConversationStatus
message_count: int
last_message_at: datetime | None
last_message_preview: str | None = None
created_at: datetime
updated_at: datetime
class A2APair(RobocoBase):
"""Unique agent pair for frontend display."""
+3
View File
@@ -50,6 +50,9 @@ class EventType(StrEnum):
# /ws/channels/{id} and /ws/sessions/{id} subscribers via the bridge.
MESSAGE_SENT = "message.sent"
# A2A chat message persisted — operator live view
A2A_MESSAGE_SENT = "a2a.message_sent"
# Handoff events
HANDOFF_CREATED = "handoff.created"
HANDOFF_ACCEPTED = "handoff.accepted"
+90 -2
View File
@@ -3060,6 +3060,94 @@ class AgentOrchestrator:
resolved = [await project_service.get(pid) for pid in ids]
return [p for p in resolved if p is not None]
async def _resolve_history_digest_ambient(
self,
project_slug: str | None,
product_id: str | None = None,
project_ids: list[str] | None = None,
) -> str | None:
"""Resolve the prompter's task-history-digest ambient block for this scope.
Unlike the conventions ambient resolver, this covers all three intake
scopes including ``project_ids`` (a MegaTask) the digest is meant to
span every project the intake agent is reading. Best-effort: returns None
on any failure or empty scope so history resolution can never block a
spawn.
"""
try:
from roboco.db.base import get_session_factory
from roboco.services.prompter import history_digest_layer
factory = get_session_factory()
async with factory() as db:
projects = await self._resolve_history_digest_projects(
db,
project_slug=project_slug,
product_id=product_id,
project_ids=project_ids,
)
return await history_digest_layer(db, projects)
except Exception as exc:
logger.warning(
"History digest ambient resolution failed (non-fatal)",
project_slug=project_slug,
error=str(exc),
)
return None
@staticmethod
async def _resolve_history_digest_projects(
db: Any,
*,
project_slug: str | None,
product_id: str | None,
project_ids: list[str] | None,
) -> list[Any]:
"""The in-scope ProjectTable rows for the history digest — single repo,
product (all cell projects), or an explicit MegaTask project_ids set."""
if project_ids:
from uuid import UUID
from roboco.services.project import get_project_service
project_svc = get_project_service(db)
out = []
for pid in project_ids:
p = await project_svc.get(UUID(pid))
if p is not None:
out.append(p)
return out
if product_id is not None:
return await AgentOrchestrator._ambient_product_projects(db, product_id)
if project_slug:
from roboco.services.project import get_project_service
project = await get_project_service(db).get_by_slug(project_slug)
return [project] if project is not None else []
return []
async def _resolve_intake_ambient(
self,
project_slug: str | None,
*,
product_id: str | None,
project_ids: list[str] | None,
) -> str | None:
"""The intake spawn's full ambient block: conventions + history digest,
joined with ``compose_prompt``'s own layer separator."""
conventions_ambient = await self._resolve_conventions_ambient(
project_slug, product_id=product_id
)
history_ambient = await self._resolve_history_digest_ambient(
project_slug, product_id=product_id, project_ids=project_ids
)
return (
"\n\n---\n\n".join(
part for part in (conventions_ambient, history_ambient) if part
)
or None
)
async def _readiness_gate(self, agent_id: str, task_id: str | None) -> str | None:
"""Return a reason string if the spawn must be refused, else None.
@@ -3740,8 +3828,8 @@ class AgentOrchestrator:
project_slug, product_id, project_ids
)
ambient = await self._resolve_conventions_ambient(
project_slug, product_id=product_id
ambient = await self._resolve_intake_ambient(
project_slug, product_id=product_id, project_ids=project_ids
)
prompt_path = self._generate_composed_prompt(
INTAKE_AGENT_ID, ambient=ambient
+257 -9
View File
@@ -7,8 +7,8 @@ Provides business logic for A2A protocol operations including:
- Message handling and routing
"""
from datetime import datetime
from typing import Any, cast
from datetime import UTC, datetime
from typing import Any, Final, cast
from uuid import UUID
import structlog
@@ -23,12 +23,13 @@ from roboco.db.tables import (
AgentTable,
TaskTable,
)
from roboco.enforcement import validate_a2a_access
from roboco.enforcement import A2AAccessDeniedError, validate_a2a_access
from roboco.events import Event, EventType, get_event_bus
from roboco.models.a2a import (
A2AArtifact,
A2AChatMessage,
A2AConversation,
A2AConversationAdminSummary,
A2AConversationStatus,
A2AConversationSummary,
A2AInboxSummary,
@@ -51,6 +52,17 @@ from roboco.seeds.initial_data import AGENT_UUIDS
logger = structlog.get_logger()
# The A2A_MESSAGE_SENT WS frame (operator live view) carries a briefing-sized
# excerpt only — the full body remains readable via the existing REST message
# endpoints, so the live stream doesn't balloon on long A2A bodies.
_LIVE_VIEW_EXCERPT_CHARS: Final[int] = 240
def _excerpt(text: str, limit: int = _LIVE_VIEW_EXCERPT_CHARS) -> str:
"""Truncate ``text`` to ``limit`` chars, appending an ellipsis marker
only when truncation actually happened."""
return text if len(text) <= limit else text[:limit].rstrip() + ""
class A2AService:
"""
@@ -931,6 +943,26 @@ class A2AService:
return self._conv_to_model(conv)
async def get_conversation_admin(
self,
conversation_id: UUID,
) -> A2AConversation | None:
"""Get a conversation by ID with NO participant check.
The CEO's live view needs to look up (and reply into) any
conversation, including ones it is not itself a party to unlike
``get_conversation``, which gates on membership.
"""
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = result.scalar_one_or_none()
if conv is None:
return None
return self._conv_to_model(conv)
async def list_conversations(
self,
agent_slug: str,
@@ -1012,6 +1044,54 @@ class A2AService:
return summaries
async def _last_message(self, conversation_id: UUID) -> A2AMessageTable | None:
"""Most recent message row in a conversation, or None."""
result = await self.session.execute(
select(A2AMessageTable)
.where(A2AMessageTable.conversation_id == conversation_id)
.order_by(A2AMessageTable.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_conversations_admin(
self, limit: int = 50
) -> list[A2AConversationAdminSummary]:
"""ALL conversations across every agent pair, most-recent-first, bounded.
No participant filter this is the CEO's org-wide live view, not a
per-agent inbox. Reuses the same last-message-preview lookup as
``list_conversations``.
"""
query = (
select(A2AConversationTable)
.order_by(A2AConversationTable.updated_at.desc())
.limit(limit)
)
result = await self.session.execute(query)
conversations = result.scalars().all()
summaries = []
for conv in conversations:
last_msg = await self._last_message(cast("UUID", conv.id))
summaries.append(
A2AConversationAdminSummary(
id=str(conv.id),
agent_a=conv.agent_a,
agent_b=conv.agent_b,
topic=conv.topic,
task_id=str(conv.task_id) if conv.task_id else None,
status=conv.status,
message_count=conv.message_count,
last_message_at=conv.last_message_at,
last_message_preview=(last_msg.content[:100] if last_msg else None),
created_at=conv.created_at,
updated_at=conv.updated_at,
)
)
return summaries
async def close_conversation(
self,
conversation_id: UUID,
@@ -1042,6 +1122,54 @@ class A2AService:
by_agent=agent_slug,
)
async def _enforce_ceo_reply_budget(
self,
conv: A2AConversationTable,
conversation_id: UUID,
from_agent: str,
) -> None:
"""Reply-then-wait budget on the CEO's inbox — the one stateful gate
the stateless ``can_a2a_direct`` matrix can't see (it only blocks
conversation *creation*, unconditionally, as defense-in-depth).
An agent may message the CEO only inside a conversation the CEO
itself opened, and only up to the CEO's own message count there:
reject when the agent's message count >= the CEO's message count.
No-op for CEO-authored sends or conversations the CEO isn't part of.
"""
other = conv.agent_b if from_agent == conv.agent_a else conv.agent_a
if other != "ceo" or from_agent == "ceo":
return
from sqlalchemy import func
agent_count = await self.session.scalar(
select(func.count())
.select_from(A2AMessageTable)
.where(
A2AMessageTable.conversation_id == conversation_id,
A2AMessageTable.from_agent == from_agent,
)
)
ceo_count = await self.session.scalar(
select(func.count())
.select_from(A2AMessageTable)
.where(
A2AMessageTable.conversation_id == conversation_id,
A2AMessageTable.from_agent == "ceo",
)
)
if (agent_count or 0) >= (ceo_count or 0):
raise A2AAccessDeniedError(
from_agent=from_agent,
to_agent="ceo",
reason=(
"you have already replied to the CEO's last message — "
"wait for the CEO to respond before sending again"
),
route_hint="Wait for the CEO to post again in this conversation.",
)
async def send_chat_message(
self,
conversation_id: UUID,
@@ -1118,6 +1246,8 @@ class A2AService:
)
return self._msg_to_model(dup)
await self._enforce_ceo_reply_budget(conv, conversation_id, from_agent)
# Create message
msg = A2AMessageTable(
conversation_id=conversation_id,
@@ -1190,6 +1320,41 @@ class A2AService:
# Return in chronological order
return [self._msg_to_model(m) for m in reversed(list(messages))]
async def get_messages_admin(
self,
conversation_id: UUID,
limit: int = 100,
before: datetime | None = None,
) -> list[A2AChatMessage]:
"""Like ``get_messages`` but WITHOUT the participant check — the CEO
can read any conversation's transcript for the live view. Returns
``[]`` only when the conversation truly doesn't exist.
"""
conv_result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.id == conversation_id
)
)
conv = conv_result.scalar_one_or_none()
if conv is None:
return []
query = (
select(A2AMessageTable)
.where(A2AMessageTable.conversation_id == conversation_id)
.order_by(A2AMessageTable.created_at.desc())
.limit(limit)
)
if before:
query = query.where(A2AMessageTable.created_at < before)
result = await self.session.execute(query)
messages = result.scalars().all()
return [self._msg_to_model(m) for m in reversed(list(messages))]
async def mark_read(
self,
conversation_id: UUID,
@@ -1432,6 +1597,38 @@ class A2AService:
raise ValueError(f"Agent not found for id {agent_id}")
return str(slug)
async def _get_conversation_for_reply_to_ceo(
self, from_slug: str, to_slug: str
) -> A2AConversation:
"""Resolve the conversation for an agent replying to the CEO.
Agents can never CREATE a CEO conversation (the matrix blocks
initiation unconditionally), so an existing pair conversation's mere
presence proves the CEO opened it. Looked up directly here
bypassing ``get_or_create_conversation``'s validate-first gate,
which would otherwise deny even a legitimate reply.
"""
a, b = self._canonical_pair(from_slug, to_slug)
result = await self.session.execute(
select(A2AConversationTable).where(
A2AConversationTable.agent_a == a,
A2AConversationTable.agent_b == b,
A2AConversationTable.topic.is_(None),
)
)
conv = result.scalar_one_or_none()
if conv is None:
raise A2AAccessDeniedError(
from_agent=from_slug,
to_agent=to_slug,
reason=(
"CEO is human. You may only reply inside a conversation "
"the CEO opened — use notify() otherwise."
),
route_hint="Wait for the CEO to open an A2A conversation with you.",
)
return self._conv_to_model(conv)
async def send(
self,
*,
@@ -1454,6 +1651,13 @@ class A2AService:
`skill` is persisted on the message row so the receiver (and the
inbox) learns which capability is being requested.
The recipient "ceo" is special-cased: an agent can never CREATE a
CEO conversation (the matrix blocks it unconditionally), so calling
``get_or_create_conversation`` would deny even a legitimate reply.
Instead the existing pair conversation is looked up directly its
mere existence proves the CEO opened it and the reply proceeds to
``send_chat_message``, where the reply budget applies.
"""
from_slug = await self._resolve_slug_from_id(from_agent)
to_slug = (
@@ -1462,17 +1666,61 @@ class A2AService:
else to_agent
)
conv = await self.get_or_create_conversation(
agent_a=from_slug,
agent_b=to_slug,
task_id=task_id,
)
if to_slug == "ceo" and from_slug != "ceo":
conv = await self._get_conversation_for_reply_to_ceo(from_slug, to_slug)
else:
conv = await self.get_or_create_conversation(
agent_a=from_slug,
agent_b=to_slug,
task_id=task_id,
)
options: dict[str, Any] = {}
if skill is not None:
options["skill"] = skill
return await self.send_chat_message(
msg = await self.send_chat_message(
conversation_id=UUID(conv.id),
from_agent=from_slug,
content=body,
options=options or None,
)
await self._publish_a2a_message_sent(msg, task_id, from_slug, to_slug, skill)
return msg
@staticmethod
async def _publish_a2a_message_sent(
msg: A2AChatMessage,
task_id: UUID,
from_slug: str,
to_slug: str,
skill: str | None,
) -> None:
"""Best-effort publish of A2A_MESSAGE_SENT for the operator live view.
Mirrors MessagingService.send_message's publish pattern: a bus outage
is logged and never rolls back the already-persisted message.
"""
try:
bus = get_event_bus()
if bus.is_connected():
timestamp = (
msg.created_at.isoformat()
if msg.created_at
else datetime.now(UTC).isoformat()
)
await bus.publish(
Event(
type=EventType.A2A_MESSAGE_SENT,
data={
"conversation_id": msg.conversation_id,
"message_id": msg.id,
"task_id": str(task_id),
"from_agent": from_slug,
"to_agent": to_slug,
"skill": skill,
"body_excerpt": _excerpt(msg.content),
"timestamp": timestamp,
},
)
)
except Exception as e:
logger.warning("Failed to publish A2A message event", error=str(e))
+122
View File
@@ -41,6 +41,8 @@ from roboco.models.task import TaskCreateRequest
from roboco.services.base import NotFoundError, ServiceError, ValidationError
if TYPE_CHECKING:
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
@@ -1156,6 +1158,126 @@ def _compose_umbrella_draft(
}
# ---------------------------------------------------------------------------
# Prompter memory v1 — informational task-history digest + compact search rows.
# The sequencing analyzer still owns ordering; this is context only.
# ---------------------------------------------------------------------------
_HISTORY_DIGEST_PER_PROJECT_LIMIT = 15
_HISTORY_TITLE_EXCERPT_CAP = 70
_HISTORY_DIGEST_TOTAL_CAP = 4000
def _task_activity_date(task: TaskTable) -> datetime:
"""The most relevant date for a task's history line: when it finished,
else when it last moved, else when it was created."""
return task.completed_at or task.updated_at or task.created_at
def _enum_value(value: Any) -> str:
"""String value of an enum-or-plain-string field (defensive for both a
real DB row and a pure-Python test double)."""
return str(getattr(value, "value", value))
def _title_excerpt(title: str, cap: int = _HISTORY_TITLE_EXCERPT_CAP) -> str:
"""Trim a title to ``cap`` chars with an ellipsis — a digest line is a
pointer, not the full record."""
text = title.strip()
if len(text) <= cap:
return text
return text[: cap - 1].rstrip() + ""
def _history_line(task: TaskTable) -> str:
"""One compact history line: short-id, title, status, date."""
short_id = str(task.id)[:8]
status = _enum_value(task.status)
date = _task_activity_date(task).date().isoformat()
return f"- `{short_id}` {_title_excerpt(task.title)} ({status}, {date})"
def build_history_digest(
tasks: list[TaskTable], *, limit: int = _HISTORY_DIGEST_PER_PROJECT_LIMIT
) -> str:
"""Render a chronological digest of recent tasks, capped at ``limit`` lines.
``tasks`` must already be ordered most-recent-activity-first (the DB
query's job — see ``TaskService.list_recent_for_project``). This takes the
top ``limit`` and reverses them to oldest-first for display: a history
reads as a timeline, not a reverse-chronological feed. Empty input -> "".
"""
recent = tasks[:limit]
chronological = list(reversed(recent))
return "\n".join(_history_line(t) for t in chronological)
async def project_history_digest(
session: AsyncSession,
project: Any,
*,
limit: int = _HISTORY_DIGEST_PER_PROJECT_LIMIT,
) -> str | None:
"""This project's rendered history digest, or None if it has no tasks."""
from roboco.services.task import get_task_service
tasks = await get_task_service(session).list_recent_for_project(
project.id, limit=limit
)
if not tasks:
return None
return build_history_digest(tasks, limit=limit)
async def history_digest_layer(
session: AsyncSession, projects: list[Any]
) -> str | None:
"""Render the task-history-digest ambient block for the in-scope project(s).
Mirrors ``conventions_ambient_layer``'s shape: one block per project
(headed by its slug when there is more than one the MegaTask case),
joined under a single heading, bounded to a total cap. Returns None when
there are no in-scope projects or none of them have any tasks, so a
brand-new project / board-level spawn injects nothing (no empty-header
noise).
"""
if not projects:
return None
blocks: list[str] = []
for project in projects:
digest = await project_history_digest(session, project)
if not digest:
continue
header = (
f"### Recent tasks — `{project.slug}`"
if len(projects) > 1
else "### Recent tasks"
)
blocks.append(f"{header}\n{digest}")
if not blocks:
return None
text = "## Task History\n\n" + "\n\n".join(blocks)
if len(text) > _HISTORY_DIGEST_TOTAL_CAP:
text = text[: _HISTORY_DIGEST_TOTAL_CAP - 1].rstrip() + ""
return text
def compact_task_rows(tasks: list[TaskTable]) -> list[dict[str, Any]]:
"""Bounded compact rows for the intake's ``search_past_tasks`` tool: id,
title, status, team, date. Mirrors the Secretary's ``/tasks?q=`` compact
shape (id/title/status/team) plus the activity date the digest computes."""
return [
{
"id": str(t.id),
"title": t.title,
"status": _enum_value(t.status),
"team": _enum_value(t.team) if t.team else None,
"date": _task_activity_date(t).date().isoformat(),
}
for t in tasks
]
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
+23
View File
@@ -6311,6 +6311,29 @@ class TaskService(BaseService):
# QUERIES
# =========================================================================
async def list_recent_for_project(
self, project_id: UUID, limit: int = 15
) -> list[TaskTable]:
"""Recent tasks for a project, most-recently-active first.
Backs the prompter's history digest: the intake agent gets a compact
chronological view of what's already been built/attempted in this repo.
"Recent" = highest of completed_at / updated_at / created_at, so a
just-touched-but-not-completed task still surfaces ahead of an old
completed one.
"""
activity = func.coalesce(
TaskTable.completed_at, TaskTable.updated_at, TaskTable.created_at
)
stmt = (
select(TaskTable)
.where(TaskTable.project_id == project_id)
.order_by(activity.desc())
.limit(limit)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def search_tasks(
self,
q: str,
+304
View File
@@ -893,6 +893,35 @@ async def test_send_chat_message_success(a2a_route_client: dict) -> None:
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.asyncio
async def test_send_chat_message_over_budget_returns_403(
a2a_route_client: dict,
) -> None:
"""An over-budget reply to the CEO raises A2AAccessDeniedError from the
service the route must surface 403, not crash into a 500 or fall
through to the ValueError->404 branch."""
client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.send_chat_message = AsyncMock(
side_effect=A2AAccessDeniedError(
from_agent="be-dev-1",
to_agent="ceo",
reason=(
"you have already replied to the CEO's last message — "
"wait for the CEO to respond before sending again"
),
)
)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/conversations/{uuid4()}/messages",
json={"content": "another update"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_mark_read(a2a_route_client: dict) -> None:
@@ -937,6 +966,281 @@ async def test_chat_list_with_status_filter(a2a_route_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Admin / live-view endpoints (CEO-only) — GET all conversations, GET any
# conversation's messages, POST a reply as the CEO.
# ---------------------------------------------------------------------------
def _set_ceo_context(app: FastAPI, dev: AgentTable) -> None:
"""Override the agent context to the CEO so the admin live-view routes
admit the call (the default fixture context is a developer)."""
async def _ceo() -> AgentContext:
return AgentContext(
agent_id=cast("UUID", dev.id),
role=AgentRole.CEO,
team=None,
slug="ceo",
)
app.dependency_overrides[get_agent_context] = _ceo
def _admin_conv_obj(
*,
conv_id: UUID,
agent_a: str = "be-dev-1",
agent_b: str = "fe-dev-1",
task_id: UUID | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
id=str(conv_id),
agent_a=agent_a,
agent_b=agent_b,
topic=None,
task_id=str(task_id) if task_id else None,
status="active",
resolution=None,
message_count=2,
unread_by_a=0,
unread_by_b=0,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
last_message_at=datetime.now(UTC),
last_message_preview="hi there",
)
@pytest.mark.asyncio
async def test_admin_list_conversations_forbidden_for_non_ceo(
a2a_route_client: dict,
) -> None:
client = a2a_route_client["client"]
response = await client.get("/api/a2a/chat/admin/conversations", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_get_messages_forbidden_for_non_ceo(
a2a_route_client: dict,
) -> None:
client = a2a_route_client["client"]
response = await client.get(
f"/api/a2a/chat/admin/conversations/{uuid4()}/messages", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_reply_forbidden_for_non_ceo(a2a_route_client: dict) -> None:
client = a2a_route_client["client"]
response = await client.post(
f"/api/a2a/chat/admin/conversations/{uuid4()}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_list_conversations_as_ceo(a2a_route_client: dict) -> None:
"""CEO sees conversations it is not itself a participant in."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv = _admin_conv_obj(conv_id=uuid4())
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.list_conversations_admin = AsyncMock(return_value=[conv])
mock_service_cls.return_value = instance
response = await client.get(
"/api/a2a/chat/admin/conversations?limit=10", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
instance.list_conversations_admin.assert_awaited_once_with(10)
body = response.json()
assert body["total"] == 1
assert body["items"][0]["agent_a"] == "be-dev-1"
assert body["items"][0]["agent_b"] == "fe-dev-1"
@pytest.mark.asyncio
async def test_admin_get_messages_as_ceo_returns_full_transcript(
a2a_route_client: dict,
) -> None:
"""The route uses get_messages_admin — the participant-bypassing
accessor not the ordinary get_messages()."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
msg = SimpleNamespace(
id=uuid4(),
conversation_id=conv_id,
from_agent="be-dev-1",
content="hello",
message_kind="message",
response_to_id=None,
requires_response=False,
read_at=None,
created_at=datetime.now(UTC),
edited_at=None,
)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_messages_admin = AsyncMock(return_value=[msg, msg])
mock_service_cls.return_value = instance
response = await client.get(
f"/api/a2a/chat/admin/conversations/{conv_id}/messages", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
instance.get_messages_admin.assert_awaited_once()
body = response.json()
_EXPECTED_MESSAGES = 2
assert body["total"] == _EXPECTED_MESSAGES
assert not body["has_more"]
@pytest.mark.asyncio
async def test_admin_reply_unknown_conversation_404(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=None)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{uuid4()}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_admin_reply_non_participant_target_400(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "ghost-agent", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_admin_reply_no_task_id_400(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=None)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_admin_reply_success(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
sent_msg = SimpleNamespace(
id=uuid4(),
conversation_id=conv_id,
from_agent="ceo",
content="chiming in",
message_kind="message",
response_to_id=None,
requires_response=False,
read_at=None,
created_at=datetime.now(UTC),
edited_at=None,
)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
instance.send = AsyncMock(return_value=sent_msg)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "chiming in"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
instance.send.assert_awaited_once()
call_kwargs = instance.send.await_args.kwargs
assert call_kwargs["to_agent"] == "be-dev-1"
assert call_kwargs["task_id"] == task_id
assert call_kwargs["body"] == "chiming in"
body = response.json()
assert body["content"] == "chiming in"
@pytest.mark.asyncio
async def test_admin_reply_access_denied_maps_to_403(a2a_route_client: dict) -> None:
"""Defensive: if send() ever rejects a CEO-authored A2A, surface 403
rather than crash mirrors create_conversation's handling."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
instance.send = AsyncMock(
side_effect=A2AAccessDeniedError(
from_agent="ceo",
to_agent="be-dev-1",
reason="denied",
)
)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# send_message: TASK_ID_REQUIRED branch (line 131)
# ---------------------------------------------------------------------------
+350 -2
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, patch
@@ -32,7 +32,8 @@ from roboco.models.base import (
TaskStatus,
TaskType,
)
from roboco.services.a2a import A2AService
from roboco.models.events import EventType
from roboco.services.a2a import _LIVE_VIEW_EXCERPT_CHARS, A2AService
from sqlalchemy import select
from sqlalchemy import select as _sel
@@ -441,6 +442,353 @@ async def test_send_records_skill_on_message_for_receiver(a2a_setup: dict) -> No
assert inbox[-1].skill == "code_review"
@pytest.mark.asyncio
async def test_send_publishes_a2a_message_sent_event_when_bus_connected(
a2a_setup: dict,
) -> None:
"""A2AService.send() is the gateway's one publish point for A2A chat — it
must fan an A2A_MESSAGE_SENT event so the CEO's live view (operator
/ws/system stream) sees every directed agent-to-agent message."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
sent = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=task_id,
body="please review",
skill="code_review",
)
mock_bus.publish.assert_awaited()
published = mock_bus.publish.await_args.args[0]
assert published.type is EventType.A2A_MESSAGE_SENT
data = published.data
assert data["conversation_id"] == sent.conversation_id
assert data["message_id"] == sent.id
assert data["task_id"] == str(task_id)
assert data["from_agent"] == "be-dev-1"
assert data["to_agent"] == "be-qa"
assert data["skill"] == "code_review"
assert data["body_excerpt"] == "please review"
assert data["timestamp"] == sent.created_at.isoformat()
@pytest.mark.asyncio
async def test_send_excerpts_long_body_in_event(a2a_setup: dict) -> None:
"""The WS live-view frame carries a capped excerpt, not the full body —
but the persisted message keeps the full untruncated text (readable via
the existing REST message endpoints)."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
long_body = "x" * (_LIVE_VIEW_EXCERPT_CHARS + 100)
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
sent = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=task_id,
body=long_body,
)
published = mock_bus.publish.await_args.args[0]
assert published.type is EventType.A2A_MESSAGE_SENT
excerpt = published.data["body_excerpt"]
assert len(excerpt) < len(long_body)
assert excerpt.endswith("")
# Full body survives untruncated in persistent storage.
assert sent.content == long_body
stored = await svc.get_messages(UUID(sent.conversation_id), "be-qa")
assert stored[-1].content == long_body
@pytest.mark.asyncio
async def test_send_bus_failure_does_not_break_send(a2a_setup: dict) -> None:
"""A bus outage during the A2A_MESSAGE_SENT publish is logged but never
rolls back the persisted message live delivery is best-effort."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
with patch(
"roboco.services.a2a.get_event_bus",
side_effect=RuntimeError("bus down"),
):
sent = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=task_id,
body="hello",
)
assert sent.id is not None
assert sent.content == "hello"
# ---------------------------------------------------------------------------
# Admin (CEO live view) service methods — no participant filter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_conversations_admin_includes_non_participant_pairs(
a2a_setup: dict,
) -> None:
"""The CEO's live view has no participant filter — it must show
conversations between two agents where the CEO is not itself a party."""
svc = a2a_setup["svc"]
conv1 = await svc.get_or_create_conversation("be-dev-1", "be-qa")
conv2 = await svc.get_or_create_conversation("fe-dev-1", "fe-qa")
summaries = await svc.list_conversations_admin(limit=50)
ids = {s.id for s in summaries}
assert conv1.id in ids
assert conv2.id in ids
pairs = {(s.agent_a, s.agent_b) for s in summaries}
assert ("be-dev-1", "be-qa") in pairs
assert ("fe-dev-1", "fe-qa") in pairs
@pytest.mark.asyncio
async def test_list_conversations_admin_orders_most_recent_first_and_bounds(
a2a_setup: dict,
) -> None:
"""Most-recent-first ordering and a hard limit — proven by forcing
distinguishable updated_at values across three seeded conversations."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
conv_a = await svc.get_or_create_conversation("be-dev-1", "be-qa")
conv_b = await svc.get_or_create_conversation("fe-dev-1", "fe-qa")
conv_c = await svc.get_or_create_conversation("ux-dev-1", "ux-qa")
now = datetime.now(UTC)
for conv_id, offset in (
(conv_a.id, timedelta(minutes=-10)),
(conv_b.id, timedelta(minutes=-5)),
(conv_c.id, timedelta(minutes=0)),
):
row = await db.get(A2AConversationTable, UUID(conv_id))
assert row is not None
row.updated_at = now + offset
await db.flush()
summaries = await svc.list_conversations_admin(limit=2)
_LIMIT = 2
assert len(summaries) == _LIMIT
assert [s.id for s in summaries] == [conv_c.id, conv_b.id]
@pytest.mark.asyncio
async def test_get_messages_admin_returns_full_transcript_for_non_participant(
a2a_setup: dict,
) -> None:
"""The plain get_messages() denies a non-participant (returns []); the
admin bypass returns the full transcript regardless the exact behavior
a normal agent-scoped call cannot give the CEO today."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "be-dev-1", "hello")
await svc.send_chat_message(cid, "be-qa", "hi back")
as_ceo_scoped = await svc.get_messages(cid, "ceo")
assert as_ceo_scoped == []
admin_view = await svc.get_messages_admin(cid)
_EXPECTED = 2
assert len(admin_view) == _EXPECTED
assert admin_view[0].content == "hello"
assert admin_view[1].content == "hi back"
@pytest.mark.asyncio
async def test_get_messages_admin_unknown_conversation_returns_empty(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
assert await svc.get_messages_admin(uuid4()) == []
@pytest.mark.asyncio
async def test_get_conversation_admin_returns_conversation_ceo_not_part_of(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
fetched = await svc.get_conversation_admin(UUID(conv.id))
assert fetched is not None
assert fetched.id == conv.id
assert fetched.agent_a == "be-dev-1"
assert fetched.agent_b == "be-qa"
@pytest.mark.asyncio
async def test_get_conversation_admin_returns_none_for_unknown(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
assert await svc.get_conversation_admin(uuid4()) is None
# ---------------------------------------------------------------------------
# CEO reply-only budget — an agent may only reply to the CEO inside a
# conversation the CEO itself opened, and only up to the CEO's own message
# count in that conversation.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_to_ceo_without_existing_conversation_denied(
a2a_setup: dict,
) -> None:
"""An agent can never INITIATE a CEO conversation via the gateway
send() adapter only reply inside one the CEO already opened."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
with pytest.raises(A2AAccessDeniedError):
await svc.send(from_agent=dev.id, to_agent="ceo", task_id=task_id, body="hi")
@pytest.mark.asyncio
async def test_ceo_can_post_consecutive_messages_no_budget(a2a_setup: dict) -> None:
"""CEO -> agent direction is unrestricted — no budget applies to CEO
sends, so the CEO may post twice in a row with no reply in between."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "first")
second = await svc.send_chat_message(cid, "ceo", "second, no reply needed yet")
assert second.content == "second, no reply needed yet"
@pytest.mark.asyncio
async def test_ceo_reply_budget_first_reply_allowed(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "hi dev")
reply = await svc.send_chat_message(cid, "be-dev-1", "on it")
assert reply.content == "on it"
@pytest.mark.asyncio
async def test_ceo_reply_budget_second_reply_without_new_ceo_message_rejected(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "hi dev")
await svc.send_chat_message(cid, "be-dev-1", "on it")
with pytest.raises(A2AAccessDeniedError, match="already replied"):
await svc.send_chat_message(cid, "be-dev-1", "another update")
@pytest.mark.asyncio
async def test_ceo_reply_budget_refreshes_after_new_ceo_message(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "hi dev")
await svc.send_chat_message(cid, "be-dev-1", "on it")
await svc.send_chat_message(cid, "ceo", "any update?")
reply2 = await svc.send_chat_message(cid, "be-dev-1", "done!")
assert reply2.content == "done!"
@pytest.mark.asyncio
async def test_ceo_reply_budget_independent_across_conversations(
a2a_setup: dict,
) -> None:
"""The a2a_conversations model is strictly pairwise — a literal 3-party
thread can't exist. Adapted form: two agents each in their OWN
conversation with the CEO get independent budgets; one agent exhausting
its budget must not affect the other's."""
svc = a2a_setup["svc"]
conv_dev = await svc.get_or_create_conversation("ceo", "be-dev-1")
conv_qa = await svc.get_or_create_conversation("ceo", "be-qa")
cid_dev = UUID(conv_dev.id)
cid_qa = UUID(conv_qa.id)
await svc.send_chat_message(cid_dev, "ceo", "dev, status?")
await svc.send_chat_message(cid_qa, "ceo", "qa, status?")
await svc.send_chat_message(cid_dev, "be-dev-1", "on it")
with pytest.raises(A2AAccessDeniedError):
await svc.send_chat_message(cid_dev, "be-dev-1", "again")
# qa's independent budget is untouched by dev's exhausted one.
qa_reply = await svc.send_chat_message(cid_qa, "be-qa", "on it too")
assert qa_reply.content == "on it too"
@pytest.mark.asyncio
async def test_ceo_reply_dedup_before_budget_check(a2a_setup: dict) -> None:
"""Dedup runs BEFORE the budget check: a respawned agent re-sending its
identical unread reply gets the existing row back idempotently never a
budget error even once the agent has exhausted its reply budget."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "status?")
first = await svc.send_chat_message(cid, "be-dev-1", "on it")
# Budget is now exhausted (agent_count == ceo_count == 1); an identical
# resend must still dedup instead of hitting the budget gate.
again = await svc.send_chat_message(cid, "be-dev-1", "on it")
assert again.id == first.id
@pytest.mark.asyncio
async def test_send_to_ceo_via_gateway_when_ceo_opened_conversation(
a2a_setup: dict,
) -> None:
"""Once the CEO has opened a conversation with the agent, the gateway
send() adapter finds it directly bypassing
get_or_create_conversation's validate-first gate, which would otherwise
deny even a legitimate reply and the reply persists under budget."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
await svc.send_chat_message(UUID(conv.id), "ceo", "status?")
reply = await svc.send(
from_agent=dev.id, to_agent="ceo", task_id=task_id, body="on it"
)
assert reply.content == "on it"
assert reply.from_agent == "be-dev-1"
@pytest.mark.asyncio
async def test_send_publishes_only_after_persist_not_on_reply_denial(
a2a_setup: dict,
) -> None:
"""A rejected send (no existing CEO conversation) must never publish
A2A_MESSAGE_SENT the event is a record of a persisted message."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with (
patch("roboco.services.a2a.get_event_bus", return_value=mock_bus),
pytest.raises(A2AAccessDeniedError),
):
await svc.send(from_agent=dev.id, to_agent="ceo", task_id=task_id, body="hi")
mock_bus.publish.assert_not_awaited()
# ---------------------------------------------------------------------------
# Conversation creation happy path with allowed pair
# ---------------------------------------------------------------------------
+66 -1
View File
@@ -8,10 +8,11 @@ start/stop routes against a fake orchestrator.
from __future__ import annotations
from datetime import UTC, datetime
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
@@ -460,3 +461,67 @@ async def test_preview_batch_returns_waves_and_does_not_reap(
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"waves": [[0, 1]], "warnings": []}
assert orch.reaped == [] # preview creates nothing and leaves the chat alive
# ---------------------------------------------------------------------------
# search-tasks — the intake's mid-conversation "have we done this before?" tool.
# ---------------------------------------------------------------------------
def _row(title: str = "Fix login bug") -> MagicMock:
row = MagicMock()
row.id = uuid4()
row.title = title
row.status = "completed"
row.team = "backend"
row.completed_at = datetime.now(UTC)
row.updated_at = None
row.created_at = datetime.now(UTC)
return row
@pytest.mark.asyncio
async def test_search_tasks_returns_compact_rows_for_alive_session(
live_client: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
client, registry = live_client["client"], live_client["registry"]
registry.open("s1", "intake-1")
task_svc = MagicMock()
task_svc.search_tasks = AsyncMock(return_value=[_row()])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _db: task_svc)
resp = await client.get("/api/prompter/live/s1/search-tasks", params={"q": "login"})
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert len(body) == 1
assert set(body[0].keys()) == {"id", "title", "status", "team", "date"}
assert body[0]["title"] == "Fix login bug"
task_svc.search_tasks.assert_awaited_once()
@pytest.mark.asyncio
async def test_search_tasks_unknown_session_404(live_client: dict) -> None:
resp = await live_client["client"].get(
"/api/prompter/live/nope/search-tasks", params={"q": "login"}
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_search_tasks_query_too_short_422(live_client: dict) -> None:
live_client["registry"].open("s1", "intake-1")
resp = await live_client["client"].get(
"/api/prompter/live/s1/search-tasks", params={"q": "a"}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_search_tasks_limit_above_max_422(live_client: dict) -> None:
live_client["registry"].open("s1", "intake-1")
resp = await live_client["client"].get(
"/api/prompter/live/s1/search-tasks", params={"q": "login", "limit": 11}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
+85 -2
View File
@@ -8,8 +8,9 @@ existing v1-flow integration tests.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
@@ -237,6 +238,88 @@ async def test_get_active_count_for_agent(task_setup: dict) -> None:
assert isinstance(count, int)
# ---------------------------------------------------------------------------
# list_recent_for_project — the prompter's history digest source
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_recent_for_project_orders_most_recent_activity_first(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
db = task_setup["db"]
now = datetime.now(UTC)
oldest = await svc.create(_req(task_setup, title="oldest"))
middle = await svc.create(_req(task_setup, title="middle"))
newest = await svc.create(_req(task_setup, title="newest"))
# Distinct activity dates: oldest only has created_at far in the past;
# middle was touched (updated_at) more recently; newest actually completed
# (completed_at wins over updated_at/created_at).
oldest.created_at = now - timedelta(days=10)
oldest.updated_at = None
middle.created_at = now - timedelta(days=9)
middle.updated_at = now - timedelta(days=5)
newest.created_at = now - timedelta(days=8)
newest.updated_at = now - timedelta(days=7)
newest.completed_at = now - timedelta(days=1)
await db.flush()
rows = await svc.list_recent_for_project(task_setup["project_id"])
ids = [t.id for t in rows]
assert ids.index(newest.id) < ids.index(middle.id) < ids.index(oldest.id)
@pytest.mark.asyncio
async def test_list_recent_for_project_respects_limit(task_setup: dict) -> None:
svc = task_setup["svc"]
db = task_setup["db"]
now = datetime.now(UTC)
tasks = [await svc.create(_req(task_setup, title=f"t{i}")) for i in range(3)]
for i, t in enumerate(tasks):
t.created_at = now - timedelta(days=10 - i) # t0 oldest, t2 newest
t.updated_at = None
await db.flush()
query_limit = 2
rows = await svc.list_recent_for_project(
task_setup["project_id"], limit=query_limit
)
assert len(rows) == query_limit
ids = [t.id for t in rows]
assert ids == [tasks[2].id, tasks[1].id]
@pytest.mark.asyncio
async def test_list_recent_for_project_scoped_to_project(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
in_scope = await svc.create(_req(task_setup, title="in-scope"))
other_project = ProjectTable(
id=uuid4(),
name="Other-Proj",
slug=f"other-proj-{uuid4().hex[:8]}",
git_url="https://example.com/other.git",
assigned_cell=Team.BACKEND,
created_by=task_setup["agent_id"],
)
db_session.add(other_project)
await db_session.flush()
other_req = _req(task_setup, title="other-project-task")
other_req.project_id = cast("UUID", other_project.id)
out_of_scope = await svc.create(other_req)
rows = await svc.list_recent_for_project(task_setup["project_id"])
ids = {t.id for t in rows}
assert in_scope.id in ids
assert out_of_scope.id not in ids
# ---------------------------------------------------------------------------
# Subtask hierarchy
# ---------------------------------------------------------------------------
+41
View File
@@ -13,6 +13,7 @@ from uuid import uuid4
import pytest
from roboco.api.websocket_bridge import (
_handle_a2a_message_event,
_handle_agent_event,
_handle_message_event,
_handle_notification_sent,
@@ -429,6 +430,44 @@ async def test_handle_usage_snapshot_broadcasts_to_system() -> None:
assert len(msg["by_agent"]) == 1
# ---------------------------------------------------------------------------
# _handle_a2a_message_event
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_handle_a2a_message_event_broadcasts_to_system() -> None:
"""An A2A_MESSAGE_SENT event is forwarded to /ws/system as an
`a2a.message` frame the CEO's live view of every agent-to-agent chat."""
event = _evt(
EventType.A2A_MESSAGE_SENT,
{
"conversation_id": "conv-1",
"message_id": "msg-1",
"task_id": "task-1",
"from_agent": "be-dev-1",
"to_agent": "be-qa",
"skill": "code_review",
"body_excerpt": "please review",
"timestamp": "2026-07-02T00:00:00+00:00",
},
)
with patch("roboco.api.websocket_bridge.manager") as mgr:
mgr.broadcast_system = AsyncMock()
await _handle_a2a_message_event(event)
mgr.broadcast_system.assert_awaited_once()
msg = mgr.broadcast_system.await_args.args[0]
assert msg["type"] == "a2a.message"
assert msg["conversation_id"] == "conv-1"
assert msg["message_id"] == "msg-1"
assert msg["task_id"] == "task-1"
assert msg["from_agent"] == "be-dev-1"
assert msg["to_agent"] == "be-qa"
assert msg["skill"] == "code_review"
assert msg["body_excerpt"] == "please review"
assert msg["timestamp"] == "2026-07-02T00:00:00+00:00"
# ---------------------------------------------------------------------------
# Registration + start
# ---------------------------------------------------------------------------
@@ -465,6 +504,8 @@ def test_register_websocket_bridge_handlers_subscribes_all_event_types() -> None
assert EventType.USAGE_SNAPSHOT in types
# Message delivery forwarded to /ws/channels + /ws/sessions.
assert EventType.MESSAGE_SENT in types
# A2A live chat forwarded to /ws/system (CEO live view).
assert EventType.A2A_MESSAGE_SENT in types
@pytest.mark.asyncio
+44
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import pytest
from roboco.agents_config import can_a2a_direct
from roboco.enforcement.a2a_access import (
A2AAccessDeniedError,
get_a2a_allowed_targets,
@@ -50,3 +51,46 @@ def test_get_a2a_allowed_targets_excludes_self() -> None:
targets = get_a2a_allowed_targets("be-dev-1", ["be-dev-1", "be-qa"])
# Self should be filtered.
assert "be-dev-1" not in targets or "be-qa" in targets
# ---------------------------------------------------------------------------
# CEO-initiated A2A — the one asymmetric rule: CEO may send, nobody may
# target CEO (the block above must still hold).
# ---------------------------------------------------------------------------
def test_validate_a2a_access_ceo_to_agent_allowed() -> None:
result = validate_a2a_access("ceo", "be-dev-1")
assert result is True
def test_can_a2a_direct_ceo_to_main_pm() -> None:
assert can_a2a_direct("ceo", "main-pm") == (True, None)
def test_can_a2a_direct_ceo_to_board_member() -> None:
"""Board members are normally unreachable via direct A2A for everyone
else (routed through main-pm) CEO is exempt from that restriction."""
assert can_a2a_direct("ceo", "product-owner") == (True, None)
def test_validate_a2a_to_ceo_still_denied_with_ceo_send_rule() -> None:
"""Regression: allowing CEO-initiated A2A must not loosen the inbound
block nobody may target the CEO."""
with pytest.raises(A2AAccessDeniedError):
validate_a2a_access("be-dev-1", "ceo")
def test_get_a2a_allowed_targets_ceo_includes_all_roles() -> None:
targets = get_a2a_allowed_targets("ceo", ["be-dev-1", "be-qa", "main-pm"])
assert set(targets) == {"be-dev-1", "be-qa", "main-pm"}
def test_can_a2a_direct_to_ceo_message_explains_reply_only() -> None:
"""An agent can never INITIATE with the CEO (only reply inside a
conversation the CEO opened) the matrix denial message must say so,
not point at the old blanket 'use notify()' framing."""
allowed, reason = can_a2a_direct("be-dev-1", "ceo")
assert allowed is False
assert reason is not None
assert "reply" in reason.lower()
@@ -316,3 +316,166 @@ async def test_propose_draft_reports_relay_failure(
msg = await intake_server.propose_draft({"title": "X"})
assert "Could not submit the draft" in msg
assert "http_503" in msg
# ---------------------------------------------------------------------------
# search_past_tasks — the intake's mid-conversation "have we done this before?"
# tool (grok-CLI path).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_query_past_tasks_success_sends_q_and_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ROBOCO_API_URL", "http://orch:8000")
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["params"] = dict(request.url.params)
return httpx.Response(
200,
json=[
{
"id": "abcdef12-3456-7890-abcd-ef1234567890",
"title": "Fix login bug",
"status": "completed",
"team": "backend",
"date": "2026-01-01",
}
],
)
async with _client(handler) as client:
result = await intake_server.query_past_tasks(
"sess-1", "login", limit=5, client=client
)
assert seen["url"].startswith(
"http://orch:8000/api/prompter/live/sess-1/search-tasks"
)
assert seen["params"]["q"] == "login"
assert seen["params"]["limit"] == "5"
assert result["results"][0]["title"] == "Fix login bug"
@pytest.mark.asyncio
async def test_query_past_tasks_too_short_query_never_calls_http() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
raise AssertionError("must not call the relay for a too-short query")
async with _client(handler) as client:
result = await intake_server.query_past_tasks("sess-1", "a", client=client)
assert result == {"error": "query_too_short", "results": []}
@pytest.mark.asyncio
async def test_query_past_tasks_http_error_shape() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(503)
async with _client(handler) as client:
result = await intake_server.query_past_tasks("sess-1", "login", client=client)
assert result["error"] == "http_503"
assert result["results"] == []
@pytest.mark.asyncio
async def test_query_past_tasks_request_failure() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom")
async with _client(handler) as client:
result = await intake_server.query_past_tasks("sess-1", "login", client=client)
assert result["error"] == "request_failed"
assert "boom" in result["detail"]
assert result["results"] == []
@pytest.mark.asyncio
async def test_query_past_tasks_clamps_limit_above_max() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["limit"] = dict(request.url.params)["limit"]
return httpx.Response(200, json=[])
async with _client(handler) as client:
await intake_server.query_past_tasks(
"sess-1", "login", limit=999, client=client
)
assert seen["limit"] == "10"
@pytest.mark.asyncio
async def test_query_past_tasks_clamps_limit_below_min() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["limit"] = dict(request.url.params)["limit"]
return httpx.Response(200, json=[])
async with _client(handler) as client:
await intake_server.query_past_tasks("sess-1", "login", limit=0, client=client)
assert seen["limit"] == "1"
def test_format_search_results_error_dict() -> None:
msg = intake_server.format_search_results({"error": "http_503", "results": []})
assert "Could not search past tasks" in msg
assert "http_503" in msg
def test_format_search_results_empty_list() -> None:
msg = intake_server.format_search_results({"results": []})
assert "No past tasks matched" in msg
def test_format_search_results_renders_lines() -> None:
result = {
"results": [
{
"id": "abcdef1234567890",
"title": "Fix login bug",
"status": "completed",
"team": "backend",
"date": "2026-01-01",
}
]
}
msg = intake_server.format_search_results(result)
assert "`abcdef12`" in msg # short id truncated to 8 chars
assert "Fix login bug" in msg
assert "completed" in msg
assert "backend" in msg
assert "2026-01-01" in msg
@pytest.mark.asyncio
async def test_search_past_tasks_requires_a_live_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ROBOCO_PROMPTER_SESSION_ID", raising=False)
msg = await intake_server.search_past_tasks("login")
assert "No live session id" in msg
@pytest.mark.asyncio
async def test_search_past_tasks_success_path(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
stub_result = {
"results": [{"id": "x", "title": "T", "status": "s", "team": "t", "date": "d"}]
}
async def _stub(_session_id: str, _query: str, **_kwargs: Any) -> dict[str, Any]:
return stub_result
monkeypatch.setattr(intake_server, "query_past_tasks", _stub)
msg = await intake_server.search_past_tasks("login")
assert msg == intake_server.format_search_results(stub_result)
+170 -1
View File
@@ -14,7 +14,7 @@ from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
from uuid import UUID
from uuid import UUID, uuid4
import pytest
from roboco.runtime.orchestrator import (
@@ -263,6 +263,118 @@ class TestIntakeScopeSlugs:
)
# ---------------------------------------------------------------------------
# _resolve_history_digest_projects — the prompter-memory ambient's project scope
# (covers all three intake scopes, unlike the conventions ambient resolver).
# ---------------------------------------------------------------------------
class TestResolveHistoryDigestProjects:
@pytest.mark.asyncio
async def test_project_slug_branch_resolves_single_project(self) -> None:
class _FakeProjectSvc:
async def get_by_slug(self, slug: str) -> Any:
return SimpleNamespace(slug=slug, id=uuid4())
with patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
):
projects = await AgentOrchestrator._resolve_history_digest_projects(
object(), project_slug="roboco", product_id=None, project_ids=None
)
assert [p.slug for p in projects] == ["roboco"]
@pytest.mark.asyncio
async def test_project_slug_missing_returns_empty(self) -> None:
class _FakeProjectSvc:
async def get_by_slug(self, _slug: str) -> Any:
return None
with patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
):
projects = await AgentOrchestrator._resolve_history_digest_projects(
object(), project_slug="ghost", product_id=None, project_ids=None
)
assert projects == []
@pytest.mark.asyncio
async def test_product_id_branch_delegates_to_ambient_product_projects(
self,
) -> None:
sentinel = [SimpleNamespace(slug="p1", id=uuid4())]
async def _fake_product_projects(_db: Any, product_id: str) -> list[Any]:
assert product_id == "prod-1"
return sentinel
with patch.object(
AgentOrchestrator, "_ambient_product_projects", _fake_product_projects
):
projects = await AgentOrchestrator._resolve_history_digest_projects(
object(), project_slug=None, product_id="prod-1", project_ids=None
)
assert projects is sentinel
@pytest.mark.asyncio
async def test_project_ids_branch_preserves_order_and_skips_missing(
self,
) -> None:
good1 = "11111111-1111-1111-1111-111111111111"
missing = "22222222-2222-2222-2222-222222222222"
good2 = "33333333-3333-3333-3333-333333333333"
class _FakeProjectSvc:
async def get(self, pid: Any) -> Any:
if str(pid) == missing:
return None
return SimpleNamespace(slug=f"proj-{str(pid)[0]}", id=pid)
with patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
):
projects = await AgentOrchestrator._resolve_history_digest_projects(
object(),
project_slug=None,
product_id=None,
project_ids=[good1, missing, good2],
)
# Order preserved; the unresolvable id is skipped, not raised — this is
# a best-effort ambient resolver, not the hard clone-scope resolver.
assert [p.slug for p in projects] == ["proj-1", "proj-3"]
@pytest.mark.asyncio
async def test_no_scope_given_returns_empty(self) -> None:
projects = await AgentOrchestrator._resolve_history_digest_projects(
object(), project_slug=None, product_id=None, project_ids=None
)
assert projects == []
# ---------------------------------------------------------------------------
# _resolve_history_digest_ambient — best-effort: any failure returns None.
# ---------------------------------------------------------------------------
class TestResolveHistoryDigestAmbient:
@pytest.mark.asyncio
async def test_failure_returns_none_not_raises(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
def _boom() -> Any:
raise RuntimeError("db unavailable")
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
result = await orch._resolve_history_digest_ambient("roboco")
assert result is None
# ---------------------------------------------------------------------------
# spawn_intake_session / reap_intake_session — orchestration (docker mocked).
# ---------------------------------------------------------------------------
@@ -389,6 +501,63 @@ class TestSpawnIntakeSession:
await orch.spawn_intake_session("sess-2", project_slug="roboco")
assert stopped == [INTAKE_AGENT_ID] # the old one was reaped first
@pytest.mark.asyncio
async def test_spawn_merges_conventions_and_history_ambient(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The composed prompt's ambient is the conventions + history-digest
blocks joined with compose_prompt's own layer separator."""
orch = _make_minimal_orchestrator()
run_calls: list[list[str]] = []
_wire_spawn_mocks(monkeypatch, orch, run_calls)
async def _conventions(*_a: Any, **_k: Any) -> str | None:
return "CONVENTIONS BLOCK"
async def _history(*_a: Any, **_k: Any) -> str | None:
return "HISTORY BLOCK"
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _conventions)
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _history)
captured: dict[str, Any] = {}
def _spy_prompt(*_args: Any, **kwargs: Any) -> Path:
captured["ambient"] = kwargs.get("ambient")
return Path("/tmp/intake-1-prompt.md")
monkeypatch.setattr(orch, "_generate_composed_prompt", _spy_prompt)
await orch.spawn_intake_session("sess-merge", project_slug="roboco")
assert captured["ambient"] == "CONVENTIONS BLOCK\n\n---\n\nHISTORY BLOCK"
@pytest.mark.asyncio
async def test_spawn_ambient_none_when_both_resolvers_empty(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
run_calls: list[list[str]] = []
_wire_spawn_mocks(monkeypatch, orch, run_calls)
async def _none(*_a: Any, **_k: Any) -> str | None:
return None
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _none)
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _none)
captured: dict[str, Any] = {}
def _spy_prompt(*_args: Any, **kwargs: Any) -> Path:
captured["ambient"] = kwargs.get("ambient")
return Path("/tmp/intake-1-prompt.md")
monkeypatch.setattr(orch, "_generate_composed_prompt", _spy_prompt)
await orch.spawn_intake_session("sess-no-ambient", project_slug="roboco")
assert captured["ambient"] is None
@pytest.mark.asyncio
async def test_initial_message_is_scheduled(
self, monkeypatch: pytest.MonkeyPatch
+227 -1
View File
@@ -8,10 +8,15 @@ conftest fixtures.
from __future__ import annotations
from typing import Any, cast
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
import pytest
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import (
AgentTable,
ProductTable,
@@ -28,15 +33,23 @@ from roboco.models.base import (
Team,
)
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services import prompter as prompter_module
from roboco.services.base import ServiceError, ValidationError
from roboco.services.prompter import (
_HISTORY_DIGEST_PER_PROJECT_LIMIT,
_HISTORY_TITLE_EXCERPT_CAP,
PrompterService,
_cell_teams,
_clean_list,
_draft_cell_map,
_task_activity_date,
_title_excerpt,
build_history_digest,
compact_task_rows,
compose_description,
derive_scale,
get_prompter_service,
history_digest_layer,
parse_readiness,
)
@@ -1052,3 +1065,216 @@ async def test_create_task_from_draft_does_not_mutate_caller_draft(
assert draft["the_work"][0]["items"] == original_items
# ...and the top-level acceptance_criteria was NOT replaced.
assert draft["acceptance_criteria"] == ["done"]
# =============================================================================
# Prompter memory v1 — history digest + compact search rows (pure, no DB)
# =============================================================================
def _task(title: str, **overrides: Any) -> TaskTable:
"""An unattached TaskTable instance — plain attribute assignment, no session.
Defaults to a completed backend task with no dates; pass ``completed_at`` /
``updated_at`` / ``created_at`` / ``status`` / ``team`` to override.
"""
fields: dict[str, Any] = {
"id": uuid4(),
"title": title,
"status": TaskStatus.COMPLETED,
"team": Team.BACKEND,
"completed_at": None,
"updated_at": None,
"created_at": None,
}
fields.update(overrides)
return TaskTable(**fields)
def test_task_activity_date_prefers_completed_at() -> None:
now = datetime.now(UTC)
task = _task(
"t",
completed_at=now,
updated_at=now - timedelta(days=1),
created_at=now - timedelta(days=2),
)
assert _task_activity_date(task) == now
def test_task_activity_date_falls_back_to_updated_at() -> None:
now = datetime.now(UTC)
task = _task(
"t", completed_at=None, updated_at=now, created_at=now - timedelta(days=1)
)
assert _task_activity_date(task) == now
def test_task_activity_date_falls_back_to_created_at() -> None:
now = datetime.now(UTC)
task = _task("t", completed_at=None, updated_at=None, created_at=now)
assert _task_activity_date(task) == now
def test_title_excerpt_leaves_short_titles_untouched() -> None:
assert _title_excerpt("Fix login bug") == "Fix login bug"
def test_title_excerpt_truncates_long_titles_with_ellipsis() -> None:
long_title = "A" * 100
excerpt = _title_excerpt(long_title)
assert len(excerpt) == _HISTORY_TITLE_EXCERPT_CAP
assert excerpt.endswith("")
def test_build_history_digest_empty_is_blank() -> None:
assert build_history_digest([]) == ""
def test_build_history_digest_caps_at_limit_keeps_most_recent() -> None:
now = datetime.now(UTC)
# t0 oldest ... t19 newest.
ascending = [
_task(f"t{i}", created_at=now + timedelta(days=i), updated_at=None)
for i in range(20)
]
# Mimic the DB's most-recent-first ordering.
most_recent_first = list(reversed(ascending))
digest = build_history_digest(most_recent_first)
lines = digest.splitlines()
assert len(lines) == _HISTORY_DIGEST_PER_PROJECT_LIMIT
for i in range(5): # the 5 oldest are excluded
assert f"`{str(ascending[i].id)[:8]}`" not in digest
for i in range(5, 20): # the 15 most recent are present
assert f"`{str(ascending[i].id)[:8]}`" in digest
def test_build_history_digest_renders_oldest_first() -> None:
now = datetime.now(UTC)
a = _task("Task A", created_at=now - timedelta(days=2), updated_at=None)
b = _task("Task B", created_at=now - timedelta(days=1), updated_at=None)
c = _task("Task C", created_at=now, updated_at=None)
# DB order is most-recent-first: C, B, A.
digest = build_history_digest([c, b, a])
idx_a = digest.index("Task A")
idx_b = digest.index("Task B")
idx_c = digest.index("Task C")
assert idx_a < idx_b < idx_c
def test_compact_task_rows_shape() -> None:
now = datetime.now(UTC)
task = _task(
"Fix login bug",
status=TaskStatus.COMPLETED,
team=Team.BACKEND,
completed_at=now,
)
rows = compact_task_rows([task])
assert len(rows) == 1
row = rows[0]
assert set(row.keys()) == {"id", "title", "status", "team", "date"}
assert row["id"] == str(task.id)
assert row["title"] == "Fix login bug"
assert row["status"] == "completed"
assert row["team"] == "backend"
assert row["date"] == now.date().isoformat()
def test_compact_task_rows_preserves_none_team() -> None:
task = _task("No team", team=None, created_at=datetime.now(UTC))
rows = compact_task_rows([task])
assert rows[0]["team"] is None
# -----------------------------------------------------------------------------
# history_digest_layer — ambient-block assembly (project_history_digest stubbed)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_history_digest_layer_empty_projects_returns_none() -> None:
assert await history_digest_layer(cast("AsyncSession", object()), []) is None
@pytest.mark.asyncio
async def test_history_digest_layer_single_project_has_no_header(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake(_session: Any, _project: Any, *, _limit: int = 15) -> str | None:
return "- `abc12345` Some task (completed, 2026-01-01)"
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
project = SimpleNamespace(slug="roboco", id=uuid4())
text = await history_digest_layer(cast("AsyncSession", object()), [project])
assert text is not None
assert text.startswith("## Task History\n\n### Recent tasks\n")
assert "### Recent tasks —" not in text
@pytest.mark.asyncio
async def test_history_digest_layer_multi_project_headers_by_slug(
monkeypatch: pytest.MonkeyPatch,
) -> None:
projects = [
SimpleNamespace(slug="backend-svc", id=uuid4()),
SimpleNamespace(slug="frontend-app", id=uuid4()),
]
async def _fake(_session: Any, project: Any, *, _limit: int = 15) -> str | None:
return f"- `deadbeef` Task for {project.slug} (completed, 2026-01-01)"
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
text = await history_digest_layer(cast("AsyncSession", object()), projects)
assert text is not None
assert "### Recent tasks — `backend-svc`" in text
assert "### Recent tasks — `frontend-app`" in text
@pytest.mark.asyncio
async def test_history_digest_layer_skips_projects_with_no_tasks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
has_tasks = SimpleNamespace(slug="has-tasks", id=uuid4())
no_tasks = SimpleNamespace(slug="empty-proj", id=uuid4())
async def _fake(_session: Any, project: Any, *, _limit: int = 15) -> str | None:
return (
"- `deadbeef` A task (completed, 2026-01-01)"
if project is has_tasks
else None
)
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
text = await history_digest_layer(
cast("AsyncSession", object()), [has_tasks, no_tasks]
)
assert text is not None
assert "has-tasks" in text
assert "empty-proj" not in text
@pytest.mark.asyncio
async def test_history_digest_layer_all_empty_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake(_session: Any, _project: Any, *, _limit: int = 15) -> str | None:
return None
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
projects = [
SimpleNamespace(slug="a", id=uuid4()),
SimpleNamespace(slug="b", id=uuid4()),
]
assert await history_digest_layer(cast("AsyncSession", object()), projects) is None