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
47 changes: 47 additions & 0 deletions app/e2e/dashboard-turn-check.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect, mockOnly, test } from "./fixtures";

// Turn check marks a finished turn whose work still runs without the agent
// (a build, a deploy, a watcher) as unfinished, and one that waits on
// someone else's reply or a later time with nothing running as parked: the
// board shows the first working and the second idle, so Done holds only
// work that is done.
test("an agent waiting on its own work sits in Working, a parked one in Idle, neither in Done", async ({ app }) => {
mockOnly("the unfinished and parked agents are mock fixtures");
await app.open({ prefs: { labs: false, labsChosen: true, version: 3 } });
await app.page.getByTestId("nav-home").click();
const working = app.page.getByRole("region", { name: "Working" });
const idle = app.page.getByRole("region", { name: "Idle" });
const done = app.page.getByRole("region", { name: "Done" });
const card = working.locator("article", { hasText: "Add tests for the export job" });
await expect(card).toBeVisible();
await expect(card.getByText("Waiting on its work")).toBeVisible();
await expect(done.locator("article", { hasText: "Add tests for the export job" })).toHaveCount(0);
// Parked until a reply: idle, not working and not done.
const parked = idle.locator("article", { hasText: "Rerun the evals once the vendor replies" });
await expect(parked).toBeVisible();
await expect(parked.getByText("Parked: waiting on a reply or a later time")).toBeVisible();
await expect(working.locator("article", { hasText: "Rerun the evals once the vendor replies" })).toHaveCount(0);
await expect(done.locator("article", { hasText: "Rerun the evals once the vendor replies" })).toHaveCount(0);
// A finished one that is done stays in Done, without either marker.
const finished = done.locator("article", { hasText: "Speed up product search" });
await expect(finished).toBeVisible();
await expect(finished.getByText("Waiting on its work")).toHaveCount(0);
await expect(finished.getByText("Parked:")).toHaveCount(0);
await app.page.screenshot({ path: test.info().outputPath("dashboard.png") });
});

test("clean up never ticks a parked agent for you, however long it has sat", async ({ app }) => {
mockOnly("the parked agent is a mock fixture");
await app.open({ prefs: { labs: false, labsChosen: true, version: 3 } });
await app.page.getByTestId("nav-home").click();
await app.page.getByRole("button", { name: "Clean up…" }).click();
const dialog = app.page.getByRole("alertdialog");
// Idle three hours: over the shortest choice, as the agent beside it.
await dialog.getByRole("group", { name: "Idle longer than" }).getByText("1 hour").click();
const row = dialog.locator("li", { hasText: "parked" });
await expect(row).toHaveCount(1);
await expect(row.getByRole("checkbox")).not.toBeChecked();
// Ticked by hand, it goes like any other.
await row.getByRole("checkbox").click();
await expect(row.getByRole("checkbox")).toBeChecked();
});
51 changes: 51 additions & 0 deletions app/e2e/settings-turn-check.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, mockOnly, test } from "./fixtures";

// Turn check picks no provider or model for you: it can't be turned on until
// you choose who checks turns and give it what it needs.
test("turn check turns on only once you pick a provider and save its key", async ({ app }) => {
mockOnly("turn check's settings are a mock box's");
await app.open();
const devl = (await app.openSettings("agents")).getByTestId("turncheck-devl");
const toggle = devl.getByRole("switch", { name: "Check finished turns on devl" });
await expect(toggle).toBeDisabled();
await expect(devl.getByText("Choose who checks turns")).toBeVisible();

await devl.getByRole("button", { name: "OpenAI Decisions" }).click();
// Picking a provider fills in the model it serves; it is yours to change.
await expect(devl.getByRole("textbox", { name: "Model that checks turns on devl" })).toHaveValue("gpt-6-luna");
await expect(devl.getByText("Save a key first")).toBeVisible();
await expect(toggle).toBeDisabled();

const key = devl.getByLabel("OpenAI key for devl");
await key.fill("sk-example");
await key.locator("xpath=following::button[1]").click();
await expect(devl.getByText("OpenAI key saved on devl")).toBeVisible();
await toggle.click();
await expect(toggle).toBeChecked();

// Another provider is a new place turns go: off again, and OpenAI's key isn't its key.
await devl.getByRole("button", { name: "Jev on AI Gateway" }).click();
await expect(toggle).not.toBeChecked();
await expect(devl.getByRole("textbox", { name: "Model that checks turns on devl" })).toHaveValue("typesafe-ai/jev");
await expect(devl.getByText("Save a key first")).toBeVisible();
await app.page.screenshot({ path: test.info().outputPath("settings-turn-check.png") });
});

