Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions frontend/e2e/live/new-run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,60 @@ test("a free-trial account starts on a budget the free credits cover, and is tol
await page.getByLabel("Budget (USDC)").fill("5");
await expect(page.getByText("Free credits cover up to 2.00 USDC per plan; this plan is 5.00 USDC")).toBeVisible();
});

test("what was typed is still there after visiting another page, until the plan is drafted or the form is cleared", async ({
page,
}) => {
await mockBackend(page);
const calls = await mockPlanApi(page, { created: draftPlan() });
await fakeGitHub(page);
await page.goto("/app/runs/new");
await page.getByLabel("Goal").fill("The refund test passes again");
await page.getByRole("combobox", { name: "Repository" }).fill("someone/else");
await page.getByRole("radio", { name: "Docs" }).check();
await page.getByLabel("Budget (USDC)").fill("2");

// Away to another tab of the app and back, without a reload.
await page.getByRole("navigation").getByRole("link", { name: "Agents" }).click();
await expect(page).toHaveURL(/\/app\/agents$/);
// The Agents page loads lazily; until it shows, the form has not left the screen, so wait for it.
await expect(page.getByRole("heading", { name: "Agents", level: 1 })).toBeVisible();
await page.goBack();
await expect(page.getByLabel("Goal")).toHaveValue("The refund test passes again");
await expect(page.getByRole("combobox", { name: "Repository" })).toHaveValue("someone/else");
await expect(page.getByRole("radio", { name: "Docs" })).toBeChecked();
await expect(page.getByLabel("Budget (USDC)")).toHaveValue("2");
await expect(page.getByTestId("draft-restored")).toBeVisible();

// "Clear the form" starts over, and a reload keeps it empty.
await page.getByRole("button", { name: "Clear the form" }).click();
await expect(page.getByLabel("Goal")).toHaveValue("");
await expect(page.getByRole("radio", { name: "Fix failing tests" })).toBeChecked();
await page.reload();
await expect(page.getByLabel("Goal")).toHaveValue("");
await expect(page.getByTestId("draft-restored")).toHaveCount(0);

// Once the plan is drafted, the form is empty the next time.
await page.getByLabel("Goal").fill("The build passes on develop");
await page.getByRole("combobox", { name: "Repository" }).fill("someone/else");
await expect(page.getByRole("combobox", { name: "Branch" })).toHaveValue("develop");
await page.getByRole("button", { name: "Draft the plan" }).click();
await expect(page).toHaveURL(new RegExp(`/app/runs/${draftPlan().plan_uuid}$`));
expect(calls.filter((c) => c.method === "POST" && c.path === "/v0/plans")).toHaveLength(1);
await page.goto("/app/runs/new");
await expect(page.getByLabel("Goal")).toHaveValue("");
});

