fix(panel): V6 review gaps — honest errors, safe secretary start, real tests

CEO A2A mutations now invalidate the Mine-list query key so the list
refreshes without the socket; the tg Metrics tab renders explicit error
notes instead of confident zero stats when a section's fetch fails; the
tg Secretary chat checks a new registry-backed /secretary/live/active
route before auto-starting, showing a Take Over button instead of
silently killing a session live on another device; the AI-routing card
surfaces roster fetch errors instead of an empty grid; the shared
acceptance-criteria editor caps at the backend's 7-item limit; the
board-tab comment no longer calls the task sheet read-only. Tests:
task-sheet approve/reject interactions (not just visibility), a
non-demo metrics error-state test, secretary take-over branches, and
dashboard-router auth-gate coverage (the e2e harness now mounts the
dashboard router so the gate is actually exercised).
This commit is contained in:
Renn F
2026-07-22 03:31:25 +02:00
parent 5ca8a9c4a6
commit a1233b2aeb
18 changed files with 726 additions and 144 deletions
@@ -413,6 +413,27 @@ describe("AIRoutingCard", () => {
expect(mixSection.querySelector(".animate-pulse")).toBeInTheDocument(); expect(mixSection.querySelector(".animate-pulse")).toBeInTheDocument();
}); });
it("shows an error note (not a silently empty grid) when the roster fetch fails", async () => {
useAgentDefinitions.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Per-agent override (mix mode)");
expect(
screen.getByText(/Couldn.t load the agent roster/i),
).toBeInTheDocument();
expect(
screen.queryByRole("heading", { level: 4, name: "Board" }),
).not.toBeInTheDocument();
const mixSection = screen
.getByText("Per-agent override (mix mode)")
.closest("section")!;
expect(mixSection.querySelector(".animate-pulse")).not.toBeInTheDocument();
});
it("tooltip-wraps the Grok/Ollama key labels and status badges, not the raw Switch", async () => { it("tooltip-wraps the Grok/Ollama key labels and status badges, not the raw Switch", async () => {
render(withQueryClient(<AIRoutingCard />)); render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Grok (xAI) API key"); await screen.findByText("Grok (xAI) API key");
@@ -131,7 +131,11 @@ export function AIRoutingCard() {
const { data: keyStatus } = useOllamaKey(); const { data: keyStatus } = useOllamaKey();
const { data: snapshot } = useRoutingMode(); const { data: snapshot } = useRoutingMode();
const { data: selfHostedModels = [] } = useSelfHostedModels(); const { data: selfHostedModels = [] } = useSelfHostedModels();
const { data: agentDefs, isLoading: agentsLoading } = useAgentDefinitions(); const {
data: agentDefs,
isLoading: agentsLoading,
isError: agentsError,
} = useAgentDefinitions();
const agentGroups = useMemo( const agentGroups = useMemo(
() => () =>
@@ -662,7 +666,13 @@ export function AIRoutingCard() {
Leave a row blank to inherit from the global mode. Saving overwrites Leave a row blank to inherit from the global mode. Saving overwrites
all per-agent overrides with what&apos;s picked here. all per-agent overrides with what&apos;s picked here.
</p> </p>
{agentsLoading ? ( {agentsError ? (
<p className="flex items-center gap-1 rounded-md border p-4 text-xs text-amber-600">
<AlertTriangle className="h-3 w-3 shrink-0" />
Couldn&apos;t load the agent roster per-agent overrides are
unavailable until this reloads.
</p>
) : agentsLoading ? (
<div className="divide-y rounded-md border"> <div className="divide-y rounded-md border">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="p-4"> <div key={i} className="p-4">
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AcceptanceCriteriaEditor } from "../acceptance-criteria-editor";
const sevenCriteria = Array.from({ length: 7 }, (_, i) => `Criterion ${i + 1}`);
describe("AcceptanceCriteriaEditor — max-7 guard", () => {
it("allows adding while under the cap", async () => {
const onChange = vi.fn();
render(
<AcceptanceCriteriaEditor
criteria={["Criterion 1"]}
onChange={onChange}
/>,
);
await userEvent.type(
screen.getByPlaceholderText(/enter acceptance criterion/i),
"Criterion 2",
);
await userEvent.click(screen.getByRole("button", { name: /add/i }));
expect(onChange).toHaveBeenCalledWith(["Criterion 1", "Criterion 2"]);
// `criteria` is controlled by the parent — unchanged in this render since
// the test doesn't re-render with the mutation applied.
expect(screen.getByText("1/7 item")).toBeInTheDocument();
});
it("disables the add control and shows the cap hint at 7 criteria", () => {
const onChange = vi.fn();
render(
<AcceptanceCriteriaEditor criteria={sevenCriteria} onChange={onChange} />,
);
expect(screen.getByText("7/7 items")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add/i })).toBeDisabled();
expect(
screen.getByPlaceholderText(/maximum of 7 criteria reached/i),
).toBeDisabled();
expect(
screen.getAllByText(/maximum of 7 acceptance criteria reached/i).length,
).toBeGreaterThan(0);
});
it("never calls onChange for an 8th criterion even via Enter", async () => {
const onChange = vi.fn();
render(
<AcceptanceCriteriaEditor criteria={sevenCriteria} onChange={onChange} />,
);
const input = screen.getByPlaceholderText(/maximum of 7 criteria reached/i);
expect(input).toBeDisabled();
// A disabled input can't be typed into or submitted — confirms the guard
// is enforced at the control, not just the handler.
await userEvent.type(input, "Criterion 8");
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -18,16 +18,23 @@ interface AcceptanceCriteriaEditorProps {
error?: string; error?: string;
} }
// Mirrors the backend cap (acceptance_criteria max_length=7 — see
// roboco/api/schemas/tasks.py's TaskUpdate and the agent-facing v1 flow
// schema). Blocking it here means an 8th criterion never round-trips into a
// swallowed 422 on save.
const MAX_CRITERIA = 7;
export function AcceptanceCriteriaEditor({ export function AcceptanceCriteriaEditor({
criteria, criteria,
onChange, onChange,
error, error,
}: AcceptanceCriteriaEditorProps) { }: AcceptanceCriteriaEditorProps) {
const [newCriterion, setNewCriterion] = useState(""); const [newCriterion, setNewCriterion] = useState("");
const atMax = criteria.length >= MAX_CRITERIA;
const handleAdd = () => { const handleAdd = () => {
const trimmed = newCriterion.trim(); const trimmed = newCriterion.trim();
if (trimmed && !criteria.includes(trimmed)) { if (trimmed && !criteria.includes(trimmed) && !atMax) {
onChange([...criteria, trimmed]); onChange([...criteria, trimmed]);
setNewCriterion(""); setNewCriterion("");
} }
@@ -59,9 +66,10 @@ export function AcceptanceCriteriaEditor({
Acceptance Criteria <span className="text-destructive">*</span> Acceptance Criteria <span className="text-destructive">*</span>
</Label> </Label>
</HelpTip> </HelpTip>
<HelpTip label="How many criteria are defined so far — at least one is required to submit."> <HelpTip label="How many criteria are defined so far — at least one is required to submit, at most 7.">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{criteria.length} item{criteria.length !== 1 ? "s" : ""} {criteria.length}/{MAX_CRITERIA} item
{criteria.length !== 1 ? "s" : ""}
</span> </span>
</HelpTip> </HelpTip>
</div> </div>
@@ -103,25 +111,42 @@ export function AcceptanceCriteriaEditor({
{/* Add new criterion */} {/* Add new criterion */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HelpTip label="A specific, testable condition — Enter or Add appends it to the list above."> <HelpTip
label={
atMax
? "Maximum of 7 acceptance criteria reached — remove one to add another."
: "A specific, testable condition — Enter or Add appends it to the list above."
}
>
<Input <Input
value={newCriterion} value={newCriterion}
onChange={(e) => setNewCriterion(e.target.value)} onChange={(e) => setNewCriterion(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Enter acceptance criterion and press Enter..." placeholder={
atMax
? "Maximum of 7 criteria reached"
: "Enter acceptance criterion and press Enter..."
}
disabled={atMax}
className="flex-1" className="flex-1"
/> />
</HelpTip> </HelpTip>
<HelpTip label="Appends the text on the left as a new criterion; disabled until you type something."> <HelpTip
label={
atMax
? "Maximum of 7 acceptance criteria reached — remove one to add another."
: "Appends the text on the left as a new criterion; disabled until you type something."
}
>
<span <span
className="inline-block" className="inline-block"
tabIndex={!newCriterion.trim() ? 0 : undefined} tabIndex={!newCriterion.trim() || atMax ? 0 : undefined}
> >
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={handleAdd} onClick={handleAdd}
disabled={!newCriterion.trim()} disabled={!newCriterion.trim() || atMax}
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
Add Add
@@ -132,8 +157,9 @@ export function AcceptanceCriteriaEditor({
{/* Helper text */} {/* Helper text */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Define at least one acceptance criterion. Each criterion should describe {atMax
a specific, testable condition for task completion. ? "Maximum of 7 acceptance criteria reached — remove one to add another."
: "Define at least one acceptance criterion. Each criterion should describe a specific, testable condition for task completion."}
</p> </p>
{/* Error message */} {/* Error message */}
@@ -1,9 +1,29 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TgChatTab } from "../tg-chat-tab"; import { TgChatTab } from "../tg-chat-tab";
const { secretaryState, startMock } = vi.hoisted(() => ({
secretaryState: { sessionId: null as string | null },
startMock: vi.fn(),
}));
vi.mock("@/hooks/use-secretary", () => ({
useSecretary: () => ({
sessionId: secretaryState.sessionId,
messages: [],
streaming: false,
start: startMock,
send: vi.fn(),
stop: vi.fn(),
}),
}));
const { isActiveMock } = vi.hoisted(() => ({ isActiveMock: vi.fn() }));
vi.mock("@/lib/api/secretary", () => ({
secretaryApi: { isActive: isActiveMock },
}));
const { mineItems, fleetItems, messages, sendMock, replyMock, markReadMock } = const { mineItems, fleetItems, messages, sendMock, replyMock, markReadMock } =
vi.hoisted(() => ({ vi.hoisted(() => ({
mineItems: { current: [] as Array<Record<string, unknown>> }, mineItems: { current: [] as Array<Record<string, unknown>> },
@@ -121,6 +141,10 @@ beforeEach(() => {
sendMock.mockReset(); sendMock.mockReset();
replyMock.mockReset(); replyMock.mockReset();
markReadMock.mockReset(); markReadMock.mockReset();
secretaryState.sessionId = null;
startMock.mockReset().mockResolvedValue("s1");
isActiveMock.mockReset();
window.localStorage.clear();
}); });
describe("TgChatTab — list", () => { describe("TgChatTab — list", () => {
@@ -212,3 +236,45 @@ describe("TgChatTab — threads", () => {
expect(screen.queryByPlaceholderText("Message…")).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText("Message…")).not.toBeInTheDocument();
}); });
}); });
describe("TgChatTab — Secretary cross-device preemption", () => {
it("offers Take over instead of auto-starting when a session is live elsewhere", async () => {
isActiveMock.mockResolvedValue(true);
renderTab();
await userEvent.click(screen.getByText("Secretary"));
expect(
await screen.findByText(/live on another device/i),
).toBeInTheDocument();
expect(startMock).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: /take over/i }));
expect(startMock).toHaveBeenCalled();
});
it("auto-starts as before when nothing is live elsewhere", async () => {
isActiveMock.mockResolvedValue(false);
renderTab();
await userEvent.click(screen.getByText("Secretary"));
await waitFor(() => expect(startMock).toHaveBeenCalled());
expect(
screen.queryByText(/live on another device/i),
).not.toBeInTheDocument();
});
it("auto-starts without checking isActive when this device has its own persisted session", async () => {
window.localStorage.setItem(
"roboco:secretary:live",
JSON.stringify({ sessionId: "old", messages: [], savedAt: Date.now() }),
);
renderTab();
await userEvent.click(screen.getByText("Secretary"));
await waitFor(() => expect(startMock).toHaveBeenCalled());
expect(isActiveMock).not.toHaveBeenCalled();
});
});
@@ -1,15 +1,44 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TgMetricsTab } from "../tg-metrics-tab"; import { TgMetricsTab } from "../tg-metrics-tab";
vi.mock("@/lib/telegram/demo", () => ({ isTgDemoMode: () => true })); // A mutable ref (not a bare boolean) so a later describe block can flip it to
// false for the non-demo error-state tests without a second module factory.
const { demoMode } = vi.hoisted(() => ({ demoMode: { current: true } }));
vi.mock("@/lib/telegram/demo", () => ({ isTgDemoMode: () => demoMode.current }));
// Scorecard resolution needs the roster only outside demo mode (the demo // Scorecard resolution needs the roster only outside demo mode (the demo
// scorecard fixture returns unconditionally) — an empty roster keeps this // scorecard fixture returns unconditionally) — an empty roster keeps this
// hook off the network without affecting anything the tests assert on. // hook off the network without affecting anything the tests assert on.
vi.mock("@/hooks/use-agents", () => ({ useAgents: () => ({ data: [] }) })); vi.mock("@/hooks/use-agents", () => ({ useAgents: () => ({ data: [] }) }));
const usageMocks = vi.hoisted(() => ({
getUsageSummary: vi.fn(),
getUsageTimeSeries: vi.fn(),
getAgentUsage: vi.fn(),
getTeamUsage: vi.fn(),
getModelUsage: vi.fn(),
getCacheEfficiency: vi.fn(),
getUsageProjection: vi.fn(),
getSpawnWaste: vi.fn(),
}));
vi.mock("@/lib/api/usage", () => ({ usageApi: usageMocks }));
const observabilityMocks = vi.hoisted(() => ({
getRework: vi.fn(),
getCycleTime: vi.fn(),
}));
vi.mock("@/lib/api/observability", () => ({
observabilityApi: observabilityMocks,
}));
beforeEach(() => {
demoMode.current = true;
for (const m of Object.values(usageMocks)) m.mockReset();
for (const m of Object.values(observabilityMocks)) m.mockReset();
});
function renderTab() { function renderTab() {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@@ -89,3 +118,58 @@ describe("TgMetricsTab", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
}); });
describe("TgMetricsTab — section error states (non-demo)", () => {
beforeEach(() => {
demoMode.current = false;
usageMocks.getUsageSummary.mockResolvedValue({
tokens_input: 0,
tokens_output: 0,
total_tokens: 1000,
total_cost_usd: 10,
trend_pct: 0,
period: "7d",
});
usageMocks.getUsageTimeSeries.mockResolvedValue([]);
usageMocks.getAgentUsage.mockResolvedValue([]);
usageMocks.getTeamUsage.mockResolvedValue([]);
usageMocks.getModelUsage.mockResolvedValue([]);
usageMocks.getCacheEfficiency.mockResolvedValue({
cache_hit_rate: 0.42,
tokens_cache_read: 0,
tokens_cache_write: 0,
tokens_input: 0,
cost_saved_by_cache_usd: 3,
period: "7d",
});
usageMocks.getUsageProjection.mockResolvedValue({
total_cost_7d: 10,
avg_daily_cost_usd: 1,
projected_monthly_cost_usd: 30,
basis_days: 7,
});
usageMocks.getSpawnWaste.mockResolvedValue({
total_spawns: 10,
unproductive_spawns: 1,
unproductive_pct: 10,
by_role: [],
respawn_strikes: [],
period: "7d",
});
// The Delivery section's own fetch fails while everything else succeeds.
observabilityMocks.getRework.mockRejectedValue(new Error("network"));
observabilityMocks.getCycleTime.mockResolvedValue([]);
});
it("shows an inline error note for Delivery instead of a 0%-backed rework tile", async () => {
renderTab();
expect(await screen.findByText("$10.00")).toBeInTheDocument();
expect(
await screen.findByText(/Couldn.t load delivery metrics/i),
).toBeInTheDocument();
expect(screen.queryByText("Rework rate")).not.toBeInTheDocument();
// Efficiency loaded fine — its own tiles still render real numbers.
expect(screen.getByText("42%")).toBeInTheDocument();
});
});
@@ -1,19 +1,30 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render as rtlRender, screen } from "@testing-library/react"; import { render as rtlRender, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { TgTaskSheet } from "../tg-task-sheet"; import { TgTaskSheet } from "../tg-task-sheet";
import type { Task } from "@/types"; import type { Task } from "@/types";
import type { TaskFindingsResponse } from "@/lib/api/tasks"; import type { TaskFindingsResponse } from "@/lib/api/tasks";
const { findings } = vi.hoisted(() => ({ const { findings, ceoApprove, ceoReject, unblock } = vi.hoisted(() => ({
findings: vi.fn<() => { data: TaskFindingsResponse | undefined }>(() => ({ findings: vi.fn<() => { data: TaskFindingsResponse | undefined }>(() => ({
data: undefined, data: undefined,
})), })),
ceoApprove: vi.fn(),
ceoReject: vi.fn(),
unblock: vi.fn(),
})); }));
vi.mock("@/hooks/use-tasks", () => ({ vi.mock("@/hooks/use-tasks", () => ({
useTaskFindings: findings, useTaskFindings: findings,
taskKeys: { all: ["tasks"] }, taskKeys: { all: ["tasks"] },
})); }));
vi.mock("@/lib/api/tasks", () => ({
tasksApi: { ceoApprove, ceoReject, unblock },
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// The sheet's CEO action block mutates through react-query. // The sheet's CEO action block mutates through react-query.
function render(ui: React.ReactElement) { function render(ui: React.ReactElement) {
@@ -71,6 +82,14 @@ function task(overrides: Partial<Task> = {}): Task {
} as Task; } as Task;
} }
beforeEach(() => {
ceoApprove.mockReset();
ceoReject.mockReset();
unblock.mockReset();
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
});
describe("TgTaskSheet", () => { describe("TgTaskSheet", () => {
it("renders nothing without a task", () => { it("renders nothing without a task", () => {
render(<TgTaskSheet task={null} onClose={vi.fn()} />); render(<TgTaskSheet task={null} onClose={vi.fn()} />);
@@ -173,3 +192,70 @@ describe("TgTaskSheet", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
}); });
describe("TgTaskSheet — CEO action interactions", () => {
it("clicking Approve calls ceoApprove with the task id and toasts success", async () => {
ceoApprove.mockResolvedValue({});
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(ceoApprove).toHaveBeenCalledWith("t1");
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith("Approved"),
);
});
it("shows the error toast when approve's promise rejects", async () => {
ceoApprove.mockRejectedValue(new Error("Approve failed"));
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Approve" }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Approve failed"),
);
expect(ceoReject).not.toHaveBeenCalled();
});
it("keeps Send back for revision disabled under the 10-char reason floor", async () => {
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
await userEvent.click(
screen.getByRole("button", { name: "Request changes" }),
);
const textarea = screen.getByPlaceholderText(/at least 10 characters/i);
const sendBack = screen.getByRole("button", {
name: "Send back for revision",
});
expect(sendBack).toBeDisabled();
await userEvent.type(textarea, "too short");
expect(sendBack).toBeDisabled();
expect(ceoReject).not.toHaveBeenCalled();
});
it("enables Send back at 10+ chars and calls ceoReject with the id and reason", async () => {
ceoReject.mockResolvedValue({});
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
await userEvent.click(
screen.getByRole("button", { name: "Request changes" }),
);
await userEvent.type(
screen.getByPlaceholderText(/at least 10 characters/i),
"Please redo the retry backoff",
);
const sendBack = screen.getByRole("button", {
name: "Send back for revision",
});
expect(sendBack).toBeEnabled();
await userEvent.click(sendBack);
expect(ceoReject).toHaveBeenCalledWith("t1", "Please redo the retry backoff");
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith("Sent back for revision"),
);
});
});
+4 -2
View File
@@ -216,8 +216,10 @@ function DoneSection({
} }
/** Cockpit Board tab — every task grouped by lifecycle stage, tapping any /** Cockpit Board tab — every task grouped by lifecycle stage, tapping any
* row opens the read-only task sheet. Demo mode swaps in the canned * row opens the task sheet (which carries the CEO's own decide verbs —
* fixture list, lazily imported so it stays out of the prod bundle. */ * approve / request changes / unblock — when a task is waiting on them).
* Demo mode swaps in the canned fixture list, lazily imported so it stays
* out of the prod bundle. */
export function TgBoardTab() { export function TgBoardTab() {
const [selected, setSelected] = useState<Task | null>(null); const [selected, setSelected] = useState<Task | null>(null);
const [demoTasks, setDemoTasks] = useState<Task[] | undefined>(undefined); const [demoTasks, setDemoTasks] = useState<Task[] | undefined>(undefined);
+75 -3
View File
@@ -29,6 +29,7 @@ import {
} from "@/hooks/use-a2a-live"; } from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket"; import { useA2ALiveStream } from "@/hooks/use-websocket";
import { useSecretary, type ChatMessage } from "@/hooks/use-secretary"; import { useSecretary, type ChatMessage } from "@/hooks/use-secretary";
import { secretaryApi } from "@/lib/api/secretary";
import { CEO_SLUG } from "@/components/a2a/a2a-utils"; import { CEO_SLUG } from "@/components/a2a/a2a-utils";
import { AgentSelector } from "@/components/agents/agent-selector"; import { AgentSelector } from "@/components/agents/agent-selector";
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog"; import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
@@ -167,20 +168,69 @@ function SecretaryPinnedRow({ onOpen }: { onOpen: () => void }) {
); );
} }
// Mirrors useSecretary's own localStorage key (panel/src/hooks/use-secretary.ts,
// PERSIST_KEY) — read-only here, just to tell "this device has nothing of
// its own to restore" apart from "a restore is still resolving" before this
// view decides whether it's safe to auto-start.
const SECRETARY_PERSIST_KEY = "roboco:secretary:live";
function hasPersistedSecretarySession(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(SECRETARY_PERSIST_KEY) !== null;
} catch {
return false;
}
}
function SecretaryView({ onBack }: { onBack: () => void }) { function SecretaryView({ onBack }: { onBack: () => void }) {
const demo = isTgDemoMode(); const demo = isTgDemoMode();
const { sessionId, messages, streaming, start, send, stop } = useSecretary(); const { sessionId, messages, streaming, start, send, stop } = useSecretary();
const shown = demo ? DEMO_SECRETARY : messages; const shown = demo ? DEMO_SECRETARY : messages;
// One live session per view — start() restores a persisted one when the // The Secretary is a backend singleton (one container at a time) — a
// hook finds it, so re-entering the chat resumes rather than respawns. // device with nothing of its own to restore auto-starting anyway would
// silently preempt a live session on another device. So this view only
// auto-starts unconditionally when it has a session of its own to resume
// (unchanged from before); a genuinely fresh device checks the singleton
// first and offers an explicit take-over instead of blind-starting.
const [activeElsewhere, setActiveElsewhere] = useState(false);
const startedRef = useRef(false); const startedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (demo || startedRef.current || sessionId) return; if (demo || startedRef.current || sessionId) return;
if (hasPersistedSecretarySession()) {
startedRef.current = true; startedRef.current = true;
start().catch((err) => toast.error(getErrorMessage(err))); start().catch((err) => toast.error(getErrorMessage(err)));
return;
}
let cancelled = false;
const goAhead = () => {
if (cancelled || startedRef.current) return;
startedRef.current = true;
void start().catch((err) => toast.error(getErrorMessage(err)));
};
secretaryApi
.isActive()
.then((active) => {
if (cancelled) return;
if (active) setActiveElsewhere(true);
else goAhead();
})
// A failed status check shouldn't strand a fresh device with neither
// a chat nor a takeover button — fall back to the prior behavior.
.catch(goAhead);
return () => {
cancelled = true;
};
}, [demo, sessionId, start]); }, [demo, sessionId, start]);
const takeOver = () => {
haptics.tap();
setActiveElsewhere(false);
startedRef.current = true;
void start().catch((err) => toast.error(getErrorMessage(err)));
};
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const count = shown.length; const count = shown.length;
useEffect(() => { useEffect(() => {
@@ -211,6 +261,24 @@ function SecretaryView({ onBack }: { onBack: () => void }) {
) : undefined ) : undefined
} }
> >
{activeElsewhere && !demo && !sessionId ? (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-sm text-muted-foreground">
A Secretary session is live on another device.
</p>
<button
type="button"
onClick={takeOver}
className={cn(
"rounded-full bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground",
TG_PRESS,
)}
>
Take over
</button>
</div>
) : (
<>
<div <div
ref={scrollRef} ref={scrollRef}
className="max-h-[58dvh] space-y-1.5 overflow-y-auto pb-1" className="max-h-[58dvh] space-y-1.5 overflow-y-auto pb-1"
@@ -239,7 +307,9 @@ function SecretaryView({ onBack }: { onBack: () => void }) {
)} )}
> >
{m.role === "user" ? ( {m.role === "user" ? (
<p className="whitespace-pre-wrap break-words">{m.text}</p> <p className="whitespace-pre-wrap break-words">
{m.text}
</p>
) : ( ) : (
<Markdown <Markdown
compact compact
@@ -275,6 +345,8 @@ function SecretaryView({ onBack }: { onBack: () => void }) {
void send(text).catch((err) => toast.error(getErrorMessage(err))); void send(text).catch((err) => toast.error(getErrorMessage(err)));
}} }}
/> />
</>
)}
</TgSubPage> </TgSubPage>
); );
} }
+39 -5
View File
@@ -219,6 +219,19 @@ function pctOrDash(v: number | null): string {
// SHARED SUBCOMPONENTS // SHARED SUBCOMPONENTS
// ============================================================================= // =============================================================================
/** Inline note for a section whose own query failed while the rest of the
* hub loaded fine — without this, a failed agentsQ/teamsQ/modelsQ/deliveryQ/
* efficiencyQ silently rendered its zero-backed empty state (e.g. "0% Rework
* rate"), indistinguishable from a real zero. */
function SectionErrorNote({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Warning className="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>Couldn&apos;t load {label}.</span>
</div>
);
}
function ErrorCard({ onRetry }: { onRetry: () => void }) { function ErrorCard({ onRetry }: { onRetry: () => void }) {
return ( return (
<div <div
@@ -383,7 +396,9 @@ function Hub({
/> />
<TgSection title="By agent"> <TgSection title="By agent">
{topAgents.length === 0 ? ( {agentsQ.isError ? (
<SectionErrorNote label="agent spend" />
) : topAgents.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground"> <p className="py-2 text-sm text-muted-foreground">
No agent spend yet. No agent spend yet.
</p> </p>
@@ -418,7 +433,9 @@ function Hub({
</TgSection> </TgSection>
<TgSection title="By team"> <TgSection title="By team">
{teamRows.length === 0 ? ( {teamsQ.isError ? (
<SectionErrorNote label="team spend" />
) : teamRows.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground"> <p className="py-2 text-sm text-muted-foreground">
No team spend yet. No team spend yet.
</p> </p>
@@ -435,7 +452,9 @@ function Hub({
</TgSection> </TgSection>
<TgSection title="By model"> <TgSection title="By model">
{modelRows.length === 0 ? ( {modelsQ.isError ? (
<SectionErrorNote label="model spend" />
) : modelRows.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground"> <p className="py-2 text-sm text-muted-foreground">
No model spend yet. No model spend yet.
</p> </p>
@@ -452,17 +471,26 @@ function Hub({
</TgSection> </TgSection>
<TgSection title="Delivery"> <TgSection title="Delivery">
{deliveryQ.isError ? (
<SectionErrorNote label="delivery metrics" />
) : (
<>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<TgStat <TgStat
value={`${((rework?.rate ?? 0) * 100).toFixed(0)}%`} value={`${((rework?.rate ?? 0) * 100).toFixed(0)}%`}
caption="Rework rate" caption="Rework rate"
tone={(rework?.rate ?? 0) > 0.2 ? "attention" : "default"} tone={(rework?.rate ?? 0) > 0.2 ? "attention" : "default"}
/> />
<TgStat value={rework?.total_completed ?? 0} caption="Completed" /> <TgStat
value={rework?.total_completed ?? 0}
caption="Completed"
/>
<TgStat <TgStat
value={worstStage ? humanizeHours(worstStage.avg_seconds) : "-"} value={worstStage ? humanizeHours(worstStage.avg_seconds) : "-"}
caption={ caption={
worstStage ? humanizeStatus(worstStage.status) : "Slowest stage" worstStage
? humanizeStatus(worstStage.status)
: "Slowest stage"
} }
/> />
<TgStat <TgStat
@@ -482,9 +510,14 @@ function Hub({
))} ))}
</div> </div>
)} )}
</>
)}
</TgSection> </TgSection>
<TgSection title="Efficiency"> <TgSection title="Efficiency">
{efficiencyQ.isError ? (
<SectionErrorNote label="efficiency metrics" />
) : (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<TgStat <TgStat
value={`${((efficiency?.cache.cache_hit_rate ?? 0) * 100).toFixed(0)}%`} value={`${((efficiency?.cache.cache_hit_rate ?? 0) * 100).toFixed(0)}%`}
@@ -510,6 +543,7 @@ function Hub({
} }
/> />
</div> </div>
)}
</TgSection> </TgSection>
</div> </div>
); );
+13 -5
View File
@@ -18,8 +18,9 @@ export const a2aLiveKeys = {
// Conversation list — refreshed by WS `a2a.message` invalidation and the // Conversation list — refreshed by WS `a2a.message` invalidation and the
// manual Refresh button; a short staleTime keeps remounts reasonably fresh. // manual Refresh button; a short staleTime keeps remounts reasonably fresh.
// `refetchInterval` is the caller's poll fallback for when the /ws/system // `refetchInterval` is an unconditional poll the caller drives at a faster
// socket is down (the desktop view gates it on the live-stream connection). // cadence while /ws/system is down (the desktop view: 20s connected / 8s
// disconnected) — it never gates off entirely, only speeds up.
export function useA2AConversations( export function useA2AConversations(
limit?: number, limit?: number,
enabled = true, enabled = true,
@@ -73,9 +74,10 @@ export function useA2AAdminPairs() {
// Transcript for one conversation. WS frames for the selected conversation // Transcript for one conversation. WS frames for the selected conversation
// invalidate this key; full bodies always come from REST (excerpts are capped). // invalidate this key; full bodies always come from REST (excerpts are capped).
// `refetchInterval` defaults to off (the desktop A2A page relies on WS // `refetchInterval` defaults to off; the desktop A2A page passes the same
// invalidation instead) — the /tg Mini App chat tab has no WS wiring, so it // unconditional poll conversations get (20s connected / 8s disconnected —
// passes a ~10s interval to poll the thread it's actively viewing. // never gated off), while the /tg Mini App chat tab gates its own ~10s poll
// off entirely whenever its WS is connected or demo mode is active.
export function useA2AMessages( export function useA2AMessages(
conversationId: string | null, conversationId: string | null,
options?: { refetchInterval?: number | false; enabled?: boolean }, options?: { refetchInterval?: number | false; enabled?: boolean },
@@ -120,6 +122,9 @@ export function useCreateCeoConversation() {
a2aApi.createConversation(request), a2aApi.createConversation(request),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations }); queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.ceoConversations,
});
}, },
}); });
} }
@@ -138,6 +143,9 @@ export function useSendCeoMessage() {
a2aApi.sendCeoMessage(conversationId, content), a2aApi.sendCeoMessage(conversationId, content),
onSuccess: (_sent, variables) => { onSuccess: (_sent, variables) => {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations }); queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.ceoConversations,
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(variables.conversationId), queryKey: a2aLiveKeys.messages(variables.conversationId),
}); });
+11
View File
@@ -77,6 +77,17 @@ export const secretaryApi = {
return data; return data;
}, },
/** Is a Secretary session live right now under ANY session id — lets a
* device with none of its own tell "live elsewhere" apart from "nothing
* running" before it would otherwise auto-start a competing one against
* the single-container singleton. */
isActive: async (): Promise<boolean> => {
const { data } = await api.get<{ active: boolean }>(
"/secretary/live/active",
);
return data.active;
},
/** Deliver the CEO's message to the running Secretary; the reply streams back. */ /** Deliver the CEO's message to the running Secretary; the reply streams back. */
sendMessage: async (sessionId: string, text: string): Promise<void> => { sendMessage: async (sessionId: string, text: string): Promise<void> => {
await api.post(`/secretary/live/${sessionId}/messages`, { text }); await api.post(`/secretary/live/${sessionId}/messages`, { text });
+21
View File
@@ -90,6 +90,27 @@ async def session_status(session_id: str) -> dict[str, bool]:
return {"alive": get_live_registry().is_alive(session_id)} return {"alive": get_live_registry().is_alive(session_id)}
# Mirrors the fixed constant the orchestrator opens every Secretary session
# under (`SECRETARY_AGENT_ID` in roboco/runtime/orchestrator.py — the
# Secretary is a single seeded, persistent singleton, one container at a
# time). Duplicated as a literal rather than imported to keep this route
# decoupled from the orchestrator module.
_SECRETARY_AGENT_ID = "secretary-1"
@router.get("/live/active", dependencies=[Depends(require_panel_token)])
async def is_active() -> dict[str, bool]:
"""Is a Secretary live session running right now, under ANY session id?
Read-only, derived from the registry alone — lets a device with no
session id of its own (e.g. a fresh phone chat) tell "someone else is
already chatting with the Secretary" apart from "nothing is running"
before deciding whether to spawn a competing session against the same
one-container singleton.
"""
return {"active": get_live_registry().has_live_agent(_SECRETARY_AGENT_ID)}
@router.post("/live/{session_id}/messages", dependencies=[Depends(require_panel_token)]) @router.post("/live/{session_id}/messages", dependencies=[Depends(require_panel_token)])
@guard_deco.rate_limit(requests=30, window=60) @guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536) @guard_deco.max_request_size(size_bytes=65536)
+14
View File
@@ -95,6 +95,20 @@ class PrompterLiveRegistry:
def get(self, session_id: str) -> LiveIntakeSession | None: def get(self, session_id: str) -> LiveIntakeSession | None:
return self._sessions.get(session_id) return self._sessions.get(session_id)
def has_live_agent(self, agent_id: str) -> bool:
"""True if any un-closed session is bound to ``agent_id``.
Distinct from ``is_alive`` (which checks a *specific* session id the
caller already holds): this answers "is the singleton agent behind
``agent_id`` live right now, under ANY session id" — e.g. so a device
with no session of its own can tell "a Secretary chat is live
elsewhere" apart from "nothing is running" before deciding whether to
spawn a competing session against the same one-container singleton.
"""
return any(
not s.closed for s in self._sessions.values() if s.agent_id == agent_id
)
def is_alive(self, session_id: str) -> bool: def is_alive(self, session_id: str) -> bool:
"""True when a live, un-closed session exists for this id. """True when a live, un-closed session exists for this id.
+2
View File
@@ -321,6 +321,7 @@ def _make_admin_clone(root: Path, origin: Path) -> Path:
def _build_app(gh: _FakeGitHub) -> FastAPI: def _build_app(gh: _FakeGitHub) -> FastAPI:
from roboco.api.middleware import setup_middleware from roboco.api.middleware import setup_middleware
from roboco.api.routes.dashboard import router as dashboard_router
from roboco.api.routes.health import router as health_router from roboco.api.routes.health import router as health_router
from roboco.api.routes.notifications import router as notifications_router from roboco.api.routes.notifications import router as notifications_router
from roboco.api.routes.orchestrator import router as orchestrator_router from roboco.api.routes.orchestrator import router as orchestrator_router
@@ -353,6 +354,7 @@ def _build_app(gh: _FakeGitHub) -> FastAPI:
# require_panel_token dep paths on these routers. # require_panel_token dep paths on these routers.
app.include_router(orchestrator_router, prefix="/api/orchestrator") app.include_router(orchestrator_router, prefix="/api/orchestrator")
app.include_router(settings_router, prefix="/api/settings") app.include_router(settings_router, prefix="/api/settings")
app.include_router(dashboard_router, prefix="/api/dashboard")
app.include_router(_fake_github_router(gh)) app.include_router(_fake_github_router(gh))
return app return app
@@ -12,6 +12,10 @@ the live uvicorn + middleware + dependency stack — no stubbed deps:
(c) NO credential ``GET /api/settings`` 401 (``require_panel_token``). (c) NO credential ``GET /api/settings`` 401 (``require_panel_token``).
(d) a REAL CEO session cookie (minted via the auth backend's JWT strategy (d) a REAL CEO session cookie (minted via the auth backend's JWT strategy
over a seeded CEO user row) ``GET /api/settings`` 200. over a seeded CEO user row) ``GET /api/settings`` 200.
(e) NO credential ``GET /api/dashboard/ceo`` 401 (the dashboard router's
OWN ``require_panel_token`` gate a distinct router from (c)/(d), added
to close a prior unauthenticated metrics/scorecard exposure).
(f) the same REAL CEO session cookie ``GET /api/dashboard/ceo`` 200.
The running uvicorn app reads the same ``roboco.config.settings`` singleton The running uvicorn app reads the same ``roboco.config.settings`` singleton
per-request, so monkeypatching it live takes effect without a restart. per-request, so monkeypatching it live takes effect without a restart.
@@ -138,6 +142,33 @@ def test_settings_real_ceo_cookie_passes(
) )
def test_dashboard_no_credential_rejected(
e2e_stack: E2EStack, monkeypatch: pytest.MonkeyPatch
) -> None:
_arm_cloud_auth(monkeypatch)
resp = httpx.get(f"{e2e_stack.base_url}/api/dashboard/ceo", timeout=10)
assert resp.status_code == HTTPStatus.UNAUTHORIZED, (
f"dashboard no-credential: expected 401, got "
f"{resp.status_code} {resp.text[:300]}"
)
def test_dashboard_real_ceo_cookie_passes(
e2e_stack: E2EStack, monkeypatch: pytest.MonkeyPatch
) -> None:
_arm_cloud_auth(monkeypatch)
user: UserTable = e2e_stack.run_db(_seed_ceo_user)
cookie = _mint_ceo_session_cookie(user)
resp = httpx.get(
f"{e2e_stack.base_url}/api/dashboard/ceo",
cookies={"roboco_session": cookie},
timeout=10,
)
assert resp.status_code == HTTPStatus.OK, (
f"dashboard real cookie: expected 200, got {resp.status_code} {resp.text[:300]}"
)
def _ceo_agent_id() -> str: def _ceo_agent_id() -> str:
from roboco.agents_config import CEO_AGENT_ID from roboco.agents_config import CEO_AGENT_ID
@@ -158,6 +158,7 @@ async def test_stream_accepts_valid_panel_token(
("GET", "/api/secretary/live/unknown/status", None), ("GET", "/api/secretary/live/unknown/status", None),
("POST", "/api/secretary/live/unknown/messages", {"text": "hi"}), ("POST", "/api/secretary/live/unknown/messages", {"text": "hi"}),
("POST", "/api/secretary/live/sess/stop", None), ("POST", "/api/secretary/live/sess/stop", None),
("GET", "/api/secretary/live/active", None),
], ],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -176,6 +177,22 @@ async def test_status_send_stop_reject_missing_token_when_required(
assert r.status_code == _HTTP_401 assert r.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_is_active_accepts_valid_panel_token_and_reflects_the_registry(
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_strict(monkeypatch)
headers = {"X-Agent-Token": issue_panel_token()}
r = await auth_client.get("/api/secretary/live/active", headers=headers)
assert r.status_code == HTTPStatus.OK
assert r.json() == {"active": False}
prompter_live.get_live_registry().open("some-device", "secretary-1")
r = await auth_client.get("/api/secretary/live/active", headers=headers)
assert r.json() == {"active": True}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_events_ungated_in_strict_mode( async def test_events_ungated_in_strict_mode(
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
+18
View File
@@ -221,5 +221,23 @@ async def test_deliver_to_unknown_or_failing_returns_false() -> None:
await client.aclose() await client.aclose()
def test_has_live_agent_tracks_any_session_for_that_agent() -> None:
"""Backs the "is the Secretary live under ANY session id" check — distinct
from is_alive, which needs the caller's own session id."""
reg = PrompterLiveRegistry()
assert reg.has_live_agent("secretary-1") is False # nothing open yet
reg.open("device-a", "secretary-1")
assert reg.has_live_agent("secretary-1") is True
assert reg.has_live_agent("intake-1") is False # different agent, untouched
reg.close("device-a")
assert reg.has_live_agent("secretary-1") is False # closed -> gone
# A second session id for the SAME agent still counts as live.
reg.open("device-b", "secretary-1")
assert reg.has_live_agent("secretary-1") is True
def test_registry_singleton() -> None: def test_registry_singleton() -> None:
assert get_live_registry() is get_live_registry() assert get_live_registry() is get_live_registry()