Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
378 changes: 377 additions & 1 deletion AGENTS.md

Large diffs are not rendered by default.

85 changes: 42 additions & 43 deletions client/e2e/loop-gate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,18 @@ test.describe("loop completion gate", () => {
}
});

test("visibly ignores bare DONE when no fresh graph proof exists", async ({ page }, testInfo) => {
// The DONE gate itself moved to core with the driver, and is covered there
// by `loop_run::tests::an_aaa_loop_refuses_done_it_cannot_prove` — a real
// run against a scripted provider, rather than this suite stubbing our own
// `agent_chat` at the browser boundary, which a core-driven loop never
// sends. What is left for the browser to prove is the contract it still
// owns: the right run is asked for, and Stop reaches core rather than only
// this tab.
test("hands /loop to core and stops it there", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const suffix = `${testInfo.workerIndex}-${testInfo.repeatEachIndex}-${Date.now().toString(36)}`;
let sessionId: string | null = null;
let agentChatCalls = 0;
const started: Array<Record<string, unknown>> = [];
const stopped: Array<Record<string, unknown>> = [];

await page.route("**/rpc", async (route) => {
const request = route.request();
Expand All @@ -120,75 +127,67 @@ test.describe("loop completion gate", () => {
return;
}
const body = request.postDataJSON() as { id?: string; method?: string; params?: Record<string, unknown> };
if (body.method === "graph_list") {
if (body.method === "loop_start") {
started.push(body.params ?? {});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { graphs: [] } }),
body: JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: {
loop: {
loopId: "loop-e2e",
slug: String(body.params?.projectSlug ?? ""),
goal: String(body.params?.goal ?? ""),
profile: String(body.params?.profile ?? "standard"),
status: "running",
iteration: 0,
maxIterations: 100,
startedAtMs: Date.now(),
},
},
}),
});
return;
}
if (body.method === "agent_chat") {
agentChatCalls += 1;
sessionId = String(body.params?.sessionId ?? "");
if (body.method === "loop_stop") {
stopped.push(body.params ?? {});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: { sessionId, reply: "DONE", toolCalls: [] },
result: { loop: { loopId: "loop-e2e", status: "stopped", iteration: 1 } },
}),
});
return;
}
await route.fallback();
});