test("each kind of work says what it means, and the line follows the one picked", async ({ page }) => {
await mockBackend(page);
await mockPlanApi(page);
await fakeGitHub(page);
await page.goto("/app/runs/new");
const hint = page.getByTestId("kind-hint");
await expect(hint).toContainText("Fix failing tests:");
await expect(hint).toContainText("which agents are suggested");
await page.getByRole("radio", { name: "Security" }).check();
await expect(hint).toContainText("Security:");
await expect(hint).toContainText("a test that tries it");
await expect(page.getByRole("group", { name: "Kind of work" })).toHaveAccessibleDescription(/Security:/);
});
6 changes: 3 additions & 3 deletions frontend/e2e/live/overview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ test("the overview shows real credits, balances and runs", async ({ page }) => {

test("the agents page lists the deployed agents with their on-chain operator", async ({ page }) => {
await page.goto("/app/agents");
const rows = page.locator("tbody tr");
await expect(rows).not.toHaveCount(0);
await expect(rows.first()).toContainText("0x1111…1111");
const cards = page.locator('[data-testid^="agent-"]');
await expect(cards).not.toHaveCount(0);
await expect(cards.first()).toContainText("0x1111…1111");
});

test("the live header offers no control that is not wired up: no Search, no Notifications bell", async ({ page }) => {
Expand Down
17 changes: 17 additions & 0 deletions frontend/e2e/live/run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,20 @@ test("the agents list shows a validated record only where there is one, and says
await expect(page.getByTestId("record-1")).toHaveText("No validated outcomes yet");
await expect(page.getByText("configuration, not a measurement")).toBeVisible();
});

test("the agents list shows no figures for an agent whose rows are only the prior (0 validated)", async ({ page }) => {
await mockBackend(page);
const prior = {
...SEASONED,
agent_id: "1",
name: "coder-a",
basis: "prior (no counted outcomes)",
counted_outcomes: 0,
};
await mockPlanApi(page, { reputation: [prior, SEASONED] });
await page.goto("/app/agents");

await expect(page.getByTestId("record-2")).toContainText("7 validated");
await expect(page.getByTestId("record-1")).toHaveText("No validated outcomes yet");
await expect(page.getByTestId("record-1")).not.toContainText("% pass");
});
3 changes: 3 additions & 0 deletions frontend/src/components/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export function TopNav() {
to={tab.to}
className={`tab${active === tab.to ? " on" : ""}`}
aria-current={active === tab.to ? "page" : undefined}
// Below 1100px only the icon shows (shell.css hides .tab-label), so the name is given here too.
aria-label={tab.label}
title={tab.label}
>
<Icon name={tab.icon} size={15} />
<span className="tab-label">{tab.label}</span>
Expand Down
141 changes: 80 additions & 61 deletions frontend/src/screens/live/LiveAgents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { Address } from "viem";
import { useAgents } from "../../api/useAgents";
import { byAgent, percent, usdc } from "../../api/reputation";
import { useReputation } from "../../api/useReputation";
import type { AgentInfo } from "../../api/types";
import type { AgentInfo, AgentReputation } from "../../api/types";
import { identityAbi } from "../../chain/abi";
import { publicClient } from "../../chain/client";
import { EnsIdentity } from "../../components/EnsIdentity";
import { PageHeader } from "../../components/PageHeader";
import { LIVE } from "../../config/live";
import "../../styles/screens/agents.css";

type Onchain = { operator: Address; runtimeKey: Address };

Expand All @@ -26,6 +27,20 @@ function AddressLink({ address }: { address: Address }) {
);
}

/** An address read from the Identity contract: a link once read, "unavailable" if the read failed, "…" before. */
function ChainAddress({ value, pick }: { value: Onchain | null | undefined; pick: (c: Onchain) => Address }) {
if (value === undefined) return <span className="c3">…</span>;
if (value === null) return <span className="c3">unavailable</span>;
return <AddressLink address={pick(value)} />;
}

/**
* Only categories with counted outcomes. A row with none holds the prior, which is configuration and not a measurement
* (CLAUDE.md: no number may be presented as measured), so it is not shown as a record.
*/
const validated = (rows: AgentReputation[] | undefined): AgentReputation[] =>
(rows ?? []).filter((r) => r.counted_outcomes > 0);

/** Who owns each agent and which key signs its receipts, read from the Identity contract. */
function useOnchain(agents: AgentInfo[] | null) {
const [info, setInfo] = useState<Record<string, Onchain | null>>({});
Expand Down Expand Up @@ -76,66 +91,70 @@ export function LiveAgents() {
title="Agents"
subtitle={`The agents you can pick for a task. Owner and signing key are read from the Identity contract on ${LIVE.chainName}.`}
/>
<section className="card pad24">
{error && <p className="c2">{error}</p>}
{!error && agents === null && <p className="c2">Loading agents…</p>}
{agents?.length === 0 && <p className="c2">No agents are deployed yet.</p>}
{agents && agents.length > 0 && (
<table className="ledger-table">
<thead>
<tr>
<th scope="col">Agent</th>
<th scope="col">ENS name</th>
<th scope="col">Model</th>
<th scope="col">Operator (paid)</th>
<th scope="col">Receipt signing key</th>
<th scope="col">Validated record</th>
</tr>
</thead>
<tbody>
{agents.map((a) => {
const chain = onchain[a.agent_id];
return (
<tr key={a.agent_id}>
<td>
<span className="strong">{a.name}</span> <span className="mono c3">#{a.agent_id}</span>
</td>
<td>
<EnsIdentity agentId={a.agent_id} name={a.name} />
</td>
<td className="mono">{a.model}</td>
<td>{chain ? <AddressLink address={chain.operator} /> : chain === null ? "unavailable" : "…"}</td>
<td>{chain ? <AddressLink address={chain.runtimeKey} /> : chain === null ? "unavailable" : "…"}</td>
<td data-testid={`record-${a.agent_id}`}>
{reputation === null ? (
"…"
) : (seen.get(a.agent_id) ?? []).length === 0 ? (
<span className="c3">No validated outcomes yet</span>
) : (
<div className="col g4">
{(seen.get(a.agent_id) ?? []).map((r) => (
<span key={r.category} className="small">
<span className="mono">{r.category}</span>: {percent(r.success_mean)} pass, about{" "}
{usdc(r.cost_mean_micros)} USDC a task, {r.value_density_per_usdc.toFixed(2)} passes per
USDC <span className="c3">({String(r.counted_outcomes)} validated)</span>
</span>
))}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
{reputation && (
<p className="small c3">
{reputation.note} Until an agent has validated outcomes, no figures are shown for it here: it would only be
a configured starting point, not a record.
</p>
)}
</section>
{error && <p className="c2">{error}</p>}
{!error && agents === null && <p className="c2">Loading agents…</p>}
{agents?.length === 0 && <p className="c2">No agents are deployed yet.</p>}
{agents && agents.length > 0 && (
<ul className="ag-grid la-grid">
{agents.map((a) => {
const chain = onchain[a.agent_id];
const record = validated(seen.get(a.agent_id));
return (
<li key={a.agent_id} className="card ag-card la-card" data-testid={`agent-${a.agent_id}`}>
<div className="row g12 la-head">
<div className="col min0 grow">
<h3 className="title la-name">{a.name}</h3>
<span className="mono small c3">Agent #{a.agent_id}</span>
</div>
<span className="mono small la-model" title={a.model}>
{a.model}
</span>
</div>

<EnsIdentity agentId={a.agent_id} name={a.name} showReason />

<dl className="la-facts">
<dt className="small c3">Owner (paid)</dt>
<dd>
<ChainAddress value={chain} pick={(c) => c.operator} />
</dd>
<dt className="small c3">Signs receipts</dt>
<dd>
<ChainAddress value={chain} pick={(c) => c.runtimeKey} />
</dd>
</dl>

<div className="col g6 la-record" data-testid={`record-${a.agent_id}`}>
{reputation === null ? (
<span className="small c3">…</span>
) : record.length === 0 ? (
<span className="small c3">No validated outcomes yet</span>
) : (
record.map((r) => (
<div key={r.category} className="col g4 la-record-row">
<span className="row between g8 small">
<span className="mono strong">{r.category}</span>
<span className="c3">{String(r.counted_outcomes)} validated</span>
</span>
<span className="small c2">
{percent(r.success_mean)} pass · about {usdc(r.cost_mean_micros)} USDC a task ·{" "}
{r.value_density_per_usdc.toFixed(2)} passes per USDC
</span>
</div>
))
)}
</div>
</li>
);
})}
</ul>
)}
{reputation && (
<p className="small c3 la-note">
{reputation.note} Until an agent has validated outcomes, no figures are shown for it here: they would only be
a configured starting point, not a record.
</p>
)}
</>
);
}
64 changes: 54 additions & 10 deletions frontend/src/screens/live/run/LiveNewRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { formatUsdc, parseUsdc, usdcText } from "../../../chain/funding";
import { useCredits } from "../../../chain/useCredits";
import { Icon, type IconName } from "../../../components/Icon";
import { Combo, type ComboOption } from "./Combo";
import { fieldProblems, GOAL_LIMIT, INITIAL, KINDS, STARTERS, toRepoUrl, type Fields } from "./newRunForm";
import { fieldProblems, GOAL_LIMIT, INITIAL, KIND_EFFECT, KINDS, STARTERS, toRepoUrl, type Fields } from "./newRunForm";
import { clearDraft, draftKey, loadDraft, saveDraft, type NewRunDraft } from "./newRunDraft";
import { Problems } from "./Problems";
import {
applyPrefill,
Expand Down Expand Up @@ -44,7 +45,42 @@ const STEPS: { icon: IconName; label: string }[] = [
export function LiveNewRun() {
const navigate = useNavigate();
const { account, session } = useAuth();
const [fields, setFields] = useState<Fields>(INITIAL);
// "Start again": an earlier plan named in the URL only fills fields that are still empty. It is read, never
// changed, and the form is still submitted, drafted, reviewed, approved and funded like any other.
const [searchParams] = useSearchParams();
const from = sourcePlanId(searchParams);
// What was typed here before going to another page comes back (newRunDraft.ts), except under "Start again", which
// fills the form from the earlier plan instead.
const key = draftKey(session?.address);
const [restored] = useState<NewRunDraft | null>(() => (from === null ? loadDraft(key) : null));
const [fields, setFields] = useState<Fields>(restored?.fields ?? INITIAL);
const [budgetTouched, setBudgetTouched] = useState(restored?.budgetTouched ?? false);
// A repository typed rather than picked starts on its own default branch, until a branch is chosen by hand.
const [refTouched, setRefTouched] = useState(restored?.refTouched ?? false);
const [showRestored, setShowRestored] = useState(restored !== null);
// Another account signing in on this tab gets its own draft (or an empty form), never the previous one's.
const [draftFor, setDraftFor] = useState(key);
if (draftFor !== key) {
setDraftFor(key);
const next = from === null ? loadDraft(key) : null;
setFields(next?.fields ?? INITIAL);
setBudgetTouched(next?.budgetTouched ?? false);
setRefTouched(next?.refTouched ?? false);
setShowRestored(next !== null);
}

useEffect(() => {
saveDraft(key, { fields, refTouched, budgetTouched });
}, [key, fields, refTouched, budgetTouched]);

const clearForm = () => {
clearDraft(key);
setFields(INITIAL);
setBudgetTouched(false);
setRefTouched(false);
setShowRestored(false);
document.getElementById("nr-goal")?.focus();
};
const { busy, problems, setProblems, submit } = useSubmit();
// One per page load: the same form sent twice (a retry, a second click) is the same plan.
const [nonce] = useState(() => crypto.randomUUID());
Expand All @@ -55,7 +91,6 @@ export function LiveNewRun() {
// A free-trial account can only pay from credits, which cover a plan up to a cap: start there, and say so if
// the budget goes over, instead of letting the plan be drafted and then refused at approval.
const trial = isTrialSession(account, session?.address);
const [budgetTouched, setBudgetTouched] = useState(false);
const startBudget = trial ? creditBudget(credits) : null;
// Until the budget is edited by hand, it is the credit-covered amount once the credits are known.
const budget = !budgetTouched && startBudget !== null ? usdcText(startBudget) : fields.budget;
Expand All @@ -77,12 +112,6 @@ export function LiveNewRun() {
const { repos, error: reposError, loading: reposLoading } = useRepoList(owner);
const chosen = parseRepo(fields.repo);
const { branches, defaultBranch, error: branchesError, loading: branchesLoading } = useBranches(chosen);
// A repository typed rather than picked starts on its own default branch, until a branch is chosen by hand.
const [refTouched, setRefTouched] = useState(false);
// "Start again": an earlier plan named in the URL only fills fields that are still empty. It is read, never
// changed, and the form is still submitted, drafted, reviewed, approved and funded like any other.
const [searchParams] = useSearchParams();
const from = sourcePlanId(searchParams);
const [prefillNote, setPrefillNote] = useState<{ from: string; text: string } | null>(null);
const prefillLoading = from !== null && prefillNote?.from !== from;

Expand Down Expand Up @@ -204,6 +233,7 @@ export function LiveNewRun() {
category: fields.category,
};
const plan = await createPlan(request, planKey(nonce, request));
clearDraft(key);
await navigate(paths.run(plan.plan_uuid));
});
};
Expand Down Expand Up @@ -315,19 +345,28 @@ export function LiveNewRun() {
</p>
)}
<Problems items={problems} />
{showRestored && (
<p className="small c2 nr-center" role="status" data-testid="draft-restored">
Your unsent run from earlier in this tab is filled back in.{" "}
<button type="button" className="link small" onClick={clearForm}>
Clear the form
</button>
</p>
)}
{from !== null && (
<p className="small c2 nr-center" role="status">
{prefillLoading ? "Loading your earlier plan…" : prefillNote?.text}
</p>
)}

<fieldset className="nr-kinds">
<fieldset className="nr-kinds" aria-describedby="nr-kind-hint">
<legend className="sr-only">Kind of work</legend>
{KINDS.map((k, i) => (
<label
key={k.value}
className={`nr-kind rise${fields.category === k.value ? " on" : ""}`}
style={{ animationDelay: `${String(100 + i * 40)}ms` }}
title={k.hint}
>
<input
type="radio"
Expand All @@ -343,6 +382,11 @@ export function LiveNewRun() {
</label>
))}
</fieldset>
{kind && (
<p id="nr-kind-hint" className="small c2 nr-kind-hint" aria-live="polite" data-testid="kind-hint">
<span className="strong">{kind.label}:</span> {kind.hint} <span className="c3">{KIND_EFFECT}</span>
</p>
)}

<div className="nr-starters">
{STARTERS.map((s, i) => {
Expand Down
Loading
Loading