test("turn check with Laya needs only your server's address", async ({ app }) => {
mockOnly("turn check's settings are a mock box's");
await app.open();
const devl = (await app.openSettings("agents")).getByTestId("turncheck-devl");
const toggle = devl.getByRole("switch", { name: "Check finished turns on devl" });
await devl.getByRole("button", { name: "Laya, your own server" }).click();
await expect(devl.getByText("Set your Laya server's address")).toBeVisible();
await expect(devl.getByLabel("Paste your key")).toHaveCount(0);
await expect(toggle).toBeDisabled();

const server = devl.getByRole("textbox", { name: "Laya server on devl" });
await server.fill("http://127.0.0.1:8770/v1/systemone");
await server.locator("xpath=following::button[1]").click();
await expect(devl.getByText("http://127.0.0.1:8770/v1/systemone")).toBeVisible();
await toggle.click();
await expect(toggle).toBeChecked();
await app.page.screenshot({ path: test.info().outputPath("settings-turn-check-laya.png") });
});
19 changes: 18 additions & 1 deletion app/src/lib/derive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { firstPrompt, restartCommand } from "./derive.ts";
import type { Session } from "@berth/plugin";

import { firstPrompt, restartCommand, sessionState } from "./derive.ts";

test("a preset session's first prompt is read back from its command", () => {
assert.equal(firstPrompt({ preset: "claude", command: "claude --model haiku 'fix the flaky test'" }), "fix the flaky test");
Expand All @@ -17,3 +19,18 @@ next line'` }), `it's "done"\nnext line`);
// What restartCommand drops is the same prompt.
assert.equal(restartCommand("claude --model haiku 'fix it'"), "claude --model haiku");
});

test("a finished turn whose work goes on without the agent shows as working, not done", () => {
const s = { name: "a", dir: "/w", created: "2026-10-12T10:00:00Z", attached: 0, exited: false, agent: "claude", agent_state: "finished" } as Session;
assert.equal(sessionState(s), "finished");
assert.equal(sessionState({ ...s, unfinished: true }), "running");
// Only a finished turn: a question still needs you.
assert.equal(sessionState({ ...s, agent_state: "waiting", unfinished: true }), "waiting");
});

test("a finished turn parked until a reply or a later time shows as idle, not done or working", () => {
const s = { name: "a", dir: "/w", created: "2026-10-12T10:00:00Z", attached: 0, exited: false, agent: "claude", agent_state: "finished", parked: true } as Session;
assert.equal(sessionState(s), "ready");
assert.equal(sessionState({ ...s, agent_state: "waiting" }), "waiting");
assert.equal(sessionState({ ...s, agent_state: "running" }), "running");
});
6 changes: 5 additions & 1 deletion app/src/lib/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { Location, Session, Stats, Worktree } from "@/lib/api";

// What the app shows for a session: the agent's last reported state ("ready"
// is an agent open with nothing to do yet), "exited" once the program ended,
// or "idle" for a plain shell.
// or "idle" for a plain shell. A finished turn that turn check read as work
// still running (a build, a deploy, a watcher) shows as running, and one it
// read as parked until a reply or a later time as ready (Idle).
export type SessionState = "ready" | "running" | "waiting" | "finished" | "exited" | "idle";

export const AGENTS = ["claude", "codex", "opencode", "gemini", "pi", "omp", "cursor-agent"];
Expand All @@ -19,6 +21,8 @@ export function sessionState(s: Session, stats?: Stats): SessionState {
if (s.exited) return "exited";
// Never another agent's state in the same worktree.
if (s.service) return "idle";
if (s.agent_state === "finished" && s.unfinished) return "running";
if (s.agent_state === "finished" && s.parked) return "ready";
if (s.agent_state) return s.agent_state === "idle" ? "ready" : s.agent_state;
// The box's report is per folder: a state from before this session began
// is another agent's in the same worktree, not this one's (a new agent
Expand Down
21 changes: 21 additions & 0 deletions app/src/lib/mock-turncheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { TurnCheckChange, TurnCheckProvider, TurnCheckStatus } from "@/lib/turncheck";

// Turn check in demo mode: off on every box until turned on, with a key
// kept per provider, as on a box.

const state: Record<string, { cfg: TurnCheckStatus; keys: Partial<Record<TurnCheckProvider, boolean>> }> = {};

export function turnCheckCall(box: string, method: string, path: string, body: unknown, delay: <T>(v: T) => Promise<T>): Promise<unknown> | undefined {
if (path !== "turncheck") return undefined;
const st = (state[box] ??= { cfg: { enabled: false, key_set: false }, keys: {} });
if (method === "PUT") {
const { key_value, remove_key, ...cfg } = body as TurnCheckChange;
const p = cfg.provider;
// The last one set wins, as on a box, and a reference stays with its provider.
const key = key_value || (p !== st.cfg.provider && cfg.key === st.cfg.key) ? undefined : cfg.key;
if (p && (key_value || remove_key || key)) st.keys[p] = !!key_value;
st.cfg = { ...cfg, key, key_set: false };
}
if (method === "GET" || method === "PUT") return delay({ ...st.cfg, key_set: !!(st.cfg.provider && st.keys[st.cfg.provider]) });
return undefined;
}
7 changes: 6 additions & 1 deletion app/src/lib/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mockRequirements } from "@/lib/mock-requirements";
import { browserCall, mockScreencast, mockShotSvg, wireBrowserMock } from "@/lib/mock-browser";
import { mockDevtoolsCall } from "@/lib/mock-devtools";
import { phoneCall } from "@/lib/mock-phone";
import { turnCheckCall } from "@/lib/mock-turncheck";
import { worktreesCall } from "@/lib/mock-worktrees";
import { kitsCall, kitsStream } from "@/lib/mock-kits";
import { marketplaceCall, marketplaceFile, marketplaceReady, type MockPlugin, seededPlugins } from "@/lib/mock-marketplace";
Expand Down Expand Up @@ -179,7 +180,7 @@ const sessions: Record<string, Session[]> = {
// Three agents in one worktree, so the board has to tell them apart.
{ name: "order-export-claude", title: "Export orders as CSV from the admin", location: "shop/order-export", dir: "/home/me/work/shop-order-export", command: "claude 'Export orders as CSV from the admin'", created: ago(1700), attached: 0, exited: false, agent: "claude", agent_state: "finished", state_since: ago(1560) },
{ name: "order-export-claude-2", location: "shop/order-export", dir: "/home/me/work/shop-order-export", command: "claude", created: ago(320), attached: 0, exited: false, agent: "claude", agent_state: "idle", state_since: ago(290) },
{ name: "order-export-claude-3", title: "Add tests for the export job", location: "shop/order-export", dir: "/home/me/work/shop-order-export", command: "claude 'Add tests for the export job'", created: ago(140), attached: 0, exited: false, agent: "claude", agent_state: "finished", state_since: ago(75) },
{ name: "order-export-claude-3", title: "Add tests for the export job", location: "shop/order-export", dir: "/home/me/work/shop-order-export", command: "claude 'Add tests for the export job'", created: ago(140), attached: 0, exited: false, agent: "claude", agent_state: "finished", state_since: ago(75), unfinished: true },
// Its repository's Playwright tests run near its 12 GB memory limit
// (mock-processes).
{ name: "https-linear-app-acme-claude", title: "Fix the cart badge after a refund", location: "shop/https-linear-app-acme", dir: "/home/me/work/shop-https-linear-app-acme", command: "claude", created: ago(44), attached: 0, exited: false, agent: "claude", agent_state: "idle", state_since: ago(40), scope: "berth-https-linear-app-acme-claude-tq1.scope", usage: LINEAR_USAGE },
Expand All @@ -188,6 +189,8 @@ const sessions: Record<string, Session[]> = {
gpu: [
{ name: "ci-flake-claude", title: "Move acme billing to the new ledger", location: "shop/ci-flake", dir: "/home/me/shop-ci-flake", command: "claude", created: ago(30), attached: 0, exited: false, agent: "claude", agent_state: "finished", state_since: ago(6) },
{ name: "judge-v2-claude", title: "Tune the judge prompt", location: "evals/judge-v2", dir: "/home/me/evals-judge-v2", command: "claude", created: ago(9), attached: 0, exited: false, agent: "claude", agent_state: "running", state_since: ago(1) },
// Turn check read its turn as parked: it checks back on a reply later.
{ name: "evals-claude", title: "Rerun the evals once the vendor replies", location: "evals", dir: "/home/me/evals", command: "claude", created: ago(400), attached: 0, exited: false, agent: "claude", agent_state: "finished", state_since: ago(180), parked: true },
{ name: "evals-codex", location: "evals", dir: "/home/me/evals", command: "codex", created: ago(3), attached: 0, exited: false, agent: "codex", agent_state: "idle", state_since: ago(3) },
// Asks a form of questions (AskUserQuestion) after a reply with code.
{ name: "shop-claude", title: "Plan the checkout release", location: "shop", dir: "/home/me/shop", command: "claude", created: ago(12), attached: 0, exited: false, agent: "claude", agent_state: "waiting", state_since: ago(2), ask: { tool: "AskUserQuestion", message: "Three questions about the release window" } },
Expand Down Expand Up @@ -675,6 +678,8 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro
if (thisMac) return thisMac;
const phone = phoneCall(box, method, path, body, delay);
if (phone) return phone;
const turnCheck = turnCheckCall(box, method, path, body, delay);
if (turnCheck) return turnCheck;
const usage = usageCall(box, method, path, body, delay);
if (usage) return usage;
const wts = worktreesCall(box, method, path, body, { locations, sessions }, emit, delay);
Expand Down
51 changes: 51 additions & 0 deletions app/src/lib/turncheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Client } from "@/lib/api";

// Turn check (see internal/box/turncheck.go): when on, a box asks the
// decision model you chose whether a finished turn needs you: Jev on Vercel
// AI Gateway, OpenAI's Decisions API, or Laya on a server you run. A pasted
// key is kept on the box, one per provider, and never comes back; key is
// only ever a secret reference. Only a paired laptop reads or sets it.

export type TurnCheckProvider = "gateway" | "openai" | "laya";

export interface TurnCheckConfig {
enabled: boolean;
provider?: TurnCheckProvider;
key?: string;
url?: string;
model?: string;
}

export interface TurnCheckStatus extends TurnCheckConfig {
// Whether a key is kept for the chosen provider.
key_set: boolean;
}

export interface TurnCheckChange extends TurnCheckConfig {
key_value?: string;
remove_key?: boolean;
}

export const turnCheckApi = {
get: (c: Client, box: string) => c.box<TurnCheckStatus>(box, "GET", "turncheck"),
put: (c: Client, box: string, change: TurnCheckChange) => c.box<TurnCheckStatus>(box, "PUT", "turncheck", change),
};

// What Settings shows for each provider. model is the one it serves today,
// filled in when you pick the provider; keyName and keyHelp are for the
// hosted ones, which take a key. Laya takes none.
export const turnCheckProviders: Record<TurnCheckProvider, { label: string; model?: string; keyName?: string; keyHelp?: string; keyLink?: string }> = {
gateway: { label: "Jev on AI Gateway", model: "typesafe-ai/jev", keyName: "AI Gateway", keyHelp: "Create an API key in Vercel's AI Gateway", keyLink: "https://vercel.com/docs/ai-gateway" },
openai: { label: "OpenAI Decisions", model: "gpt-6-luna", keyName: "OpenAI", keyHelp: "Create an OpenAI API key", keyLink: "https://platform.openai.com/api-keys" },
laya: { label: "Laya, your own server" },
};

export const layaPlaceholder = "http://127.0.0.1:8000/v1/systemone";

// turnCheckReady says whether a box has all it needs to be turned on, as
// the box decides.
export function turnCheckReady(st: TurnCheckStatus) {
if (st.provider === "laya") return !!st.url;
if (st.provider === "gateway" || st.provider === "openai") return !!st.model && (st.key_set || !!st.key);
return false;
}
20 changes: 19 additions & 1 deletion app/src/views/dashboard/agent-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SquareIcon } from "lucide-react";
import { HourglassIcon, SquareIcon } from "lucide-react";
import { useEffect, useState } from "react";

import { AgentIcon } from "@/components/agent-glyph";
Expand Down Expand Up @@ -112,6 +112,24 @@ export function AgentCard({ entry, selecting, selected, onSelect }: { entry: Ses
{crowded && ` · started ${startedAt(session.created)}`}
{where?.worktree.branch && where.worktree.branch !== place && <span className="opacity-70"> · {where.worktree.branch}</span>}
</div>
{/* Its turn ended, but turn check read the work as still going on. */}
{state === "running" && session.unfinished && (
<Tip label="Its turn ended while its work runs on: a delegated task, a build, a deploy or a watcher. It picks up by itself.">
<div className="mt-1 flex w-fit items-center gap-1 pl-5.5 text-[11px] text-muted-foreground">
<HourglassIcon className="size-3" aria-hidden />
Waiting on its work
</div>
</Tip>
)}
{/* Turn check read the work as parked: nothing runs, nothing needs you. */}
{state === "ready" && session.parked && (
<Tip label="Its work is paused until someone else replies or a later time. Nothing runs and nothing needs you; it picks up by itself.">
<div className="mt-1 flex w-fit items-center gap-1 pl-5.5 text-[11px] text-muted-foreground">
<HourglassIcon className="size-3" aria-hidden />
Parked: waiting on a reply or a later time
</div>
</Tip>
)}
{prompt && (!work || !prompt.startsWith(work.replace(/…$/, ""))) && (
<div className="mt-1 truncate pl-5.5 text-[11px] text-foreground/70 italic" title={prompt}>
“{prompt}”
Expand Down
Loading