const { slug, title } = await (async () => {
const { slug } = await (async () => {
await page.goto("/");
return createLoopProject(page, suffix);
})();
const goal = `bare DONE gate ${suffix}`;
const goal = `core-driven loop ${suffix}`;

try {
const prompt = page.getByRole("textbox", { name: "Agent prompt" });
await prompt.fill(`/loop ${goal}`);
await prompt.fill(`/loop --aaa ${goal}`);
await prompt.press("Enter");

// Distinguish "loop never started" from "gate never fired" on failure.
await expect(page.getByText(`▶ loop started: ${goal}`)).toBeVisible({ timeout: 10_000 });
// 30s: the gate line lands only after session_create/editor_attach/
// loop_report_start/session_save round-trips against the real core.
await expect(page.getByText(/DONE ignored:/).first()).toBeVisible({ timeout: 30_000 });
expect(agentChatCalls).toBeGreaterThan(0);
if (!sessionId) throw new Error("agent_chat did not carry a session id");

const loaded = (await callRpc(page, "session_load", { id: sessionId })) as {
messages?: Array<{ role?: string; content?: string }>;
};
expect(loaded.messages?.some((message) => message.role === "user" && message.content?.includes("This is /loop"))).toBe(
true,
);
expect(page.getByText(/✔ loop complete/)).toHaveCount(0);
await expect(page.getByText(`▶ loop started: ${goal}`)).toBeVisible({ timeout: 15_000 });
expect(started).toHaveLength(1);
expect(started[0]).toMatchObject({ goal, profile: "aaa", projectSlug: slug });
// The run carries this panel's session, which is what routes its turns
// back into this transcript.
expect(typeof started[0].sessionId).toBe("string");

await page.getByRole("button", { name: "Stop agent loop" }).click();
await expect(page.getByRole("button", { name: "Stop agent loop" })).toBeHidden({ timeout: 15_000 });

await page.reload();
const sidebar = page.locator("aside").first();
const project = sidebar
.getByRole("button", { name: new RegExp(`^${escapeRegExp(title)}$`) })
.first();
await expect(project).toBeVisible({ timeout: 10_000 });
await project.click();
const session = sidebar
.getByRole("button", { name: new RegExp(`^${escapeRegExp(goal)}`) })
.first();
await expect(session).toBeVisible({ timeout: 10_000 });
await session.click();
await expect(page.locator('[data-role="user"]').filter({ hasText: /This is \/loop/ }).first()).toBeVisible();
await expect(page.locator('[data-role="tool"]').filter({ hasText: /DONE ignored:/ }).first()).toBeVisible();
await expect.poll(() => stopped.length, { timeout: 15_000 }).toBe(1);
expect(stopped[0]).toMatchObject({ loopId: "loop-e2e" });
} finally {
await callRpc(page, "project_delete", { slug }).catch(() => undefined);
}
Expand Down
17 changes: 12 additions & 5 deletions client/e2e/reports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,17 @@ test.describe("Reports workspace", () => {
details: "PIE completed with persisted visual evidence.",
},
{
// Passed, not skipped: `validate_completion_readiness` requires a
// passing performance check on the last iteration, because a run
// that never timed a frame has no evidence for the half of an
// `aaa` claim a screenshot cannot carry. A skipped one describes a
// report that can no longer reach `completed`.
kind: "performance",
name: "Performance sample",
command: "pnpm test:e2e --grep reports",
status: "skipped",
durationMs: 0,
details: "Covered by this isolated browser run.",
command: "game_perf",
status: "passed",
durationMs: 12_000,
details: "60fps average, 52fps one percent low over a 20s sample.",
},
],
changedFiles: [{ path: "AGENTS.md", additions: 5, deletions: 2 }],
Expand Down Expand Up @@ -331,7 +336,9 @@ test.describe("Reports workspace", () => {
const totals = reportsPanel.locator("header dl");
await expect(totals.getByText("2", { exact: true })).toHaveCount(2);
await expect(totals.getByText("92%", { exact: true })).toBeVisible();
await expect(totals.getByText("4/6", { exact: true })).toBeVisible();
// 5 of 6: the final performance check is passed rather than skipped, so
// `refresh_totals` counts it in checks_passed instead of checks_skipped.
await expect(totals.getByText("5/6", { exact: true })).toBeVisible();
await expect(totals.getByText("3", { exact: true })).toHaveCount(1);
await expect(totals.getByText("2h 35m", { exact: true })).toBeVisible();

Expand Down
139 changes: 114 additions & 25 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ import { FileEditor } from "./components/workspace/FileEditor";
import { LivePreview } from "./components/workspace/LivePreview";
import { NewProjectDialog } from "./components/workspace/NewProjectDialog";
import { FolderPicker } from "./components/workspace/FolderPicker";
import { StarterPicker } from "./components/workspace/StarterPicker";
import { createWorkspaceFromStarter, defaultStarterPath } from "./lib/starters";
import {
chooseNativeWorkspace,
openWorkspace,
Expand Down Expand Up @@ -473,6 +475,10 @@ export default function App() {
const [folderTarget, setFolderTarget] = useState<Project | null>(null);
const [attachPath, setAttachPath] = useState<string | null>(null);
const [folderBusy, setFolderBusy] = useState(false);
/** "existing" attaches a folder that is already there; "starter" scaffolds one. */
const [attachMode, setAttachMode] = useState<"existing" | "starter">("existing");
const [starterId, setStarterId] = useState<string | null>(null);
const [starterPath, setStarterPath] = useState("");
const [folderError, setFolderError] = useState("");
const [theme, setTheme] = useState<Theme>(readTheme);
const [settingsOpen, setSettingsOpen] = useState(false);
Expand Down Expand Up @@ -690,6 +696,24 @@ export default function App() {
setFolderTarget(null);
setAttachPath(null);
setFolderError("");
setAttachMode("existing");
setStarterId(null);
setStarterPath("");
};

/**
* The folder belongs to this game, not to the app — so a second game can
* point at a different repo and switching games switches folders. The
* workspaceRoot effect below opens the folder when the game is the active one.
*/
const bindWorkspace = async (target: Project, info: WorkspaceInfo) => {
await setProjectWorkspace(target.slug, info.root);
setProjects((current) =>
current.map((item) => (item.slug === target.slug ? { ...item, workspaceRoot: info.root } : item)),
);
if (project.slug === target.slug) {
setProject((current) => ({ ...current, workspaceRoot: info.root }));
}
};

/** Bind the picked folder to the attach dialog's target game. */
Expand All @@ -700,17 +724,7 @@ export default function App() {
setFolderError("");
try {
const info = await openWorkspace(path);
// The folder belongs to this game, not to the app — so a second game
// can point at a different repo and switching games switches folders.
// The workspaceRoot effect below opens the folder when the game is the
// active one.
await setProjectWorkspace(folderTarget.slug, info.root);
setProjects((current) =>
current.map((item) => (item.slug === folderTarget.slug ? { ...item, workspaceRoot: info.root } : item)),
);
if (project.slug === folderTarget.slug) {
setProject((current) => ({ ...current, workspaceRoot: info.root }));
}
await bindWorkspace(folderTarget, info);
closeAttachDialog();
pushLog(`attached ${info.name} to ${folderTarget.title} (${info.root})`);
} catch (error) {
Expand All @@ -721,6 +735,34 @@ export default function App() {
}
};

/**
* Scaffold a starter into a new folder and attach it. Core writes the tree
* and opens it in one call, so there is no window where the folder exists
* but nothing points at it.
*/
const attachFromStarter = async () => {
const path = starterPath.trim();
if (!path || !starterId || !folderTarget || folderBusy) return;
setFolderBusy(true);
setFolderError("");
try {
const created = await createWorkspaceFromStarter(starterId, path, folderTarget.title);
await bindWorkspace(folderTarget, created.workspace);
closeAttachDialog();
pushLog(`created ${created.starter.name} at ${created.workspace.root}`);
// Core never runs this: installing needs the network, and only a
// user-initiated terminal may run a command on their machine.
if (created.install) {
pushLog(`run \`${created.install}\` in ${created.workspace.root} before PLAY`);
}
} catch (error) {
setFolderError(reason(error));
pushLog(`create from starter failed: ${reason(error)}`, "error");
} finally {
setFolderBusy(false);
}
};

/**
* The "New game" dialog's source-folder path: the folder becomes a new game
* (named after itself, or `title` when given) so it shows up as its own row
Expand Down Expand Up @@ -2377,24 +2419,65 @@ export default function App() {
<DialogContent className="max-w-lg">
<DialogTitle>{folderTarget ? `Attach a folder to ${folderTarget.title}` : ""}</DialogTitle>
<DialogDescription>
CaliCode edits this folder in place and runs its own dev server. It must contain a package.json or a
.git directory.
CaliCode edits this folder in place and runs its own dev server. Attach one you already have, or
scaffold a new one from a starter.
</DialogDescription>

<div role="tablist" aria-label="Folder source" className="mt-3 flex gap-1 rounded-lg bg-surface-1 p-1">
{(["existing", "starter"] as const).map((mode) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={attachMode === mode}
disabled={folderBusy}
onClick={() => {
setFolderError("");
setAttachMode(mode);
// Seed the destination from the game's slug the first time
// the starter tab is opened, so the common case is one click.
if (mode === "starter" && !starterPath && folderTarget) {
setStarterPath(defaultStarterPath(folderTarget.slug));
}
}}
className={`flex-1 rounded-md px-3 py-1.5 text-xs transition-colors ${
attachMode === mode ? "bg-surface-0 text-ink-strong" : "text-ink-subtle hover:bg-surface-2"
}`}
>
{mode === "existing" ? "Existing folder" : "New from starter"}
</button>
))}
</div>

<form
className="mt-3"
onSubmit={(event) => {
event.preventDefault();
void attachFolder();
if (attachMode === "starter") void attachFromStarter();
else void attachFolder();
}}
>
<FolderPicker
value={attachPath}
onChange={(path) => {
setFolderError("");
setAttachPath(path);
}}
disabled={folderBusy}
/>
{attachMode === "existing" ? (
<FolderPicker
value={attachPath}
onChange={(path) => {
setFolderError("");
setAttachPath(path);
}}
disabled={folderBusy}
/>
) : (
<StarterPicker
value={starterId}
onChange={(id) => {
setFolderError("");
setStarterId(id);
}}
path={starterPath}
onPathChange={setStarterPath}
disabled={folderBusy}
/>
)}
{folderError ? (
<p role="alert" className="mt-2 text-xs text-danger-soft">
{folderError}
Expand All @@ -2406,9 +2489,15 @@ export default function App() {
Cancel
</Button>
</DialogClose>
<Button type="submit" size="sm" disabled={!attachPath || folderBusy}>
{folderBusy ? "Attaching..." : "Attach"}
</Button>
{attachMode === "starter" ? (
<Button type="submit" size="sm" disabled={!starterId || !starterPath.trim() || folderBusy}>
{folderBusy ? "Creating..." : "Create and attach"}
</Button>
) : (
<Button type="submit" size="sm" disabled={!attachPath || folderBusy}>
{folderBusy ? "Attaching..." : "Attach"}
</Button>
)}
</div>
</form>
</DialogContent>
Expand Down
Loading
Loading