mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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's picked here.
|
all per-agent overrides with what'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'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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
startedRef.current = true;
|
if (hasPersistedSecretarySession()) {
|
||||||
start().catch((err) => toast.error(getErrorMessage(err)));
|
startedRef.current = true;
|
||||||
|
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,70 +261,92 @@ function SecretaryView({ onBack }: { onBack: () => void }) {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
{activeElsewhere && !demo && !sessionId ? (
|
||||||
ref={scrollRef}
|
<div className="flex flex-col items-center gap-3 py-10 text-center">
|
||||||
className="max-h-[58dvh] space-y-1.5 overflow-y-auto pb-1"
|
<p className="text-sm text-muted-foreground">
|
||||||
>
|
A Secretary session is live on another device.
|
||||||
{shown.length === 0 && (
|
|
||||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
{demo || sessionId
|
|
||||||
? "Ask anything: company state, queues, directives."
|
|
||||||
: "Waking the Secretary…"}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<button
|
||||||
{shown.map((m, i) => (
|
type="button"
|
||||||
<div
|
onClick={takeOver}
|
||||||
key={i}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex",
|
"rounded-full bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground",
|
||||||
m.role === "user" ? "justify-end" : "justify-start",
|
TG_PRESS,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
Take over
|
||||||
className={cn(
|
</button>
|
||||||
"max-w-[82%] rounded-2xl px-3.5 py-2 text-[15px] leading-relaxed",
|
</div>
|
||||||
m.role === "user"
|
) : (
|
||||||
? "rounded-br-md bg-primary text-primary-foreground"
|
<>
|
||||||
: "rounded-bl-md bg-card",
|
<div
|
||||||
)}
|
ref={scrollRef}
|
||||||
>
|
className="max-h-[58dvh] space-y-1.5 overflow-y-auto pb-1"
|
||||||
{m.role === "user" ? (
|
>
|
||||||
<p className="whitespace-pre-wrap break-words">{m.text}</p>
|
{shown.length === 0 && (
|
||||||
) : (
|
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||||
<Markdown
|
{demo || sessionId
|
||||||
compact
|
? "Ask anything: company state, queues, directives."
|
||||||
className="prose prose-sm prose-invert max-w-none [&_p]:my-1 first:[&_p]:mt-0 last:[&_p]:mb-0"
|
: "Waking the Secretary…"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{shown.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
m.role === "user" ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[82%] rounded-2xl px-3.5 py-2 text-[15px] leading-relaxed",
|
||||||
|
m.role === "user"
|
||||||
|
? "rounded-br-md bg-primary text-primary-foreground"
|
||||||
|
: "rounded-bl-md bg-card",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{m.text || "…"}
|
{m.role === "user" ? (
|
||||||
</Markdown>
|
<p className="whitespace-pre-wrap break-words">
|
||||||
)}
|
{m.text}
|
||||||
</div>
|
</p>
|
||||||
|
) : (
|
||||||
|
<Markdown
|
||||||
|
compact
|
||||||
|
className="prose prose-sm prose-invert max-w-none [&_p]:my-1 first:[&_p]:mt-0 last:[&_p]:mb-0"
|
||||||
|
>
|
||||||
|
{m.text || "…"}
|
||||||
|
</Markdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streaming && shown[shown.length - 1]?.role === "user" && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="rounded-2xl rounded-bl-md bg-card px-3.5 py-2.5">
|
||||||
|
<span className="inline-flex gap-1">
|
||||||
|
{[0, 1, 2].map((d) => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
||||||
|
style={{ animationDelay: `${d * 150}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Composer
|
||||||
{streaming && shown[shown.length - 1]?.role === "user" && (
|
placeholder={demo ? "Demo mode, sends disabled" : "Message…"}
|
||||||
<div className="flex justify-start">
|
pending={streaming}
|
||||||
<div className="rounded-2xl rounded-bl-md bg-card px-3.5 py-2.5">
|
disabled={demo || (!demo && !sessionId)}
|
||||||
<span className="inline-flex gap-1">
|
onSend={(text) => {
|
||||||
{[0, 1, 2].map((d) => (
|
void send(text).catch((err) => toast.error(getErrorMessage(err)));
|
||||||
<span
|
}}
|
||||||
key={d}
|
/>
|
||||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
</>
|
||||||
style={{ animationDelay: `${d * 150}ms` }}
|
)}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Composer
|
|
||||||
placeholder={demo ? "Demo mode, sends disabled" : "Message…"}
|
|
||||||
pending={streaming}
|
|
||||||
disabled={demo || (!demo && !sessionId)}
|
|
||||||
onSend={(text) => {
|
|
||||||
void send(text).catch((err) => toast.error(getErrorMessage(err)));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TgSubPage>
|
</TgSubPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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,64 +471,79 @@ function Hub({
|
|||||||
</TgSection>
|
</TgSection>
|
||||||
|
|
||||||
<TgSection title="Delivery">
|
<TgSection title="Delivery">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{deliveryQ.isError ? (
|
||||||
<TgStat
|
<SectionErrorNote label="delivery metrics" />
|
||||||
value={`${((rework?.rate ?? 0) * 100).toFixed(0)}%`}
|
) : (
|
||||||
caption="Rework rate"
|
<>
|
||||||
tone={(rework?.rate ?? 0) > 0.2 ? "attention" : "default"}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
/>
|
<TgStat
|
||||||
<TgStat value={rework?.total_completed ?? 0} caption="Completed" />
|
value={`${((rework?.rate ?? 0) * 100).toFixed(0)}%`}
|
||||||
<TgStat
|
caption="Rework rate"
|
||||||
value={worstStage ? humanizeHours(worstStage.avg_seconds) : "-"}
|
tone={(rework?.rate ?? 0) > 0.2 ? "attention" : "default"}
|
||||||
caption={
|
/>
|
||||||
worstStage ? humanizeStatus(worstStage.status) : "Slowest stage"
|
<TgStat
|
||||||
}
|
value={rework?.total_completed ?? 0}
|
||||||
/>
|
caption="Completed"
|
||||||
<TgStat
|
/>
|
||||||
value={fmtUsd(rework?.rework_cost_usd ?? 0)}
|
<TgStat
|
||||||
caption="Rework cost"
|
value={worstStage ? humanizeHours(worstStage.avg_seconds) : "-"}
|
||||||
/>
|
caption={
|
||||||
</div>
|
worstStage
|
||||||
{bounced.length > 0 && (
|
? humanizeStatus(worstStage.status)
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
: "Slowest stage"
|
||||||
{bounced.map((a) => (
|
}
|
||||||
<span
|
/>
|
||||||
key={a.agent_slug}
|
<TgStat
|
||||||
className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
|
value={fmtUsd(rework?.rework_cost_usd ?? 0)}
|
||||||
>
|
caption="Rework cost"
|
||||||
{getAgentDisplayName(a.agent_slug)} · {a.bounces} bounces
|
/>
|
||||||
</span>
|
</div>
|
||||||
))}
|
{bounced.length > 0 && (
|
||||||
</div>
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{bounced.map((a) => (
|
||||||
|
<span
|
||||||
|
key={a.agent_slug}
|
||||||
|
className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{getAgentDisplayName(a.agent_slug)} · {a.bounces} bounces
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TgSection>
|
</TgSection>
|
||||||
|
|
||||||
<TgSection title="Efficiency">
|
<TgSection title="Efficiency">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{efficiencyQ.isError ? (
|
||||||
<TgStat
|
<SectionErrorNote label="efficiency metrics" />
|
||||||
value={`${((efficiency?.cache.cache_hit_rate ?? 0) * 100).toFixed(0)}%`}
|
) : (
|
||||||
caption="Cache hit rate"
|
<div className="grid grid-cols-2 gap-3">
|
||||||
/>
|
<TgStat
|
||||||
<TgStat
|
value={`${((efficiency?.cache.cache_hit_rate ?? 0) * 100).toFixed(0)}%`}
|
||||||
value={fmtUsd(efficiency?.cache.cost_saved_by_cache_usd ?? 0)}
|
caption="Cache hit rate"
|
||||||
caption="Saved by cache"
|
/>
|
||||||
/>
|
<TgStat
|
||||||
<TgStat
|
value={fmtUsd(efficiency?.cache.cost_saved_by_cache_usd ?? 0)}
|
||||||
value={fmtUsd(
|
caption="Saved by cache"
|
||||||
efficiency?.projection.projected_monthly_cost_usd ?? 0,
|
/>
|
||||||
)}
|
<TgStat
|
||||||
caption="Projected monthly"
|
value={fmtUsd(
|
||||||
/>
|
efficiency?.projection.projected_monthly_cost_usd ?? 0,
|
||||||
<TgStat
|
)}
|
||||||
value={`${(efficiency?.spawnWaste.unproductive_pct ?? 0).toFixed(0)}%`}
|
caption="Projected monthly"
|
||||||
caption="Spawn waste"
|
/>
|
||||||
tone={
|
<TgStat
|
||||||
(efficiency?.spawnWaste.unproductive_pct ?? 0) > 25
|
value={`${(efficiency?.spawnWaste.unproductive_pct ?? 0).toFixed(0)}%`}
|
||||||
? "attention"
|
caption="Spawn waste"
|
||||||
: "default"
|
tone={
|
||||||
}
|
(efficiency?.spawnWaste.unproductive_pct ?? 0) > 25
|
||||||
/>
|
? "attention"
|
||||||
</div>
|
: "default"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TgSection>
|
</TgSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user