diff --git a/panel/src/components/tasks/__tests__/acceptance-criteria-editor.test.tsx b/panel/src/components/tasks/__tests__/acceptance-criteria-editor.test.tsx
new file mode 100644
index 00000000..18b825ec
--- /dev/null
+++ b/panel/src/components/tasks/__tests__/acceptance-criteria-editor.test.tsx
@@ -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(
+
,
+ );
+
+ 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(
+
,
+ );
+
+ 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(
+
,
+ );
+
+ 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();
+ });
+});
diff --git a/panel/src/components/tasks/acceptance-criteria-editor.tsx b/panel/src/components/tasks/acceptance-criteria-editor.tsx
index b5ed01a2..2c39321c 100644
--- a/panel/src/components/tasks/acceptance-criteria-editor.tsx
+++ b/panel/src/components/tasks/acceptance-criteria-editor.tsx
@@ -18,16 +18,23 @@ interface AcceptanceCriteriaEditorProps {
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({
criteria,
onChange,
error,
}: AcceptanceCriteriaEditorProps) {
const [newCriterion, setNewCriterion] = useState("");
+ const atMax = criteria.length >= MAX_CRITERIA;
const handleAdd = () => {
const trimmed = newCriterion.trim();
- if (trimmed && !criteria.includes(trimmed)) {
+ if (trimmed && !criteria.includes(trimmed) && !atMax) {
onChange([...criteria, trimmed]);
setNewCriterion("");
}
@@ -59,9 +66,10 @@ export function AcceptanceCriteriaEditor({
Acceptance Criteria
*
-
+
- {criteria.length} item{criteria.length !== 1 ? "s" : ""}
+ {criteria.length}/{MAX_CRITERIA} item
+ {criteria.length !== 1 ? "s" : ""}
@@ -103,25 +111,42 @@ export function AcceptanceCriteriaEditor({
{/* Add new criterion */}