diff --git a/AGENTS.md b/AGENTS.md index 602bfe3d..44203615 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,7 +448,7 @@ the run is registered and the driver continues detached, so a loop outlives the request — and the tab — that started it: ``` -loop_start {projectSlug, goal, profile?, intervalMs?, sessionId?, maxIterations?} +loop_start {projectSlug, goal, profile?, intervalMs?, sessionId?} loop_stop {loopId} · loop_status {loopId} · loop_runs {} ``` @@ -460,9 +460,9 @@ loop_stop {loopId} · loop_status {loopId} · loop_runs {} - `Aaa` completion defers entirely to `loop_report::validate_completion_readiness` — the same gate a model calling `loop_report_update` must clear, so the loop cannot finish itself by a route - the report would have rejected. Measured: with no report on disk, three DONEs - in a row were refused and the run hit its cap, where `standard` completed in - two iterations. + the report would have rejected. Measured: with no passing report on disk, + three DONEs in a row are refused and the run remains live until Stop, while + `standard` completes in two iterations. - Progress rides the SSE bus as `loop.iteration`, `loop.done_refused`, `loop.completed`, `loop.finished`. - **The panel rejoins a run it did not start.** On mount it asks `loop_runs` diff --git a/client/build-electron/entitlements.mac.plist b/client/build-electron/entitlements.mac.plist index 54abe9b5..87d3be51 100644 --- a/client/build-electron/entitlements.mac.plist +++ b/client/build-electron/entitlements.mac.plist @@ -7,6 +7,9 @@ refused without this. --> com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-jit + com.apple.security.cs.disable-library-validation diff --git a/client/e2e/features.spec.ts b/client/e2e/features.spec.ts index fb51f03d..c7a804bb 100644 --- a/client/e2e/features.spec.ts +++ b/client/e2e/features.spec.ts @@ -3,8 +3,31 @@ import { expect, test } from "@playwright/test"; const PNG_1PX = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; -const openTab = (page: import("@playwright/test").Page, name: string) => - page.getByRole("tab", { name, exact: true }).click(); +const TAB_LABELS: Record = { + play: "Play", + code: "Code", + art: "Assets", + build: "Build", + scene: "Scene", + test: "Test", + terminal: "Terminal", + browser: "Browser", + reports: "Reports", +}; + +async function openTab(page: import("@playwright/test").Page, name: string): Promise { + const tab = page.getByRole("tab", { name, exact: true }); + if ((await tab.count()) === 0) { + const picker = page.getByRole("button", { name: `Show ${TAB_LABELS[name]}` }); + if ((await picker.count()) > 0) { + await picker.click(); + } else { + await page.getByRole("button", { name: "Add view" }).click(); + await page.getByRole("menuitem", { name: TAB_LABELS[name], exact: true }).click(); + } + } + await tab.click(); +} async function callRpc( page: import("@playwright/test").Page, diff --git a/client/e2e/loop-gate.spec.ts b/client/e2e/loop-gate.spec.ts index bd98ca87..e7f6114b 100644 --- a/client/e2e/loop-gate.spec.ts +++ b/client/e2e/loop-gate.spec.ts @@ -143,7 +143,6 @@ test.describe("loop completion gate", () => { profile: String(body.params?.profile ?? "standard"), status: "running", iteration: 0, - maxIterations: 100, startedAtMs: Date.now(), }, }, diff --git a/client/e2e/one-prompt-live.spec.ts b/client/e2e/one-prompt-live.spec.ts index b6f9d34a..3c976d3c 100644 --- a/client/e2e/one-prompt-live.spec.ts +++ b/client/e2e/one-prompt-live.spec.ts @@ -303,6 +303,7 @@ test.describe("one-prompt live production proof @live", () => { expect(persisted.tests.length).toBeGreaterThan(baseline.tests.length); const reportsTab = page.getByRole("tab", { name: "reports", exact: true }); + if ((await reportsTab.count()) === 0) await page.getByRole("button", { name: "Show Reports" }).click(); await reportsTab.click(); const reportsPanel = page.locator("#workspace-panel-reports"); await expect(reportsPanel.getByText(goal, { exact: true })).toBeVisible({ timeout: 30_000 }); diff --git a/client/e2e/reports.spec.ts b/client/e2e/reports.spec.ts index 47d73a4f..67b01180 100644 --- a/client/e2e/reports.spec.ts +++ b/client/e2e/reports.spec.ts @@ -323,6 +323,7 @@ test.describe("Reports workspace", () => { await projectButton.click(); const reportsTab = page.getByRole("tab", { name: "reports", exact: true }); + if ((await reportsTab.count()) === 0) await page.getByRole("button", { name: "Show Reports" }).click(); await expect(reportsTab).toBeVisible(); await reportsTab.click(); await expect(reportsTab).toHaveAttribute("aria-selected", "true"); diff --git a/client/e2e/smoke.spec.ts b/client/e2e/smoke.spec.ts index eeef9e48..48ad1287 100644 --- a/client/e2e/smoke.spec.ts +++ b/client/e2e/smoke.spec.ts @@ -4,14 +4,15 @@ test("editor boots with the CaliCode workspace shell", async ({ page }) => { await page.goto("/"); await expect(page.getByLabel("Agent prompt")).toBeVisible(); await expect(page.locator("canvas").first()).toBeVisible(); - for (const name of ["play", "code", "art", "scene", "test"]) { - await expect(page.getByRole("tab", { name, exact: true })).toBeVisible(); - } + await expect(page.locator("[data-empty-workspace]")).toBeVisible(); + await expect(page.getByRole("tab")).toHaveCount(0); + await page.getByRole("button", { name: "Show Play" }).click(); + await expect(page.getByRole("tab", { name: "play", exact: true })).toBeVisible(); }); test("play transport runs PIE and captures frames", async ({ page }) => { await page.goto("/"); - await page.getByRole("tab", { name: "play", exact: true }).click(); + await page.getByRole("button", { name: "Show Play" }).click(); await page.getByRole("button", { name: "PLAY", exact: true }).click(); await expect(page.getByRole("button", { name: "PAUSE", exact: true })).toBeVisible({ timeout: 10_000 }); @@ -22,6 +23,7 @@ test("play transport runs PIE and captures frames", async ({ page }) => { test("live bar exposes build, fps, and a console", async ({ page }) => { await page.goto("/"); + await page.getByRole("button", { name: "Show Play" }).click(); await expect(page.getByText("FPS", { exact: false }).first()).toBeVisible(); // The log lives in the bottom dock now, behind the panel toggle. @@ -34,7 +36,7 @@ test("live bar exposes build, fps, and a console", async ({ page }) => { test("tweak pins open a live inspector over the viewport", async ({ page }) => { await page.goto("/"); - await page.getByRole("tab", { name: "play", exact: true }).click(); + await page.getByRole("button", { name: "Show Play" }).click(); const pin = page.getByRole("button", { name: "RUNTIME", exact: true }); await expect(pin).toHaveAttribute("aria-pressed", "false"); diff --git a/client/e2e/visual.spec.ts b/client/e2e/visual.spec.ts index 4c6dff29..3dfde543 100644 --- a/client/e2e/visual.spec.ts +++ b/client/e2e/visual.spec.ts @@ -44,6 +44,27 @@ async function settle(page: Page): Promise { const MAX_DIFF_PIXELS = 150; const TABS = ["play", "code", "art", "scene", "test"] as const; +const TAB_PICKER_LABELS: Record<(typeof TABS)[number], string> = { + play: "Play", + code: "Code", + art: "Assets", + scene: "Scene", + test: "Test", +}; + +async function openWorkspaceTab(page: Page, tab: (typeof TABS)[number]): Promise { + const existing = page.getByRole("tab", { name: tab, exact: true }); + if ((await existing.count()) === 0) { + const picker = page.getByRole("button", { name: `Show ${TAB_PICKER_LABELS[tab]}` }); + if ((await picker.count()) > 0) { + await picker.click(); + } else { + await page.getByRole("button", { name: "Add view" }).click(); + await page.getByRole("menuitem", { name: TAB_PICKER_LABELS[tab], exact: true }).click(); + } + } + await page.getByRole("tab", { name: tab, exact: true }).click(); +} test.describe("workspace surfaces @visual", () => { for (const tab of TABS) { @@ -51,7 +72,7 @@ test.describe("workspace surfaces @visual", () => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); await settle(page); - await page.getByRole("tab", { name: tab, exact: true }).click(); + await openWorkspaceTab(page, tab); await settle(page); await expect(page).toHaveScreenshot(`tab-${tab}.png`, { diff --git a/client/e2e/visual.spec.ts-snapshots/console-expanded-darwin.png b/client/e2e/visual.spec.ts-snapshots/console-expanded-darwin.png index 34c07a9e..a9c9172d 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/console-expanded-darwin.png and b/client/e2e/visual.spec.ts-snapshots/console-expanded-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/console-expanded-linux.png b/client/e2e/visual.spec.ts-snapshots/console-expanded-linux.png index ec40c488..cca15976 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/console-expanded-linux.png and b/client/e2e/visual.spec.ts-snapshots/console-expanded-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-darwin.png b/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-darwin.png index f5330f40..ab475309 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-darwin.png and b/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-linux.png b/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-linux.png index 2af0125c..9fa95f4c 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-linux.png and b/client/e2e/visual.spec.ts-snapshots/layout-desktop-1440-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-darwin.png b/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-darwin.png index 3fe7f87d..87de87d3 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-darwin.png and b/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-linux.png b/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-linux.png index 0664be36..66268b3d 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-linux.png and b/client/e2e/visual.spec.ts-snapshots/layout-laptop-1280-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-darwin.png b/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-darwin.png index f1dd6a90..8f15b2e2 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-darwin.png and b/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-linux.png b/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-linux.png index 29d6e688..66243e53 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-linux.png and b/client/e2e/visual.spec.ts-snapshots/layout-tablet-768-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-art-darwin.png b/client/e2e/visual.spec.ts-snapshots/tab-art-darwin.png index da50b24b..20abe83f 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-art-darwin.png and b/client/e2e/visual.spec.ts-snapshots/tab-art-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-art-linux.png b/client/e2e/visual.spec.ts-snapshots/tab-art-linux.png index 79f18b43..b3787c64 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-art-linux.png and b/client/e2e/visual.spec.ts-snapshots/tab-art-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-code-darwin.png b/client/e2e/visual.spec.ts-snapshots/tab-code-darwin.png index d5e09e9a..b6dfb215 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-code-darwin.png and b/client/e2e/visual.spec.ts-snapshots/tab-code-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-code-linux.png b/client/e2e/visual.spec.ts-snapshots/tab-code-linux.png index 448d93da..fc39f78c 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-code-linux.png and b/client/e2e/visual.spec.ts-snapshots/tab-code-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-play-darwin.png b/client/e2e/visual.spec.ts-snapshots/tab-play-darwin.png index f5330f40..aa79e3a8 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-play-darwin.png and b/client/e2e/visual.spec.ts-snapshots/tab-play-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-play-linux.png b/client/e2e/visual.spec.ts-snapshots/tab-play-linux.png index 2af0125c..d900838d 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-play-linux.png and b/client/e2e/visual.spec.ts-snapshots/tab-play-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-scene-darwin.png b/client/e2e/visual.spec.ts-snapshots/tab-scene-darwin.png index 74c0e900..db73ceba 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-scene-darwin.png and b/client/e2e/visual.spec.ts-snapshots/tab-scene-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-scene-linux.png b/client/e2e/visual.spec.ts-snapshots/tab-scene-linux.png index 1fc34846..604a6c33 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-scene-linux.png and b/client/e2e/visual.spec.ts-snapshots/tab-scene-linux.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-test-darwin.png b/client/e2e/visual.spec.ts-snapshots/tab-test-darwin.png index 9dac95ba..142d4a97 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-test-darwin.png and b/client/e2e/visual.spec.ts-snapshots/tab-test-darwin.png differ diff --git a/client/e2e/visual.spec.ts-snapshots/tab-test-linux.png b/client/e2e/visual.spec.ts-snapshots/tab-test-linux.png index 4f8a3538..42a7b812 100644 Binary files a/client/e2e/visual.spec.ts-snapshots/tab-test-linux.png and b/client/e2e/visual.spec.ts-snapshots/tab-test-linux.png differ diff --git a/client/e2e/workflow.spec.ts b/client/e2e/workflow.spec.ts index f68cf3b7..90270612 100644 --- a/client/e2e/workflow.spec.ts +++ b/client/e2e/workflow.spec.ts @@ -8,7 +8,31 @@ const slugify = (value: string) => const uniqueLabel = (prefix: string, testInfo: TestInfo) => `${prefix} ${testInfo.workerIndex}-${testInfo.repeatEachIndex}-${Math.random().toString(36).slice(2, 8)}`; -const openTab = (page: Page, name: string) => page.getByRole("tab", { name, exact: true }).click(); +const TAB_LABELS: Record = { + play: "Play", + code: "Code", + art: "Assets", + build: "Build", + scene: "Scene", + test: "Test", + terminal: "Terminal", + browser: "Browser", + reports: "Reports", +}; + +async function openTab(page: Page, name: string): Promise { + const tab = page.getByRole("tab", { name, exact: true }); + if ((await tab.count()) === 0) { + const picker = page.getByRole("button", { name: `Show ${TAB_LABELS[name]}` }); + if ((await picker.count()) > 0) { + await picker.click(); + } else { + await page.getByRole("button", { name: "Add view" }).click(); + await page.getByRole("menuitem", { name: TAB_LABELS[name], exact: true }).click(); + } + } + await tab.click(); +} async function callRpc(page: Page, method: string, params: Record): Promise { return page.evaluate( diff --git a/client/electron/main.ts b/client/electron/main.ts index 87d899d5..3e4ce199 100644 --- a/client/electron/main.ts +++ b/client/electron/main.ts @@ -321,7 +321,9 @@ function registerIpc(panel: BrowserPanel): void { }); return result.canceled ? null : (result.filePaths[0] ?? null); }); - ipcMain.handle(IPC.panelBounds, (_event, bounds: PanelBounds) => { + // Bounds are high-frequency, fire-and-forget layout updates from the + // renderer (`ipcRenderer.send`), not request/response calls. + ipcMain.on(IPC.panelBounds, (_event, bounds: PanelBounds) => { panel.setBounds(bounds); }); ipcMain.handle(IPC.panelTarget, () => panel.targetId()); diff --git a/client/src/App.tsx b/client/src/App.tsx index fc8e78a0..7b23fc0e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } import { Viewport } from "./components/editor/Viewport"; import { ConsolePanel, type LogEntry } from "./components/editor/ConsolePanel"; import { AgentPanel } from "./components/editor/AgentPanel"; +import { GraphPanel } from "./components/editor/GraphPanel"; import { ArrowLeft, ArrowRight, @@ -9,6 +10,7 @@ import { PanelBottom, PanelLeft, PanelRight, + Workflow, } from "lucide-react"; import blenderLogo from "./assets/blender-logo.svg"; import { hasOverlayWindowControls } from "./lib/desktop"; @@ -18,6 +20,7 @@ import { type SessionMenuAction, } from "./components/workspace/GamesSidebar"; import { AssetsLibraryPage } from "./components/library/AssetsLibraryPage"; +import { ProjectHub } from "./components/workspace/ProjectHub"; import { SettingsPage } from "./components/settings/SettingsPage"; import { archiveSession, @@ -31,6 +34,7 @@ import { MULTI_INSTANCE_TABS, WORKSPACE_TABS, WorkspaceTabs, + WorkspaceTabPicker, nextTabId, tabKind, type WorkspaceTab, @@ -92,6 +96,7 @@ import { } from "./hooks/useResizablePanels"; import type { PieRuntime, PieState } from "./lib/pie"; import type { AgentMessage, Asset, CapturedFrame, Entity, ModelList, Project, TestResult } from "./lib/types"; +import { cancelGraph, runGraph, type TaskGraph } from "./lib/graph"; import type { ProjectTemplate } from "./lib/projectTemplates"; import { importMime, isBlenderAsset } from "./lib/blender"; @@ -187,8 +192,8 @@ const FILE_TREE_PANEL: ResizablePanelOptions = { * lost work even though nothing was lost. */ interface ViewState { - tab: WorkspaceTabId; - /** Views open as dock tabs. Order is the strip order. */ + tab: WorkspaceTabId | null; + /** Views open as dock tabs. Order is the strip order; empty is first use. */ openTabs: WorkspaceTabId[]; /** * Views this editor has already offered. @@ -207,14 +212,23 @@ interface ViewState { function readView(): ViewState { try { const raw = localStorage.getItem(VIEW_KEY); + // A missing view record is a first-use dock, not an instruction to fill it + // with every available panel. The picker in the dock supplies the first + // explicit choice and persistence takes over from there. + if (raw === null) { + return { tab: null, openTabs: [], seenTabs: [...WORKSPACE_TABS], workspaceFile: null }; + } const parsed = raw ? (JSON.parse(raw) as Partial) : {}; - const tab = WORKSPACE_TABS.includes(parsed.tab as WorkspaceTab) ? (parsed.tab as WorkspaceTab) : "play"; + // Graph is contextual: it should appear beside a conversation once a + // graph exists, rather than occupying a tab in every empty workspace. + const defaultCatalogue = WORKSPACE_TABS.filter((entry) => entry !== "graph"); + const parsedTab = WORKSPACE_TABS.includes(parsed.tab as WorkspaceTab) ? (parsed.tab as WorkspaceTab) : null; // A stored strip is filtered against the current view list, so a released // or renamed view cannot restore a tab that no longer has a panel. An - // empty result falls back to every view rather than none — a dock with no - // tabs has nothing to show and no way back. + // explicitly empty strip is a valid persisted first-use state. // Bare ids only: a second side chat is a memory-only thread, so restoring // its tab would reopen an empty panel the user never asked for again. + const hasStoredStrip = Array.isArray(parsed.openTabs); const stored = Array.isArray(parsed.openTabs) ? parsed.openTabs.filter((entry): entry is WorkspaceTab => WORKSPACE_TABS.includes(entry as WorkspaceTab)) : []; @@ -223,8 +237,14 @@ function readView(): ViewState { const seen = Array.isArray(parsed.seenTabs) ? parsed.seenTabs.filter((entry): entry is WorkspaceTab => WORKSPACE_TABS.includes(entry as WorkspaceTab)) : stored; - const openTabs = withIntroducedTabs(stored, seen, WORKSPACE_TABS); - if (!openTabs.includes(tab)) openTabs.unshift(tab); + const openTabs = hasStoredStrip + ? stored.length === 0 + ? [] + : withIntroducedTabs(stored, seen, defaultCatalogue) + : parsedTab + ? [parsedTab] + : []; + const tab = parsedTab && openTabs.includes(parsedTab) ? parsedTab : openTabs[0] ?? null; return { tab, openTabs, @@ -232,7 +252,12 @@ function readView(): ViewState { workspaceFile: typeof parsed.workspaceFile === "string" ? parsed.workspaceFile : null, }; } catch { - return { tab: "play", openTabs: [...WORKSPACE_TABS], seenTabs: [...WORKSPACE_TABS], workspaceFile: null }; + return { + tab: null, + openTabs: [], + seenTabs: [...WORKSPACE_TABS], + workspaceFile: null, + }; } } @@ -262,12 +287,39 @@ export default function App() { const [modelList, setModelList] = useState(null); const [captureEvery, setCaptureEvery] = useState(3); const [assetSearch, setAssetSearch] = useState(""); - const [tab, setTab] = useState(() => readView().tab); + const [tab, setTab] = useState(() => readView().tab); const [openTabs, setOpenTabs] = useState(() => readView().openTabs); // Render mirror of the strip, so an opener can allocate against the current // set without waiting for a re-render. const openTabsRef = useRef(openTabs); openTabsRef.current = openTabs; + + /** Open a view as a tab and select it, including from the blank first-use dock. */ + const addWorkspaceTab = useCallback((kind: WorkspaceTab): WorkspaceTabId => { + // Read and write the ref, not just state: `/side` twice in one tick has to + // allocate two ids, and the second call runs before React has re-rendered + // with the first. + const current = openTabsRef.current; + const opened = MULTI_INSTANCE_TABS.includes(kind) ? nextTabId(kind, current) : kind; + if (!current.includes(opened)) { + const next = [...current, opened]; + openTabsRef.current = next; + setOpenTabs(next); + } + setTab(opened); + return opened; + }, []); + + /** Select the newest instance of a view, opening one when the strip is blank. */ + const focusWorkspaceTab = useCallback( + (kind: WorkspaceTab): WorkspaceTabId => { + const existing = [...openTabsRef.current].reverse().find((id) => tabKind(id) === kind); + if (!existing) return addWorkspaceTab(kind); + setTab(existing); + return existing; + }, + [addWorkspaceTab], + ); /** * What the agent browser currently has open, so the BROWSER tab can show the * page's own title and favicon the way a browser tab does. @@ -287,6 +339,11 @@ export default function App() { // transcript and talks to a tool-less endpoint, so asking about a run can // never alter it. const [mainTranscript, setMainTranscript] = useState([]); + // The graph belongs to the active agent session, but the full visualization + // lives in the tools dock so it can sit beside the conversation. + const [activeGraph, setActiveGraph] = useState(null); + const [graphTickers, setGraphTickers] = useState>({}); + const graphAutoOpenedRef = useRef(null); // Text `/side ` puts in the side chat's composer, unsent. The // nonce is what makes a repeat of the same question reach the panel again. // Drafts and threads are per side chat, not per project: `/side` twice @@ -358,8 +415,10 @@ export default function App() { const [sidebarDrawerOpen, setSidebarDrawerOpen] = useState(false); // From lg up the dock is a real column; the chat header's panel button // hides/shows it there, and below lg the same button drives the overlay. - // What the main column shows: the agent chat, or the Assets Library page. - const [mainView, setMainView] = useState<"chat" | "library">("chat"); + // What the main column shows: the project hub, agent chat, or Assets Library. + // Existing installs open the selected game directly; an empty projects + // directory switches to the hub below so there is still a clear first step. + const [mainView, setMainView] = useState<"chat" | "library" | "hub">("chat"); const [toolsVisible, setToolsVisible] = useState( () => localStorage.getItem("calicode-tools-visible") !== "0", ); @@ -398,8 +457,8 @@ export default function App() { /** * Where the next `/side` goes. Each run gets its own thread, except that an * untouched panel — nothing asked, nothing typed — is used before a new one - * is opened beside it. Without that, the side chat the dock ships with would - * sit empty forever while every question opened another tab next to it. + * is opened beside it. This also lets a slash command reuse a side-chat tab + * that was chosen from the first-use picker without multiplying empty tabs. */ const openFreshSideChat = (): WorkspaceTabId => { const pristine = sideChatIds.find( @@ -658,15 +717,32 @@ export default function App() { void (async () => { try { if (coreRetry > 0) setCoreStatus("unknown"); - const loaded = await rpc("project_open", { slug: projectSlugRef.current || "starter" }); + const listed = (await rpc("project_list", {})) ?? []; if (cancelled) return; - setProject(adoptSaved(loaded)); + setProjects(listed); coreHydratedRef.current = true; + const preferred = listed.find((item) => item.slug === projectSlugRef.current) ?? listed[0]; + if (!preferred) { + // An intentionally empty projects directory is a valid first-run + // state. Keep the local preview object inert while the hub explains + // how to create or open the first real game. + setActiveSessionId(null); + setWorkspace(null); + setWorkspaceFile(null); + setActivityFile(null); + setPendingActivityFile(null); + setFrames([]); + setTestResults([]); + setLoadMs(performance.now() - started); + setMainView("hub"); + return; + } + const loaded = await rpc("project_open", { slug: preferred.slug }); + if (cancelled) return; + setProject(adoptSaved(loaded)); + setMainView("chat"); setScriptBaseline(snapshotScripts(loaded)); setCaptureEvery((loaded.settings.pie as { captureEvery?: number })?.captureEvery ?? 3); - const listed = await rpc("project_list", {}); - if (cancelled) return; - setProjects(listed); setLoadMs(performance.now() - started); } catch { if (cancelled) return; @@ -777,6 +853,7 @@ export default function App() { const existing = projects.find((item) => item.workspaceRoot === info.root); if (existing) { setNewProjectOpen(false); + setMainView("chat"); setActiveSessionId(null); setSessionRevision((current) => current + 1); await openProject(existing.slug); @@ -794,6 +871,7 @@ export default function App() { const bound = { ...created, workspaceRoot: info.root }; setProjects((current) => [...current.filter((item) => item.slug !== slug), bound]); setProject(adoptSaved(bound)); + setMainView("chat"); setActiveSessionId(null); setSessionRevision((current) => current + 1); setScriptBaseline(snapshotScripts(bound)); @@ -855,7 +933,7 @@ export default function App() { const revealActivityEditor = useCallback(() => { setMainView("chat"); - setTab("code"); + focusWorkspaceTab("code"); // The dock can be hidden persistently on desktop, or must be opened as an // overlay on narrow windows. A file activity click is an explicit request // to reveal it in either layout. @@ -863,7 +941,7 @@ export default function App() { localStorage.setItem("calicode-tools-visible", "1"); if (window.matchMedia?.("(min-width: 1024px)").matches) setToolsOpen(false); else setToolsOpen(true); - }, []); + }, [focusWorkspaceTab]); const openActivityFile = useCallback( (change: ActivityFileChange) => { @@ -1066,12 +1144,12 @@ export default function App() { }); } setBuilderAssetId(assetId); - setTab("build"); + focusWorkspaceTab("build"); }, - [project], + [focusWorkspaceTab, project], ); - const focusBuilderTab = useCallback(() => setTab("build"), []); + const focusBuilderTab = useCallback(() => focusWorkspaceTab("build"), [focusWorkspaceTab]); const browserTools = useBrowserTools({ project, @@ -1142,46 +1220,39 @@ export default function App() { return () => window.clearTimeout(timer); }, [coreStatus, project, persistProjectNow]); - /** - * Open a view as a tab and select it. A repeatable view (the side chat) - * opens another instance every time rather than focusing the one already - * there — two asks are two threads. - */ - const addWorkspaceTab = useCallback((kind: WorkspaceTab): WorkspaceTabId => { - // Read and write the ref, not just state: `/side` twice in one tick has to - // allocate two ids, and the second call runs before React has re-rendered - // with the first. - const current = openTabsRef.current; - const opened = MULTI_INSTANCE_TABS.includes(kind) ? nextTabId(kind, current) : kind; - if (!current.includes(opened)) { - const next = [...current, opened]; - openTabsRef.current = next; - setOpenTabs(next); - } - setTab(opened); - return opened; - }, []); - - /** Select the newest instance of a view, opening one only if none is open. */ - const focusWorkspaceTab = useCallback((kind: WorkspaceTab): WorkspaceTabId => { - const existing = [...openTabsRef.current].reverse().find((id) => tabKind(id) === kind); - if (!existing) return addWorkspaceTab(kind); - setTab(existing); - return existing; - }, [addWorkspaceTab]); + /** Keep the graph beside the chat, opening its dock tab on first use. */ + const handleGraphChange = useCallback( + (graph: TaskGraph | null, tickers: Record) => { + setActiveGraph(graph); + setGraphTickers(tickers); + if (!graph) { + graphAutoOpenedRef.current = null; + return; + } + if (graphAutoOpenedRef.current === graph.graphId) return; + graphAutoOpenedRef.current = graph.graphId; + addWorkspaceTab("graph"); + setToolsVisible(true); + localStorage.setItem("calicode-tools-visible", "1"); + if (viewportWidth < TOOLS_DOCK_BREAKPOINT) setToolsOpen(true); + }, + [addWorkspaceTab, viewportWidth], + ); /** * Close a tab. Closing the active one selects its neighbour rather than - * leaving the dock pointed at a view that is no longer in the strip. + * leaving the dock pointed at a view that is no longer in the strip. Closing + * the final tab selects the blank picker instead. */ const closeWorkspaceTab = useCallback((target: WorkspaceTabId) => { setOpenTabs((current) => { - if (current.length <= 1) return current; const index = current.indexOf(target); if (index < 0) return current; const next = current.filter((entry) => entry !== target); openTabsRef.current = next; - setTab((active) => (active === target ? next[Math.min(index, next.length - 1)] : active)); + setTab((active) => + active === target ? next[Math.min(index, next.length - 1)] ?? null : active, + ); return next; }); // Closing a side chat ends that thread. Ids are reused once freed, so a @@ -1297,6 +1368,7 @@ export default function App() { const created = await rpc("project_create", { slug, title, template: templateId }); setProjects((current) => [...current.filter((item) => item.slug !== slug), created]); setProject(adoptSaved(created)); + setMainView("chat"); // A task belongs to exactly one game/worktree. Carrying the previously // selected task into a newly created game makes AgentPanel attempt to // resume it against the wrong project before the user has done anything. @@ -1373,6 +1445,21 @@ export default function App() { }); }; + const openProjectHub = () => { + setMainView("hub"); + setToolsOpen(false); + setToolsExpanded(false); + setSidebarDrawerOpen(false); + }; + + const selectProjectFromHub = (slug: string) => { + pushNav({ slug, sessionId: null }); + setActiveSessionId(null); + setSessionRevision((current) => current + 1); + setSidebarDrawerOpen(false); + if (slug !== project.slug) void openProject(slug); + }; + const startSession = async (slug: string) => { try { const created = await createSession(slug); @@ -1478,6 +1565,21 @@ export default function App() { if (project.slug === target.slug && remaining[0]) { setActiveSessionId(null); await openProject(remaining[0].slug); + } else if (project.slug === target.slug) { + // Removing the final game is the explicit route back to the launch + // experience. Drop editor-only state so the empty hub is honest. + runtime?.stop(); + setRuntime(null); + setActiveSessionId(null); + setSessionRevision((current) => current + 1); + setWorkspace(null); + setWorkspaceFile(null); + setActivityFile(null); + setPendingActivityFile(null); + setFrames([]); + setTestResults([]); + setMainView("hub"); + setNav({ stack: [], index: -1 }); } pushLog(`removed ${target.title}`); } @@ -1767,6 +1869,8 @@ export default function App() { void startSession(slug); }} onNewGame={() => setNewProjectOpen(true)} + onOpenProjectHub={openProjectHub} + projectHubActive={mainView === "hub"} pinnedProjectSlugs={pinnedProjectSlugs} onProjectAction={handleProjectAction} onSessionAction={handleSessionAction} @@ -1870,9 +1974,11 @@ export default function App() { {mainView === "library" ? "Assets Library" - : sessions.find((session) => session.id === activeSessionId)?.title ?? project.title} + : mainView === "hub" + ? "Projects" + : sessions.find((session) => session.id === activeSessionId)?.title ?? project.title} - void persistProjectNow()} /> + {mainView === "chat" ? void persistProjectNow()} /> : null} {mainView === "chat" ? ( <> + ) : null} + + ) : null} + {messages.map((message, index) => { if (message.turnId) { if (activityAnchors.get(message.turnId) !== index) return null; + const turnMessages = activityGroups.get(message.turnId) ?? [message]; + const marker = turnMessages.find((candidate) => isTurnMarker(candidate)); + const firstActionIndex = turnMessages.findIndex((candidate) => Boolean(candidate.toolCallId)); + const actions = turnMessages.filter((candidate) => isTurnMarker(candidate) || candidate.toolCallId); + const assistantMessages = turnMessages.filter( + (candidate): candidate is AgentMessage & { role: "assistant" } => candidate.role === "assistant", + ); const reasoning = reasoningByTurn[message.turnId]; + const showActivity = actions.some((candidate) => Boolean(candidate.toolCallId)) || marker?.stopped === true; + const beforeActions = + firstActionIndex < 0 + ? [] + : turnMessages + .slice(0, firstActionIndex) + .filter( + (candidate): candidate is AgentMessage & { role: "assistant" } => candidate.role === "assistant", + ); + const afterActions = + firstActionIndex < 0 + ? assistantMessages + : turnMessages + .slice(firstActionIndex + 1) + .filter( + (candidate): candidate is AgentMessage & { role: "assistant" } => candidate.role === "assistant", + ); + const lastUserIndex = previousUserIndex(messages, index); + // Older/resumed transcripts may contain a visible assistant + // fragment without the client turn id. If it precedes this + // tagged group, it still belongs to the same user turn and has + // already claimed the speaker label. + const hasExternalAssistantBefore = messages + .slice(lastUserIndex + 1, index) + .some((candidate) => candidate.role === "assistant" && candidate.turnId !== message.turnId); + const headingOffset = hasExternalAssistantBefore ? 1 : 0; + const assistantRows = (items: Array, continuationOffset = 0) => + items.map((assistant, assistantIndex) => ( + 0} + /> + )); return ( + {firstActionIndex >= 0 ? assistantRows(beforeActions, headingOffset) : null} {reasoning ? ( ) : null} {message.turnId === latestTurnId && subagentChips.length > 0 ? ( ) : null} - + {showActivity ? ( + + ) : null} + {firstActionIndex >= 0 + ? assistantRows(afterActions, headingOffset + beforeActions.length) + : assistantRows(afterActions, headingOffset)} ); } @@ -3195,29 +3156,14 @@ export function AgentPanel({ // The eyebrow names a speaker, and the speaker does not change // between two consecutive blocks from the agent. Repeating it // splits one answer into two that look like different turns. - const previous = messages[index - 1]; - const continuation = previous?.role === "assistant" && !previous.turnId; - return ( -
- {continuation ? null : ( -
CALICODE
- )} -
- {message.panel ? ( - - ) : ( - - )} -
-
- ); + const lastUserIndex = previousUserIndex(messages, index); + const continuation = messages + .slice(lastUserIndex + 1, index) + .some((candidate) => candidate.role === "assistant"); + return ; })} - {busy && ( + {busy && !activeReasoning && !hasLiveActivity && (
@@ -3406,14 +3352,7 @@ export function AgentPanel({
- { - if (mode === "loop") stopAgent(); - else void runGoalCommand({ action: "clear" }); - }} - /> + {messages.some((message) => isTurnMarker(message)) ? (
node.id === selectedId) ?? null; return ( -
-
+
+
Graph {graph.goal} @@ -122,7 +122,7 @@ export function GraphPanel({ graph, onCancel, onRerun, onSelectNode, tickers }: ) : null}
-
+
); diff --git a/client/src/components/editor/ReasoningRow.tsx b/client/src/components/editor/ReasoningRow.tsx index dd93217e..fea220a7 100644 --- a/client/src/components/editor/ReasoningRow.tsx +++ b/client/src/components/editor/ReasoningRow.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState } from "react"; -import { Brain, ChevronRight } from "lucide-react"; +import { ChevronRight, Sparkles } from "lucide-react"; /** How long a finished block stays open before folding itself away. */ const AUTO_COLLAPSE_MS = 1000; @@ -11,8 +11,14 @@ export interface ReasoningRowProps { streaming: boolean; /** Milliseconds the block took (or has taken so far). */ durationMs?: number; - /** Start collapsed regardless of streaming state (used when replaying a saved transcript). */ + /** Start collapsed regardless of streaming state so the reader can opt into the detail. */ defaultCollapsed?: boolean; + /** + * Show the block duration in the settled label. The conversation panel + * keeps elapsed time on the turn/activity row, so it can use the DeepSeek + * style `Think` disclosure without putting two clocks beside one stream. + */ + showDuration?: boolean; } function formatDuration(ms: number): string { @@ -23,11 +29,17 @@ function formatDuration(ms: number): string { /** * A turn's reasoning as a collapsible block — shimmering while it streams, - * then labelled with how long it took, with the text under a left rule (the + * then labelled with its settled state, with the text under a left rule (the * opencode idiom). Opens and closes itself around the stream, but a manual * toggle wins from then on: the panel must never fight the reader. */ -export function ReasoningRow({ text, streaming, durationMs, defaultCollapsed = false }: ReasoningRowProps) { +export function ReasoningRow({ + text, + streaming, + durationMs, + defaultCollapsed = false, + showDuration = true, +}: ReasoningRowProps) { const [open, setOpen] = useState(!defaultCollapsed); const toggled = useRef(false); const bodyRef = useRef(null); @@ -56,9 +68,11 @@ export function ReasoningRow({ text, streaming, durationMs, defaultCollapsed = f const label = streaming ? "Thinking…" - : durationMs === undefined - ? "Thought" - : `Thought for ${formatDuration(durationMs)}`; + : !showDuration + ? "Think" + : durationMs === undefined + ? "Thought" + : `Thought for ${formatDuration(durationMs)}`; return (
@@ -72,12 +86,21 @@ export function ReasoningRow({ text, streaming, durationMs, defaultCollapsed = f aria-controls={bodyId} className="flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors hover:bg-surface-2 active:bg-surface-3" > - + {label} + {streaming && durationMs !== undefined ? ( + + {formatDuration(durationMs)} + + ) : null} { vi.useRealTimers(); }); -const goal = (overrides: Partial = {}): GoalState => ({ - goal: "make the jump test pass", - startedAtMs: NOW - 12_000, - evaluations: 3, - ...overrides, -}); - describe("RunStatusPill", () => { - it("renders nothing when no goal or loop is running", () => { - const { container } = render( undefined} />); + it("renders nothing when no loop is running", () => { + const { container } = render( undefined} />); expect(container.firstChild).toBeNull(); }); - it("names an active goal, its checks, and how long it has run", () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW); - render( undefined} />); - - expect(screen.getByText("Goal")).toBeTruthy(); - expect(screen.getByText("make the jump test pass")).toBeTruthy(); - expect(screen.getByText("3 checks")).toBeTruthy(); - expect(screen.getByText("12s")).toBeTruthy(); - }); - - it("counts a single evaluation in the singular", () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW); - render( undefined} />); - expect(screen.getByText("1 check")).toBeTruthy(); - }); - it("names a running loop and offers to stop it", () => { vi.useFakeTimers(); vi.setSystemTime(NOW); const onStop = vi.fn(); render( , @@ -56,57 +29,48 @@ describe("RunStatusPill", () => { expect(screen.getByText("Loop")).toBeTruthy(); expect(screen.getByText("ship the arena")).toBeTruthy(); expect(screen.getByText("1h 04m 07s")).toBeTruthy(); - // A loop reports no evaluator checks; showing "0 checks" would be a lie. - expect(screen.queryByText(/check/)).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Stop loop" })); - expect(onStop).toHaveBeenCalledWith("loop"); + expect(onStop).toHaveBeenCalledOnce(); }); - it("clears the goal from the pill", () => { + it("labels a paced loop as a watch", () => { vi.useFakeTimers(); vi.setSystemTime(NOW); - const onStop = vi.fn(); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Clear goal" })); - expect(onStop).toHaveBeenCalledWith("goal"); + render( + undefined} + />, + ); + expect(screen.getByText("every 15m")).toBeTruthy(); }); - it("ticks the elapsed label while the run continues", () => { + it("ticks the elapsed label while the loop continues", () => { vi.useFakeTimers(); vi.setSystemTime(NOW); - render( undefined} />); + render( + undefined} + />, + ); expect(screen.getByText("12s")).toBeTruthy(); act(() => { - vi.advanceTimersByTime(1_000); - }); - expect(screen.getByText("13s")).toBeTruthy(); - act(() => { - vi.advanceTimersByTime(60_000); + vi.advanceTimersByTime(61_000); }); expect(screen.getByText("1m 13s")).toBeTruthy(); }); - it("stops ticking once nothing is running", () => { + it("stops ticking once the loop is gone", () => { vi.useFakeTimers(); vi.setSystemTime(NOW); - const { rerender } = render( undefined} />); + const { rerender } = render( + undefined} />, + ); expect(vi.getTimerCount()).toBe(1); - rerender( undefined} />); + rerender( undefined} />); expect(vi.getTimerCount()).toBe(0); }); - - it("prefers the loop when a stale goal is still set — the loop owns Stop", () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW); - render( - undefined} />, - ); - expect(screen.getByText("Loop")).toBeTruthy(); - expect(screen.queryByText("Goal")).toBeNull(); - expect(screen.getByRole("button", { name: "Stop loop" })).toBeTruthy(); - }); }); diff --git a/client/src/components/editor/RunStatusPill.tsx b/client/src/components/editor/RunStatusPill.tsx index 965334a4..3befbf78 100644 --- a/client/src/components/editor/RunStatusPill.tsx +++ b/client/src/components/editor/RunStatusPill.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from "react"; -import { Repeat, Square, Target } from "lucide-react"; +import { Repeat, Square } from "lucide-react"; import { formatDuration } from "../../lib/activity"; -import type { GoalState } from "../../lib/goal"; /** The `/loop` run in flight, as much of it as the composer needs to show. */ export interface ActiveLoopRun { @@ -11,44 +10,7 @@ export interface ActiveLoopRun { every?: string | null; } -export type RunMode = "goal" | "loop"; - -interface ActiveRun { - mode: RunMode; - objective: string; - startedAtMs: number; - /** A paced loop's interval label; null for everything else. */ - every?: string | null; - /** Evaluator passes so far; a loop has no equivalent, hence null. */ - checks: number | null; -} - -/** - * A `/goal` cannot be set while a loop runs, so at most one of these is real. - * The loop still wins if both are somehow present: it is the run that owns the - * Stop button, and offering to clear a goal would not halt it. - */ -function activeRun(goal: GoalState | null, loop: ActiveLoopRun | null): ActiveRun | null { - if (loop) - return { - mode: "loop", - objective: loop.objective, - startedAtMs: loop.startedAtMs, - every: loop.every ?? null, - checks: null, - }; - if (goal) { - return { - mode: "goal", - objective: goal.goal, - startedAtMs: goal.startedAtMs, - checks: Math.max(0, Math.floor(goal.evaluations)), - }; - } - return null; -} - -function RunStatusPillBody({ run, onStop }: { run: ActiveRun; onStop: (mode: RunMode) => void }) { +function RunStatusPillBody({ loop, onStop }: { loop: ActiveLoopRun; onStop: () => void }) { const [nowMs, setNowMs] = useState(() => Date.now()); // The ticker lives here, not in the panel: a state update one level up would // re-render the whole transcript once a second for a label this small. @@ -56,30 +18,24 @@ function RunStatusPillBody({ run, onStop }: { run: ActiveRun; onStop: (mode: Run const timer = window.setInterval(() => setNowMs(Date.now()), 1000); return () => window.clearInterval(timer); }, []); - const elapsed = formatDuration(Math.max(0, nowMs - run.startedAtMs), "clock"); - const Icon = run.mode === "loop" ? Repeat : Target; + const elapsed = formatDuration(Math.max(0, nowMs - loop.startedAtMs), "clock"); return (
- - {run.mode === "loop" ? "Loop" : "Goal"} + + Loop {/* A watch is a different thing from a run-to-done loop; the pill has to say which one is up there. */} - {run.every ? every {run.every} : null} - {run.objective} - {run.checks != null ? ( - - {run.checks} {run.checks === 1 ? "check" : "checks"} - - ) : null} + {loop.every ? every {loop.every} : null} + {loop.objective} {elapsed} + + + {activeProject ? ( +
+ + + Currently open: {activeProject.title} + + Select another project below to switch context. +
+ ) : null} + + {!hasProjects ? ( + + ) : ( +
+
+
+

+ Your games +

+

Open a project to continue with its chats and workspace.

+
+ + {projects.length} {projects.length === 1 ? "project" : "projects"} + +
+ +
+ {projects.map((project) => { + const projectSessions = sessions[project.slug] ?? []; + const active = project.slug === activeSlug; + return ( + + ); + })} +
+
+ )} + +
+ cali + Projects are the boundary for agent context and file changes. +
+
+ + ); +} + +function EmptyProjectState({ + coreStatus, + onNewProject, +}: { + coreStatus: CoreConnectionState; + onNewProject: () => void; +}) { + return ( +
+
+ + + +

Start with a game

+

+ Create a new scene or open a folder you already have. Your prompt will stay scoped to that project. +

+ {coreStatus === "offline" ? ( +

Core is offline. Reconnect to load saved projects.

+ ) : null} + +
+
+ ); +} + +function workspaceLabel(root: string): string { + const normalized = root.trim().replace(/[\\/]+$/, ""); + const parts = normalized.split(/[\\/]/).filter(Boolean); + return parts.at(-1) ?? root; +} + diff --git a/client/src/components/workspace/WorkspaceTabs.test.tsx b/client/src/components/workspace/WorkspaceTabs.test.tsx index b8b4a3ff..312954bf 100644 --- a/client/src/components/workspace/WorkspaceTabs.test.tsx +++ b/client/src/components/workspace/WorkspaceTabs.test.tsx @@ -1,6 +1,13 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { WORKSPACE_TABS, WorkspaceTabs, nextTabId, tabKind, type WorkspaceTabId } from "./WorkspaceTabs"; +import { + WORKSPACE_TABS, + WorkspaceTabPicker, + WorkspaceTabs, + nextTabId, + tabKind, + type WorkspaceTabId, +} from "./WorkspaceTabs"; afterEach(cleanup); @@ -78,6 +85,13 @@ describe("WorkspaceTabs", () => { expect(screen.queryByRole("tab", { name: "scene" })).toBeNull(); }); + it("supports a fresh dock with no active tab", () => { + renderTabs({ openTabs: [], active: null }); + + expect(screen.queryAllByRole("tab")).toHaveLength(0); + expect(screen.getByRole("button", { name: "Add view" }).hasAttribute("disabled")).toBe(false); + }); + it("closes a tab through its own control", () => { const { onClose } = renderTabs({ openTabs: ["play", "code"], active: "play" }); @@ -106,10 +120,11 @@ describe("WorkspaceTabs", () => { expect(onChange).not.toHaveBeenCalled(); }); - it("hides the close control when one tab is left, so the dock cannot be emptied", () => { - renderTabs({ openTabs: ["play"], active: "play" }); + it("keeps a close control on the final tab so the dock can return to blank", () => { + const { onClose } = renderTabs({ openTabs: ["play"], active: "play" }); - expect(screen.queryByRole("button", { name: /Close .* tab/ })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Close Play tab" })); + expect(onClose).toHaveBeenCalledWith("play"); }); it("offers only unopened views under the add button", async () => { @@ -174,6 +189,17 @@ describe("WorkspaceTabs", () => { }); }); +describe("empty workspace picker", () => { + it("asks what to show and opens the selected view", () => { + const onSelect = vi.fn(); + render(); + + expect(screen.getByRole("heading", { name: "What do you want to show here?" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Show Play" })); + expect(onSelect).toHaveBeenCalledWith("play"); + }); +}); + describe("repeatable views", () => { it("splits an instance id into its view", () => { expect(tabKind("sidechat")).toBe("sidechat"); diff --git a/client/src/components/workspace/WorkspaceTabs.tsx b/client/src/components/workspace/WorkspaceTabs.tsx index b98e6f04..e8786f22 100644 --- a/client/src/components/workspace/WorkspaceTabs.tsx +++ b/client/src/components/workspace/WorkspaceTabs.tsx @@ -15,10 +15,23 @@ import { Play, Plus, SquareTerminal, + Workflow, X, } from "lucide-react"; -export const WORKSPACE_TABS = ["play", "code", "art", "build", "scene", "test", "terminal", "browser", "sidechat", "reports"] as const; +export const WORKSPACE_TABS = [ + "play", + "code", + "art", + "build", + "scene", + "test", + "terminal", + "browser", + "sidechat", + "graph", + "reports", +] as const; export type WorkspaceTab = (typeof WORKSPACE_TABS)[number]; @@ -53,13 +66,13 @@ export function nextTabId(kind: WorkspaceTab, open: readonly WorkspaceTabId[]): } interface WorkspaceTabsProps { - /** Views open as tabs, in strip order. Never empty. */ + /** Views open as tabs, in strip order. The strip may be empty on first use. */ openTabs: readonly WorkspaceTabId[]; - active: WorkspaceTabId; + active: WorkspaceTabId | null; onChange: (tab: WorkspaceTabId) => void; /** Open a view. Repeatable views open another instance. */ onAdd: (tab: WorkspaceTab) => void; - /** Close a tab. The last remaining tab cannot be closed. */ + /** Close a tab. Closing the final tab returns the dock to its blank picker. */ onClose: (tab: WorkspaceTabId) => void; badges: Partial>; /** @@ -90,6 +103,7 @@ const TAB_META = { terminal: { label: "Terminal", icon: SquareTerminal }, browser: { label: "Browser", icon: Globe }, sidechat: { label: "Side chat", icon: MessageCircleQuestion }, + graph: { label: "Graph", icon: Workflow }, reports: { label: "Reports", icon: FileChartColumn }, } satisfies Record; @@ -122,14 +136,15 @@ export function WorkspaceTabs({ // view from elsewhere in the app (the header's side-chat button, a badge) // is exactly when that happens. useEffect(() => { + if (!active) return; document .getElementById(`workspace-tab-${active}`) ?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [active]); - // Closing the last tab would leave the dock with nothing to show, so the - // affordance disappears rather than failing on click. - const closable = openTabs.length > 1; + // A fresh dock starts empty, and closing the final view returns it there so + // the chooser remains reachable without a reload or a storage reset. + const closable = openTabs.length > 0; // Views not currently in the strip. A repeatable view drops out once one is // open: "Add view" is for reaching a view you do not have, and stacking a // second side chat is a deliberate act — `/side` — not a menu pick that @@ -145,7 +160,7 @@ export function WorkspaceTabs({ role="tablist" aria-label="Workspace" aria-orientation="horizontal" - className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]" + className="scrollbar-none flex h-8 min-w-0 shrink-0 flex-1 items-center gap-1 overflow-x-auto [mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]" > {openTabs.map((tab, index) => { const selected = tab === active; @@ -307,3 +322,54 @@ export function WorkspaceTabs({
); } + +interface WorkspaceTabPickerProps { + /** Open a view in the dock and make it active. */ + onSelect: (tab: WorkspaceTab) => void; +} + +/** + * First-use state for the tools dock. It is intentionally separate from the + * tab strip so an empty dock still has the same chrome and add menu as a dock + * with tabs, while the content area answers the obvious next question. + */ +export function WorkspaceTabPicker({ onSelect }: WorkspaceTabPickerProps) { + const choices = WORKSPACE_TABS.filter((tab) => tab !== "graph"); + + return ( +
+
+ +
+

What do you want to show here?

+

+ Choose a workspace tab to get started. +

+
+ {choices.map((tab) => { + const meta = TAB_META[tab]; + const Icon = meta.icon; + return ( + + ); + })} +
+
+ ); +} diff --git a/client/src/lib/checkpoints.test.ts b/client/src/lib/checkpoints.test.ts index e51fab9d..bdb25289 100644 --- a/client/src/lib/checkpoints.test.ts +++ b/client/src/lib/checkpoints.test.ts @@ -1,75 +1,16 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { - AUTO_CHECKPOINT_INTERVAL_MS, - AUTO_CHECKPOINT_KEEP, checkpointTakenAtMs, - clearAutoCheckpoints, - dueForCheckpoint, formatCheckpointAge, formatCheckpointList, parseRestoreArgs, - readAutoCheckpoints, - recordAutoCheckpoint, restoreWarning, - type AutoCheckpoint, } from "./checkpoints"; -function entry(index: number): AutoCheckpoint { - return { - id: `cp-${1_700_000_000_000 + index}`, - takenAtMs: 1_700_000_000_000 + index, - reason: "loop", - objective: "polish the game", - }; -} - -beforeEach(clearAutoCheckpoints); - -describe("the automatic checkpoint registry", () => { - it("forgets the oldest ids once retention is reached", () => { - for (let index = 1; index <= AUTO_CHECKPOINT_KEEP + 5; index += 1) { - recordAutoCheckpoint("demo", entry(index)); - } - const kept = readAutoCheckpoints("demo"); - - expect(kept).toHaveLength(AUTO_CHECKPOINT_KEEP); - expect(kept[0].id).toBe(entry(AUTO_CHECKPOINT_KEEP + 5).id); - expect(kept.map((item) => item.id)).not.toContain(entry(1).id); - }); - - it("keeps games apart", () => { - recordAutoCheckpoint("demo", entry(1)); - recordAutoCheckpoint("other", entry(2)); - - expect(readAutoCheckpoints("demo").map((item) => item.id)).toEqual([entry(1).id]); - expect(readAutoCheckpoints("other").map((item) => item.id)).toEqual([entry(2).id]); - }); - - it("survives a corrupt store rather than throwing into a run", () => { - localStorage.setItem("calicode-auto-checkpoints", "{ not json"); - expect(readAutoCheckpoints("demo")).toEqual([]); - - localStorage.setItem("calicode-auto-checkpoints", JSON.stringify({ demo: [{ id: 7 }, entry(1)] })); - expect(readAutoCheckpoints("demo").map((item) => item.id)).toEqual([entry(1).id]); - }); -}); - -describe("throttling", () => { - it("always allows the first checkpoint of a run", () => { - expect(dueForCheckpoint(null, 1_700_000_000_000)).toBe(true); - }); - - it("suppresses a second checkpoint inside the window and allows one after it", () => { - const last = 1_700_000_000_000; - expect(dueForCheckpoint(last, last + AUTO_CHECKPOINT_INTERVAL_MS - 1)).toBe(false); - expect(dueForCheckpoint(last, last + AUTO_CHECKPOINT_INTERVAL_MS)).toBe(true); - }); -}); - describe("checkpoint ids", () => { - it("reads the timestamp core minted into the id, including the collision suffix", () => { + it("reads timestamps from project and git ids, including collision suffixes", () => { expect(checkpointTakenAtMs("cp-1700000000000")).toBe(1_700_000_000_000); - expect(checkpointTakenAtMs("cp-1700000000000-3")).toBe(1_700_000_000_000); + expect(checkpointTakenAtMs("git-1700000000000-3")).toBe(1_700_000_000_000); }); it("refuses to invent an age for an id it cannot read", () => { @@ -100,32 +41,32 @@ describe("/restore arguments", () => { }); describe("the /checkpoints listing", () => { - it("names each id with its age and the run it guarded", () => { + it("names each core-owned restore point with its age and kind", () => { const now = 1_700_000_000_000; const text = formatCheckpointList( [ - { id: "cp-a", takenAtMs: now - 5 * 60_000, reason: "loop", objective: "polish the game" }, - { id: "cp-b", takenAtMs: now - 3 * 3_600_000, reason: "goal", objective: "make the tests pass" }, + { id: "git-a", createdAtMs: now - 5 * 60_000, kind: "git" }, + { id: "cp-b", createdAtMs: now - 3 * 3_600_000, kind: "project" }, ], now, ); - expect(text).toContain("cp-a — 5m ago, before a /loop turn (polish the game)"); - expect(text).toContain("cp-b — 3h ago, before a /goal turn (make the tests pass)"); + expect(text).toContain("git-a — 5m ago, git snapshot"); + expect(text).toContain("cp-b — 3h ago, project snapshot"); expect(text).toContain("/restore confirm"); }); + + it("explains when the core inventory is empty", () => { + expect(formatCheckpointList([], Date.now())).toContain("A /loop takes one before its first iteration"); + }); }); describe("restoreWarning", () => { it("tells a git restore point apart from a project copy", () => { - // The two mechanisms cover opposite halves of the game, so naming the - // wrong one is how someone approves a rollback that does not do what the - // confirmation just described. const git = restoreWarning("cp-git-1", "2m ago", true, "git"); expect(git).toContain("tracked files"); expect(git).toContain("HEAD and your branch do not move"); expect(git).toContain("does NOT restore untracked files"); - // A git restore point says nothing about the CaliCode project document. expect(git).toContain("project.json"); const copy = restoreWarning("cp-1700000000000", "2m ago", true, "project"); @@ -135,7 +76,6 @@ describe("restoreWarning", () => { }); it("defaults to the more conservative project wording", () => { - // An unreachable core must not promise the workspace came back. expect(restoreWarning("cp-1", null, true)).toContain("does NOT restore the attached workspace folder"); }); diff --git a/client/src/lib/checkpoints.ts b/client/src/lib/checkpoints.ts index 179d8196..383fd318 100644 --- a/client/src/lib/checkpoints.ts +++ b/client/src/lib/checkpoints.ts @@ -1,115 +1,12 @@ -// Restore points for unattended runs. -// -// Core gives us exactly two primitives: `project_checkpoint` copies the project -// directory and returns `{ id }`, and `project_revert` copies one back. There -// is no RPC that lists checkpoints and none that deletes one, so the ids this -// panel creates are remembered here — a restore point nobody can name is not a -// restore point. The same gap means retention below can only bound the *list*; -// bounding the bytes on disk needs a core method that does not exist yet. +// Restore-point presentation and confirmation for unattended `/loop` runs. +// Core owns creation, inventory, pruning, and restoration; keeping inventory +// server-side means a loop remains recoverable after a reload or in another +// window. -export type AutoCheckpointReason = "loop" | "goal"; - -export interface AutoCheckpoint { - /** Core's checkpoint id, e.g. `cp-1723600000000`. */ +export interface ListedCheckpoint { id: string; - takenAtMs: number; - reason: AutoCheckpointReason; - /** The loop/goal objective this checkpoint was taken in front of. */ - objective: string; -} - -/** - * How long an automatic checkpoint stays fresh enough to skip taking another. - * - * Each one is a full copy of the project directory, and a three-day `/loop` - * would otherwise take hundreds. Fifteen minutes bounds the work a single - * destructive turn can cost to roughly one iteration while keeping a 72-hour - * run near 288 copies — already more than anyone wants, which is why the - * interval sits here rather than at one or five minutes. - */ -export const AUTO_CHECKPOINT_INTERVAL_MS = 15 * 60_000; - -/** Automatic restore points remembered per game. Older ids are forgotten. */ -export const AUTO_CHECKPOINT_KEEP = 10; - -/** Objective text kept beside an id, so the list stays one line per entry. */ -const OBJECTIVE_CHARS = 60; - -const STORAGE_KEY = "calicode-auto-checkpoints"; - -type Registry = Record; - -function isReason(value: unknown): value is AutoCheckpointReason { - return value === "loop" || value === "goal"; -} - -function toEntry(value: unknown): AutoCheckpoint | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - const record = value as Record; - if (typeof record.id !== "string" || record.id.length === 0) return null; - if (typeof record.takenAtMs !== "number" || !Number.isFinite(record.takenAtMs)) return null; - if (!isReason(record.reason)) return null; - return { - id: record.id, - takenAtMs: record.takenAtMs, - reason: record.reason, - objective: typeof record.objective === "string" ? record.objective : "", - }; -} - -function readRegistry(): Registry { - try { - const parsed: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; - const registry: Registry = {}; - for (const [slug, value] of Object.entries(parsed as Record)) { - if (!Array.isArray(value)) continue; - registry[slug] = value.flatMap((item) => { - const entry = toEntry(item); - return entry ? [entry] : []; - }); - } - return registry; - } catch { - return {}; - } -} - -function writeRegistry(registry: Registry): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(registry)); - } catch { - // A full or disabled localStorage must not break a run: the checkpoint - // itself already exists on disk, only the name for it is lost. - } -} - -/** Newest first, capped at `AUTO_CHECKPOINT_KEEP`. */ -export function pruneAutoCheckpoints(entries: readonly AutoCheckpoint[]): AutoCheckpoint[] { - return [...entries].sort((left, right) => right.takenAtMs - left.takenAtMs).slice(0, AUTO_CHECKPOINT_KEEP); -} - -export function readAutoCheckpoints(slug: string): AutoCheckpoint[] { - return pruneAutoCheckpoints(readRegistry()[slug] ?? []); -} - -export function recordAutoCheckpoint(slug: string, entry: AutoCheckpoint): AutoCheckpoint[] { - const registry = readRegistry(); - const kept = pruneAutoCheckpoints([entry, ...(registry[slug] ?? []).filter((item) => item.id !== entry.id)]); - registry[slug] = kept; - writeRegistry(registry); - return kept; -} - -/** Test seam: drops every remembered id for every game. */ -export function clearAutoCheckpoints(): void { - writeRegistry({}); -} - -/** True when no checkpoint has been taken inside the throttle window. */ -export function dueForCheckpoint(lastAtMs: number | null, nowMs: number): boolean { - if (lastAtMs === null) return true; - return nowMs - lastAtMs >= AUTO_CHECKPOINT_INTERVAL_MS; + kind: "git" | "project"; + createdAtMs: number; } /** @@ -120,7 +17,7 @@ export function dueForCheckpoint(lastAtMs: number | null, nowMs: number): boolea * still carries an age even though this panel never recorded it. */ export function checkpointTakenAtMs(id: string): number | null { - const match = /^cp-(\d+)(?:-\d+)?$/.exec(id); + const match = /^(?:cp|git)-(\d+)(?:-\d+)?$/.exec(id); if (!match) return null; const millis = Number(match[1]); return Number.isFinite(millis) && millis > 0 ? millis : null; @@ -135,23 +32,17 @@ export function formatCheckpointAge(ageMs: number): string { return `${Math.floor(hours / 24)}d ago`; } -function objectiveOf(entry: AutoCheckpoint): string { - const trimmed = entry.objective.trim(); - if (!trimmed) return ""; - return trimmed.length > OBJECTIVE_CHARS ? `${trimmed.slice(0, OBJECTIVE_CHARS)}…` : trimmed; -} - -export function formatCheckpointList(entries: readonly AutoCheckpoint[], nowMs: number): string { +export function formatCheckpointList(entries: readonly ListedCheckpoint[], nowMs: number): string { if (entries.length === 0) { return [ - "No restore points recorded yet.", - "One is taken automatically before the first /loop iteration and the first /goal round, then at most one every 15 minutes.", + "No restore points exist yet.", + "A /loop takes one before its first iteration, then at most one every 15 minutes.", ].join("\n"); } const lines = entries.map((entry) => { - const age = formatCheckpointAge(Math.max(0, nowMs - entry.takenAtMs)); - const objective = objectiveOf(entry); - return `• ${entry.id} — ${age}, before a /${entry.reason} turn${objective ? ` (${objective})` : ""}`; + const age = formatCheckpointAge(Math.max(0, nowMs - entry.createdAtMs)); + const kind = entry.kind === "git" ? "git snapshot" : "project snapshot"; + return `• ${entry.id} — ${age}, ${kind}`; }); return [ "Restore points, newest first:", diff --git a/client/src/lib/goal.test.ts b/client/src/lib/goal.test.ts deleted file mode 100644 index 419d8200..00000000 --- a/client/src/lib/goal.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - buildEvaluatorTranscript, - buildTranscriptWindow, - formatGoalStatus, - goalContinuationPrompt, - goalIsVerifiable, - parseGoalCommand, -} from "./goal"; - -describe("parseGoalCommand", () => { - test("empty arguments show the current goal", () => { - expect(parseGoalCommand("")).toEqual({ action: "show" }); - expect(parseGoalCommand(" ")).toEqual({ action: "show" }); - }); - - test("clear words retire the goal", () => { - expect(parseGoalCommand("clear")).toEqual({ action: "clear" }); - expect(parseGoalCommand("off")).toEqual({ action: "clear" }); - expect(parseGoalCommand(" DONE ")).toEqual({ action: "clear" }); - }); - - test("anything else sets the goal, trimmed", () => { - expect(parseGoalCommand(" ship the double jump ")).toEqual({ - action: "set", - goal: "ship the double jump", - }); - }); - - test("a clear word inside a longer goal still sets", () => { - expect(parseGoalCommand("clear the level of debris")).toEqual({ - action: "set", - goal: "clear the level of debris", - }); - }); -}); - -describe("buildTranscriptWindow", () => { - const messages = [ - { role: "user", content: "add a double jump" }, - { role: "assistant", content: "on it" }, - { role: "tool", content: "3 tests passed", tool: "run_tests" }, - ]; - - test("reports a whole transcript as untruncated", () => { - expect(buildTranscriptWindow(messages)).toMatchObject({ kept: 3, total: 3, truncated: false }); - }); - - test("counts what the budget dropped", () => { - const window = buildTranscriptWindow(messages, 45); - expect(window).toMatchObject({ kept: 1, total: 3, truncated: true }); - expect(window.text).toContain("3 tests passed"); - }); - - test("reports a head-clipped single entry as truncated", () => { - const window = buildTranscriptWindow([{ role: "user", content: "x".repeat(200) }], 40); - expect(window).toMatchObject({ kept: 1, total: 1, truncated: true }); - }); - - test("ignores entries with no content when counting", () => { - const window = buildTranscriptWindow([...messages, { role: "assistant", content: " " }]); - expect(window).toMatchObject({ kept: 3, total: 3, truncated: false }); - }); -}); - -describe("buildEvaluatorTranscript", () => { - const messages = [ - { role: "user", content: "add a double jump" }, - { role: "assistant", content: "on it" }, - { role: "tool", content: "3 tests passed", tool: "run_tests" }, - ]; - - test("labels every entry by role and names the tool", () => { - const excerpt = buildEvaluatorTranscript(messages); - expect(excerpt).toBe("user: add a double jump\nassistant: on it\ntool(run_tests): 3 tests passed"); - }); - - test("keeps the newest entries and drops the oldest when capped", () => { - const excerpt = buildEvaluatorTranscript(messages, 45); - expect(excerpt).toContain("tool(run_tests): 3 tests passed"); - expect(excerpt).not.toContain("add a double jump"); - expect(excerpt.length).toBeLessThanOrEqual(45); - }); - - test("truncates the front of a single over-long entry but keeps its label", () => { - const excerpt = buildEvaluatorTranscript( - [{ role: "tool", content: `${"x".repeat(200)}THE-END`, tool: "run_tests" }], - 40, - ); - expect(excerpt.startsWith("tool(run_tests): …")).toBe(true); - expect(excerpt.endsWith("THE-END")).toBe(true); - expect(excerpt.length).toBeLessThanOrEqual(40); - }); - - test("skips blank entries", () => { - const excerpt = buildEvaluatorTranscript([ - { role: "assistant", content: " " }, - { role: "user", content: "keep going" }, - ]); - expect(excerpt).toBe("user: keep going"); - }); - - test("returns nothing for an empty transcript or a zero budget", () => { - expect(buildEvaluatorTranscript([])).toBe(""); - expect(buildEvaluatorTranscript(messages, 0)).toBe(""); - }); -}); - -describe("goalContinuationPrompt", () => { - test("restates the goal, the reason, and the coming re-check", () => { - const prompt = goalContinuationPrompt("ship the jump", "the jump height is still 0", 2); - expect(prompt).toContain("Goal: ship the jump"); - expect(prompt).toContain("the jump height is still 0"); - expect(prompt).toContain("check 3"); - expect(prompt.split("\n")).toHaveLength(4); - }); - - test("reads as an instruction to continue", () => { - expect(goalContinuationPrompt("x goal", "y reason", 0).startsWith("Keep going")).toBe(true); - }); -}); - -describe("formatGoalStatus", () => { - test("reports no goal", () => { - expect(formatGoalStatus(null)).toBe("goal: none"); - }); - - test("a freshly set goal has not been checked", () => { - expect(formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 0 })).toBe( - "goal: ship the jump — set", - ); - }); - - test("counts checks and singularises one", () => { - expect(formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 3 })).toBe( - "goal: ship the jump — unmet (3 checks)", - ); - expect(formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 1 })).toBe( - "goal: ship the jump — unmet (1 check)", - ); - }); - - test("appends the latest evaluator reason", () => { - expect( - formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 2, lastReason: "no jump input yet" }), - ).toBe("goal: ship the jump — unmet (2 checks): no jump input yet"); - }); -}); - -describe("goalIsVerifiable", () => { - test("accepts goals with an observable end state", () => { - expect(goalIsVerifiable("add a double jump and cover it with a test")).toEqual({ ok: true }); - expect(goalIsVerifiable("hold the frame time under 16ms while 200 crates spawn")).toEqual({ ok: true }); - }); - - test("hints on goals too short to check", () => { - const verdict = goalIsVerifiable("fix it"); - expect(verdict.ok).toBe(false); - expect(verdict.hint).toContain("what should be true"); - }); - - test("hints on purely subjective wording", () => { - for (const goal of ["make the platforming feel better", "the lighting should look nicer overall"]) { - const verdict = goalIsVerifiable(goal); - expect(verdict.ok).toBe(false); - expect(verdict.hint).toContain("observable end state"); - } - }); - - test("a subjective word paired with a named check passes", () => { - expect(goalIsVerifiable("make the jump feel better until the jump test passes")).toEqual({ ok: true }); - expect(goalIsVerifiable("improve the frame time to 60 fps")).toEqual({ ok: true }); - }); - - test("does not flag ordinary goals that merely contain no metrics", () => { - expect(goalIsVerifiable("replace the placeholder crate mesh with the library one")).toEqual({ ok: true }); - }); -}); diff --git a/client/src/lib/goal.ts b/client/src/lib/goal.ts deleted file mode 100644 index dfd396af..00000000 --- a/client/src/lib/goal.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Session-scoped objective for `/goal`. The goal outlives a turn: after each -// one a tool-less evaluator judges it, and while it is unmet the evaluator's -// reason is injected as the next turn's directive. Everything here is pure so -// the panel owns the state and the RPC. - -export interface GoalState { - goal: string; - /** Epoch ms when it was set. */ - startedAtMs: number; - /** Turns evaluated so far. */ - evaluations: number; - /** Latest evaluator reason, shown in the status line. */ - lastReason?: string; -} - -export type GoalCommand = - | { action: "set"; goal: string } - | { action: "show" } - | { action: "clear" }; - -/** Words that retire a goal without the evaluator having to agree. */ -const CLEAR_WORDS = new Set(["clear", "off", "done"]); - -const DEFAULT_TRANSCRIPT_CHARS = 8000; - -/** Parse `/goal` arguments: empty → show, "clear"/"off"/"done" → clear, else set. */ -export function parseGoalCommand(args: string): GoalCommand { - const trimmed = args.trim(); - if (!trimmed) return { action: "show" }; - if (CLEAR_WORDS.has(trimmed.toLowerCase())) return { action: "clear" }; - return { action: "set", goal: trimmed }; -} - -function labelFor(entry: { role: string; tool?: string }): string { - const role = entry.role.trim().toLowerCase() || "message"; - const tool = entry.tool?.trim(); - // The evaluator has to tell a claim ("I fixed it") from a result, so tool - // entries carry the tool that produced them. - return tool ? `${role}(${tool})` : role; -} - -/** - * Drop the head of an over-long entry but keep its label; a bare tail would - * leave the evaluator unable to attribute the text. - */ -function truncateFront(line: string, limit: number): string { - if (line.length <= limit) return line; - const separator = line.indexOf(": "); - const label = separator > 0 ? line.slice(0, separator + 2) : ""; - const budget = limit - label.length - 1; - if (budget <= 0) return line.slice(line.length - limit); - return `${label}…${line.slice(line.length - budget)}`; -} - -export interface TranscriptWindow { - /** The excerpt itself. */ - text: string; - /** Entries that made the excerpt. */ - kept: number; - /** Entries with content that could have. */ - total: number; - /** True when the budget dropped an entry, or clipped the head of one. */ - truncated: boolean; -} - -/** - * The excerpt plus what it left out. - * - * The counts exist so a caller can say so: a reader who cannot tell that the - * head of a long run was dropped will read a confident answer about a step the - * model never saw as if it were grounded. - */ -export function buildTranscriptWindow( - messages: Array<{ role: string; content: string; tool?: string }>, - maxChars = DEFAULT_TRANSCRIPT_CHARS, -): TranscriptWindow { - const total = messages.filter((entry) => entry.content?.trim()).length; - const limit = Math.floor(maxChars); - if (!Number.isFinite(limit) || limit <= 0) { - return { text: "", kept: 0, total, truncated: total > 0 }; - } - const kept: string[] = []; - let used = 0; - let clipped = false; - // Walk backwards: the recent turns decide whether the goal is met, so the - // budget is spent from the newest entry outwards and the head is dropped. - for (let index = messages.length - 1; index >= 0; index -= 1) { - const entry = messages[index]; - const content = entry.content?.trim(); - if (!content) continue; - const line = `${labelFor(entry)}: ${content}`; - const cost = kept.length ? line.length + 1 : line.length; - if (used + cost <= limit) { - kept.unshift(line); - used += cost; - continue; - } - if (!kept.length) { - kept.push(truncateFront(line, limit)); - clipped = true; - } - break; - } - return { text: kept.join("\n"), kept: kept.length, total, truncated: clipped || kept.length < total }; -} - -/** The excerpt handed to the evaluator: the tail of the transcript, capped. */ -export function buildEvaluatorTranscript( - messages: Array<{ role: string; content: string; tool?: string }>, - maxChars = DEFAULT_TRANSCRIPT_CHARS, -): string { - return buildTranscriptWindow(messages, maxChars).text; -} - -/** The directive injected as the next turn's user message when a goal is unmet. */ -export function goalContinuationPrompt(goal: string, reason: string, evaluations: number): string { - const check = Math.max(0, Math.floor(evaluations)) + 1; - return [ - "Keep going — your goal is not met yet.", - `Goal: ${goal.trim()}`, - `Not met because: ${reason.trim()}`, - `Fix exactly that, then continue. It is re-checked after this turn (check ${check}).`, - ].join("\n"); -} - -/** One-line status for the transcript, e.g. `goal: ship the jump — unmet (3 checks)`. */ -export function formatGoalStatus(state: GoalState | null): string { - if (!state) return "goal: none"; - const goal = state.goal.trim(); - const evaluations = Math.max(0, Math.floor(state.evaluations)); - // A met goal is cleared by the caller, so any surviving state is unmet. - const head = - evaluations === 0 - ? `goal: ${goal} — set` - : `goal: ${goal} — unmet (${evaluations === 1 ? "1 check" : `${evaluations} checks`})`; - const reason = state.lastReason?.trim(); - return reason ? `${head}: ${reason}` : head; -} - -/** - * Words that describe a feeling rather than an end state. A tool-less - * evaluator cannot decide "nicer", so it would either never pass or pass - * arbitrarily. - */ -const SUBJECTIVE_WORDS = [ - "better", - "nicer", - "nice", - "good", - "great", - "cool", - "pretty", - "prettier", - "polished", - "polish", - "awesome", - "fun", - "clean", - "improve", - "improved", -]; - -/** - * Cues that a check was named anyway ("looks better once the jump test - * passes"). Their presence keeps a subjective word from tripping the hint. - */ -const CHECK_CUES = ["test", "tests", "passes", "pass", "assert", "until", "verify", "check", "fps", "ms", "error", "errors"]; - -const MIN_GOAL_CHARS = 12; - -/** - * Advisory only — the caller still sets the goal. This is a hint printed - * beside it, never a block, because the heuristic is deliberately crude and - * would otherwise reject perfectly good goals it does not understand. - */ -export function goalIsVerifiable(goal: string): { ok: boolean; hint?: string } { - const trimmed = goal.trim(); - if (trimmed.length < MIN_GOAL_CHARS) { - return { - ok: false, - hint: "That is short enough that I cannot tell when it is done. Say what should be true at the end.", - }; - } - const words = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); - const vague = SUBJECTIVE_WORDS.find((word) => words.includes(word)); - if (!vague) return { ok: true }; - const hasCheck = words.some((word) => CHECK_CUES.includes(word)) || /\d/.test(trimmed); - if (hasCheck) return { ok: true }; - return { - ok: false, - hint: `"${vague}" has no observable end state. Name the check that decides it, e.g. "…until the jump test passes".`, - }; -} diff --git a/client/src/lib/loopGuards.test.ts b/client/src/lib/loopGuards.test.ts deleted file mode 100644 index 1f4c95ad..00000000 --- a/client/src/lib/loopGuards.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { budgetNotice, detectStall, exhaustionPrompt, type LoopAction } from "./loopGuards"; - -const action = (tool: string, signature: string, failed?: boolean): LoopAction => ({ tool, signature, failed }); - -const repeat = (count: number, value: LoopAction): LoopAction[] => Array.from({ length: count }, () => ({ ...value })); - -describe("detectStall", () => { - test("no actions is ok", () => { - expect(detectStall([])).toEqual({ level: "ok", reason: "" }); - }); - - test("a varied sequence stays ok", () => { - const verdict = detectStall([ - action("read_file", "a.ts"), - action("edit_file", "a.ts"), - action("run_tests", "unit"), - action("read_file", "b.ts"), - ]); - expect(verdict.level).toBe("ok"); - expect(verdict.reason).toBe(""); - }); - - test("the same tool with different arguments is progress, not a stall", () => { - const verdict = detectStall([ - action("read_file", "a.ts"), - action("read_file", "b.ts"), - action("read_file", "c.ts"), - action("read_file", "d.ts"), - action("read_file", "e.ts"), - ]); - expect(verdict.level).toBe("ok"); - }); - - test("warns at two identical actions in a row", () => { - const verdict = detectStall(repeat(2, action("run_tests", "unit"))); - expect(verdict.level).toBe("warn"); - expect(verdict.reason).toContain("run_tests"); - expect(verdict.reason).toContain("2 times"); - }); - - test("blocks at five identical actions in a row", () => { - const verdict = detectStall(repeat(5, action("run_tests", "unit"))); - expect(verdict.level).toBe("block"); - expect(verdict.reason).toContain("5 times"); - }); - - test("an intervening different action resets the run", () => { - const verdict = detectStall([ - ...repeat(4, action("run_tests", "unit")), - action("edit_file", "player.ts"), - action("run_tests", "unit"), - ]); - expect(verdict.level).toBe("ok"); - }); - - test("counts consecutive failures across different actions", () => { - const warn = detectStall([ - action("read_file", "a.ts"), - action("edit_file", "a.ts", true), - action("run_tests", "unit", true), - ]); - expect(warn.level).toBe("warn"); - expect(warn.reason).toContain("2 steps"); - expect(warn.reason).toContain("run_tests"); - - const block = detectStall([ - action("edit_file", "a.ts", true), - action("edit_file", "b.ts", true), - action("run_tests", "unit", true), - action("edit_file", "c.ts", true), - action("run_tests", "e2e", true), - ]); - expect(block.level).toBe("block"); - expect(block.reason).toContain("5 steps"); - }); - - test("a successful step ends the failure run", () => { - const verdict = detectStall([ - action("edit_file", "a.ts", true), - action("edit_file", "b.ts", true), - action("run_tests", "unit"), - ]); - expect(verdict.level).toBe("ok"); - }); - - test("thresholds are configurable", () => { - const actions = repeat(3, action("run_tests", "unit")); - expect(detectStall(actions, { warnAfter: 4, blockAfter: 6 }).level).toBe("ok"); - expect(detectStall(actions, { warnAfter: 2, blockAfter: 3 }).level).toBe("block"); - }); - - test("repeated failing calls report the repetition", () => { - const verdict = detectStall(repeat(3, action("run_tests", "unit", true))); - expect(verdict.level).toBe("warn"); - expect(verdict.reason).toContain("identical arguments"); - }); -}); - -describe("budgetNotice", () => { - test("states used and remaining iterations", () => { - expect(budgetNotice(3, 12)).toContain("iteration 3 of 12"); - expect(budgetNotice(3, 12)).toContain("9 iterations remaining"); - }); - - test("singularises the last iteration", () => { - expect(budgetNotice(11, 12)).toContain("1 iteration remaining"); - }); - - test("tells the model to wrap up when the budget is gone", () => { - expect(budgetNotice(12, 12)).toContain("none remaining"); - expect(budgetNotice(99, 12)).toContain("iteration 12 of 12"); - }); -}); - -describe("exhaustionPrompt", () => { - test("asks for a handoff instead of more tool calls", () => { - const prompt = exhaustionPrompt(" ship the double jump ", 25); - expect(prompt).toContain("25 iterations"); - expect(prompt).toContain("Goal: ship the double jump"); - expect(prompt).toContain("Stop calling tools"); - expect(prompt).toContain("what still remains"); - expect(prompt).toContain("exact next step"); - }); -}); diff --git a/client/src/lib/loopGuards.ts b/client/src/lib/loopGuards.ts deleted file mode 100644 index df710696..00000000 --- a/client/src/lib/loopGuards.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Runaway detection for `/loop`. An iteration counter alone cannot separate a -// loop that is working from one retrying the same failing call, so the count is -// paired with two cheap signals — identical repeated actions and repeated -// failures — and the cap ends in a handoff rather than a hard stop. - -export interface LoopAction { - /** Tool name, or a stable label for the step. */ - tool: string; - /** Stable signature of the arguments — the caller stringifies. */ - signature: string; - /** Did the step fail? */ - failed?: boolean; -} - -export interface StallVerdict { - /** "ok" | "warn" | "block" */ - level: "ok" | "warn" | "block"; - /** Human sentence naming what is repeating; empty when level is "ok". */ - reason: string; -} - -const DEFAULT_WARN_AFTER = 2; -const DEFAULT_BLOCK_AFTER = 5; - -const OK: StallVerdict = { level: "ok", reason: "" }; - -function severity(level: StallVerdict["level"]): number { - return level === "block" ? 2 : level === "warn" ? 1 : 0; -} - -function levelForRun(run: number, warnAfter: number, blockAfter: number): StallVerdict["level"] { - if (run >= blockAfter) return "block"; - if (run >= warnAfter) return "warn"; - return "ok"; -} - -function plural(count: number, unit: string): string { - return `${count} ${unit}${count === 1 ? "" : "s"}`; -} - -/** - * Repeated identical actions and repeated failures across recent iterations. - * - * Only the run at the tail counts: a false positive kills a healthy loop, which - * is far worse than letting one extra repeat through, so anything varied — - * including a repeat that has since been broken by a different action — stays - * "ok". - */ -export function detectStall( - actions: LoopAction[], - options?: { warnAfter?: number; blockAfter?: number }, -): StallVerdict { - // A run of one is just an action, so thresholds below 2 are meaningless and - // would flag every loop on its first step. - const warnAfter = Math.max(2, Math.floor(options?.warnAfter ?? DEFAULT_WARN_AFTER)); - const blockAfter = Math.max(warnAfter, Math.floor(options?.blockAfter ?? DEFAULT_BLOCK_AFTER)); - if (!actions.length) return OK; - - const last = actions[actions.length - 1]; - let repeats = 1; - for (let index = actions.length - 2; index >= 0; index -= 1) { - const action = actions[index]; - if (action.tool !== last.tool || action.signature !== last.signature) break; - repeats += 1; - } - - let failures = 0; - for (let index = actions.length - 1; index >= 0; index -= 1) { - if (!actions[index].failed) break; - failures += 1; - } - - const repeatLevel = levelForRun(repeats, warnAfter, blockAfter); - const failureLevel = levelForRun(failures, warnAfter, blockAfter); - if (severity(repeatLevel) >= severity(failureLevel)) { - if (repeatLevel === "ok") return OK; - return { - level: repeatLevel, - reason: `Ran the same \`${last.tool}\` call with identical arguments ${plural(repeats, "time")} in a row.`, - }; - } - return { - level: failureLevel, - reason: `The last ${plural(failures, "step")} all failed, most recently \`${last.tool}\`.`, - }; -} - -/** Remaining-budget line so the model can pace itself and wrap up deliberately. */ -export function budgetNotice(iteration: number, cap: number): string { - const total = Math.max(0, Math.floor(cap)); - const used = Math.min(total, Math.max(0, Math.floor(iteration))); - const remaining = total - used; - if (remaining <= 0) { - return `Budget: iteration ${used} of ${total} — none remaining. Finish and summarise now.`; - } - return `Budget: iteration ${used} of ${total} — ${plural(remaining, "iteration")} remaining. Pace the work so it lands inside that budget.`; -} - -/** The final, tools-stripped instruction when a loop runs out of budget. */ -export function exhaustionPrompt(goal: string, iterations: number): string { - const used = Math.max(0, Math.floor(iterations)); - return [ - `The iteration budget is spent after ${plural(used, "iteration")} and the goal is not confirmed done.`, - `Goal: ${goal.trim()}`, - "Stop calling tools. Reply with a handoff: what you accomplished, what still remains, and the exact next step to take.", - ].join("\n"); -} diff --git a/client/src/lib/loopReports.ts b/client/src/lib/loopReports.ts index f1ab2d68..3287f7da 100644 --- a/client/src/lib/loopReports.ts +++ b/client/src/lib/loopReports.ts @@ -159,7 +159,6 @@ export interface LoopRun { profile: "standard" | "aaa"; status: "running" | "completed" | "stopped" | "failed"; iteration: number; - maxIterations: number; startedAtMs: number; intervalMs?: number; sessionId?: string; diff --git a/client/src/lib/rpc.ts b/client/src/lib/rpc.ts index 79097cb9..26f4e713 100644 --- a/client/src/lib/rpc.ts +++ b/client/src/lib/rpc.ts @@ -174,7 +174,6 @@ export type AgentEvent = { loopId?: string; /** `loop.iteration` only. */ iteration?: number; - maxIterations?: number; /** `loop.iteration` only: the prompt core is about to send. */ prompt?: string; /** `loop.finished` only: the run's terminal view (see `LoopRun`). */ diff --git a/client/src/lib/slashCommands.test.ts b/client/src/lib/slashCommands.test.ts index 8822cbc4..c0b536b1 100644 --- a/client/src/lib/slashCommands.test.ts +++ b/client/src/lib/slashCommands.test.ts @@ -52,6 +52,11 @@ describe("parseSlash", () => { expect(parsed?.name).toBe("nope"); expect(parsed?.command).toBeNull(); }); + + test("does not expose the retired /goal command", () => { + expect(parseSlash("/goal ship it")?.command).toBeNull(); + expect(SLASH_COMMANDS.some((command) => command.name === "goal")).toBe(false); + }); }); describe("/side", () => { @@ -288,7 +293,7 @@ describe("runsBare", () => { test("every other built-in completes into the composer instead", () => { // Named explicitly because these are the ones that used to fire the moment // the word was spelled, before any instructions could be added to them. - for (const name of ["compact", "clear", "goal", "usage", "diff", "loop", "model", "restore"]) { + for (const name of ["compact", "clear", "usage", "diff", "loop", "model", "restore"]) { expect(runsBare(SLASH_COMMANDS.find((command) => command.name === name)!)).toBe(false); } }); @@ -333,6 +338,12 @@ describe("/loop", () => { expect(runLoop).not.toHaveBeenCalled(); expect(say).toHaveBeenCalled(); }); + + test("documents completion and Stop without promising an iteration cap", () => { + run("/loop"); + expect(say.mock.calls[0][0]).toContain("until the goal is met or you stop the run"); + expect(say.mock.calls[0][0]).not.toContain("iteration cap"); + }); }); describe("/compact", () => { diff --git a/client/src/lib/slashCommands.ts b/client/src/lib/slashCommands.ts index f798dc6a..34607160 100644 --- a/client/src/lib/slashCommands.ts +++ b/client/src/lib/slashCommands.ts @@ -1,7 +1,6 @@ import { parseRestoreArgs } from "./checkpoints"; import { parseInterval, parseLoopArgs, type LoopProfile } from "./interval"; import type { FileCommandInfo, SkillInfo } from "./extensions"; -import { parseGoalCommand, type GoalCommand } from "./goal"; import type { CommandPanel } from "./types"; // Slash-command registry for the agent panel — the harness surface that codex, @@ -58,8 +57,6 @@ export interface SlashContext { runGraphGoal: (goal: string, template?: string) => Promise; /** Cancel the active task graph, if any. */ stopGraph: () => void | Promise; - /** Set, show or clear the session goal (see lib/goal). */ - runGoalCommand: (command: GoalCommand) => void | Promise; /** Print the restore points automatic checkpointing recorded. */ listCheckpoints: () => void | Promise; /** @@ -113,17 +110,6 @@ export interface SlashCommand extends NamedCommand { run: (args: string, ctx: SlashContext) => void | Promise; } -/** - * Iterations `/loop` will run before it stops asking for tools and hands back - * a summary. This is a runaway backstop, not a product limit: the loop's real - * exit is its completion gate, and the real runaway guard is `detectStall` - * in lib/loopGuards — a counter only catches a stuck loop by accident, since - * twenty iterations of genuine progress and twenty repeats of one failing - * action look identical to it. Comparable harnesses bear this out: Codex and - * opencode ship no cap at all, and Hermes — the one that does — sits at 90. - */ -export const MAX_LOOP_ITERATIONS = 100; - export const SLASH_COMMANDS: readonly SlashCommand[] = [ { name: "help", @@ -157,7 +143,7 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [ // the loop would spend an hour chasing the goal "15m". if (!goal || parseInterval(goal) !== null) { ctx.say( - "Usage: /loop — I'll keep working until the goal is met or I hit the iteration cap.\n" + + "Usage: /loop — I'll keep working until the goal is met or you stop the run.\n" + "/loop — same work, paced: `/loop 15m run the tests and fix what fails` keeps re-checking every 15m until you stop it. Units are s, m, h.\n" + "/loop --aaa — the quality pipeline: a specialist task graph, PIE evidence, and a judge that has to score the result before DONE is believed.", ); @@ -231,12 +217,6 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [ return ctx.runGraphGoal(match[2].trim(), match[1]); }, }, - { - name: "goal", - summary: "Keep working toward a goal, re-checked after every turn", - usage: "[objective | clear]", - run: (args, ctx) => ctx.runGoalCommand(parseGoalCommand(args)), - }, { name: "graph-stop", summary: "Cancel the running task graph", diff --git a/client/src/lib/transcript.test.ts b/client/src/lib/transcript.test.ts new file mode 100644 index 00000000..51b73102 --- /dev/null +++ b/client/src/lib/transcript.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; +import { buildTranscriptWindow } from "./transcript"; + +describe("buildTranscriptWindow", () => { + const messages = [ + { role: "user", content: "add a double jump" }, + { role: "assistant", content: "on it" }, + { role: "tool", content: "3 tests passed", tool: "run_tests" }, + ]; + + test("labels a whole transcript and reports it as untruncated", () => { + expect(buildTranscriptWindow(messages)).toEqual({ + text: "user: add a double jump\nassistant: on it\ntool(run_tests): 3 tests passed", + kept: 3, + total: 3, + truncated: false, + }); + }); + + test("keeps the newest entries and reports what the budget dropped", () => { + const window = buildTranscriptWindow(messages, 45); + expect(window).toMatchObject({ kept: 1, total: 3, truncated: true }); + expect(window.text).toContain("tool(run_tests): 3 tests passed"); + expect(window.text).not.toContain("add a double jump"); + expect(window.text.length).toBeLessThanOrEqual(45); + }); + + test("clips the front of a single long entry but keeps its label", () => { + const window = buildTranscriptWindow( + [{ role: "tool", content: `${"x".repeat(200)}THE-END`, tool: "run_tests" }], + 40, + ); + expect(window).toMatchObject({ kept: 1, total: 1, truncated: true }); + expect(window.text.startsWith("tool(run_tests): …")).toBe(true); + expect(window.text.endsWith("THE-END")).toBe(true); + }); + + test("ignores blank entries and handles a zero budget", () => { + const withBlank = [...messages, { role: "assistant", content: " " }]; + expect(buildTranscriptWindow(withBlank)).toMatchObject({ kept: 3, total: 3, truncated: false }); + expect(buildTranscriptWindow(messages, 0)).toEqual({ text: "", kept: 0, total: 3, truncated: true }); + }); +}); diff --git a/client/src/lib/transcript.ts b/client/src/lib/transcript.ts new file mode 100644 index 00000000..673d40c9 --- /dev/null +++ b/client/src/lib/transcript.ts @@ -0,0 +1,65 @@ +const DEFAULT_TRANSCRIPT_CHARS = 8000; + +function labelFor(entry: { role: string; tool?: string }): string { + const role = entry.role.trim().toLowerCase() || "message"; + const tool = entry.tool?.trim(); + // Tool entries carry their source so a claim and a command result remain + // distinguishable after the transcript is flattened to text. + return tool ? `${role}(${tool})` : role; +} + +/** Drop the head of an over-long entry while retaining its source label. */ +function truncateFront(line: string, limit: number): string { + if (line.length <= limit) return line; + const separator = line.indexOf(": "); + const label = separator > 0 ? line.slice(0, separator + 2) : ""; + const budget = limit - label.length - 1; + if (budget <= 0) return line.slice(line.length - limit); + return `${label}…${line.slice(line.length - budget)}`; +} + +export interface TranscriptWindow { + text: string; + /** Entries that made the excerpt. */ + kept: number; + /** Non-empty entries that could have made it. */ + total: number; + /** True when an entry was dropped or its head was clipped. */ + truncated: boolean; +} + +/** + * Build a labelled, newest-first-budgeted excerpt while preserving the + * original order of everything retained. + */ +export function buildTranscriptWindow( + messages: Array<{ role: string; content: string; tool?: string }>, + maxChars = DEFAULT_TRANSCRIPT_CHARS, +): TranscriptWindow { + const total = messages.filter((entry) => entry.content?.trim()).length; + const limit = Math.floor(maxChars); + if (!Number.isFinite(limit) || limit <= 0) { + return { text: "", kept: 0, total, truncated: total > 0 }; + } + const kept: string[] = []; + let used = 0; + let clipped = false; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const entry = messages[index]; + const content = entry.content?.trim(); + if (!content) continue; + const line = `${labelFor(entry)}: ${content}`; + const cost = kept.length ? line.length + 1 : line.length; + if (used + cost <= limit) { + kept.unshift(line); + used += cost; + continue; + } + if (!kept.length) { + kept.push(truncateFront(line, limit)); + clipped = true; + } + break; + } + return { text: kept.join("\n"), kept: kept.length, total, truncated: clipped || kept.length < total }; +} diff --git a/client/src/lib/viewState.ts b/client/src/lib/viewState.ts index a576266c..0dba9d58 100644 --- a/client/src/lib/viewState.ts +++ b/client/src/lib/viewState.ts @@ -27,8 +27,9 @@ export function withIntroducedTabs( seen: readonly Tab[], catalogue: readonly Tab[], ): Tab[] { - // An empty strip means "no preference recorded", not "the user closed - // everything" — a dock with no tabs has nothing to show and no way back. + // Callers that use this merge helper treat an empty strip as "no preference + // recorded". The app's first-use path handles its intentional blank state + // before calling here, so a chooser always remains available. if (stored.length === 0) return [...catalogue]; const introduced = catalogue.filter((view) => !seen.includes(view)); return [...new Set([...stored, ...introduced])]; diff --git a/core/src/advisor.rs b/core/src/advisor.rs index 906c90c2..00108250 100644 --- a/core/src/advisor.rs +++ b/core/src/advisor.rs @@ -59,6 +59,27 @@ const MAX_TOOL_ROUNDS: usize = 4; const EMPTY_REPLY: &str = "The advisor returned an empty reply. Ask again, or narrow the question to one part of the run."; +/// Server-side ceiling on the observed transcript excerpt. The client +/// truncates first; this keeps a buggy or non-browser client from crowding the +/// operator's actual question out of the context window. +const MAX_TRANSCRIPT_CHARS: usize = 12_000; + +/// Keep the tail of an over-long excerpt: the newest activity is what an +/// explanation usually turns on. Character indexing keeps the returned slice +/// on a UTF-8 boundary. +fn clamp_transcript(transcript: &str) -> (&str, bool) { + let total = transcript.chars().count(); + if total <= MAX_TRANSCRIPT_CHARS { + return (transcript, false); + } + let start = transcript + .char_indices() + .nth(total - MAX_TRANSCRIPT_CHARS) + .map(|(index, _)| index) + .unwrap_or(0); + (&transcript[start..], true) +} + /// One question about a run: the thread so far plus everything that scopes it. #[derive(Default)] pub struct AdvisorRequest<'a> { @@ -79,9 +100,9 @@ pub struct AdvisorRequest<'a> { /// Load config the way the other model-backed RPCs do and ask once. pub async fn advise(state: &AppState, request: AdvisorRequest<'_>) -> Result { let mut config = { state.config.read().await.clone() }; - // Explaining a run is judging one, so it follows the same `judge` role - // routing `goal_evaluate` uses: an operator who mapped a cheap model to - // that role gets it here too. Unmapped, this is a no-op. + // Explaining a run is judging one, so it follows the graph judge's role + // routing. An operator who mapped a cheap model to that role gets it here + // too. Unmapped, this is a no-op. crate::config::apply_role_model(&mut config, &["judge".to_string()]); if let Some((provider, model)) = request.model { apply_model_choice(&mut config, provider, model)?; @@ -352,7 +373,7 @@ fn read_history(messages: &[Value]) -> Vec { /// can never be mistaken for the operator speaking, and so the replayed /// history keeps strict user/assistant alternation. fn build_system(transcript: &str, project_slug: Option<&str>, anchor: Option<&str>) -> String { - let (excerpt, truncated) = crate::goal::clamp_transcript(transcript); + let (excerpt, truncated) = clamp_transcript(transcript); let mut prompt = String::from(SYSTEM_PROMPT); prompt.push_str("\n\nOBSERVED SESSION"); if let Some(slug) = project_slug.map(str::trim).filter(|slug| !slug.is_empty()) { @@ -394,7 +415,6 @@ fn clamp_anchor(anchor: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::goal::MAX_TRANSCRIPT_CHARS; use axum::response::sse::{Event, Sse}; use axum::routing::post; use axum::Router; @@ -939,6 +959,14 @@ mod tests { assert!(longest_run(sent, 'y') < MAX_TRANSCRIPT_CHARS); } + #[test] + fn transcript_clamping_keeps_utf8_intact() { + let transcript = "é".repeat(MAX_TRANSCRIPT_CHARS + 500); + let (kept, truncated) = clamp_transcript(&transcript); + assert!(truncated); + assert_eq!(kept.chars().count(), MAX_TRANSCRIPT_CHARS); + } + #[tokio::test] async fn an_empty_history_is_rejected_before_any_provider_call() { let (config, requests) = mock_config("sure.").await; diff --git a/core/src/goal.rs b/core/src/goal.rs deleted file mode 100644 index f8d77bdf..00000000 --- a/core/src/goal.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! Tool-less goal verifier behind the client's `/goal` command. -//! -//! After each turn the client hands core the objective plus a transcript -//! excerpt and gets back `{ met, reason }`. When `met` is false the reason is -//! phrased as the next directive, so the client can feed it straight back to -//! the worker. -//! -//! The evaluator is deliberately given no tools. It can only judge what the -//! worker's own output already demonstrates, which forces goals to be stated -//! as observable end states and stops the worker from grading its own homework -//! with fresh tool calls. -//! -//! Parsing fails *closed*: anything we cannot read as a verdict becomes -//! `met: false`. A verifier that fails open is worse than no verifier, because -//! it silently declares victory. - -use crate::config::AppConfig; -use crate::model; -use crate::AppState; -use anyhow::Result; -use serde_json::{json, Value}; - -/// Server-side ceiling on the transcript excerpt, in characters. The client -/// truncates first; this exists so a client bug cannot blow the context of -/// what is meant to be a cheap per-turn check. -pub const MAX_TRANSCRIPT_CHARS: usize = 12_000; - -const SYSTEM_PROMPT: &str = "You are a strict, sceptical verifier. You have no tools: you cannot run commands, read files, or inspect anything yourself. You judge exactly one question — does the transcript excerpt contain evidence that the stated goal has actually been achieved? - -Rules: -- Reply with a single JSON object and nothing else: {\"met\": true, \"reason\": \"...\"} or {\"met\": false, \"reason\": \"...\"}. No prose, no code fences, no second object. -- Evidence means the transcript shows the observable end state the goal describes: command output, file contents, a test summary, an error that is demonstrably gone. A claim of success with nothing demonstrating it is not evidence — answer met: false. -- Partial progress is not achievement. If any part of the goal is unproven, answer met: false. -- When met is false, write reason as one sentence addressed to the worker as its next instruction, naming the missing evidence and the action that would produce it. Example: \"The tests were never run — run them and paste the output.\" -- When met is true, write reason as one sentence stating the evidence that satisfied you. Example: \"cargo test printed 'test result: ok. 555 passed' in the transcript.\" -- Never ask a question, never request permission, never explain yourself outside the JSON object."; - -/// A read verdict. `reason` is always non-empty. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Verdict { - pub met: bool, - pub reason: String, -} - -impl Verdict { - fn to_json(&self) -> Value { - json!({ "met": self.met, "reason": self.reason }) - } -} - -/// The verdict returned when the model's reply cannot be read. -fn unreadable() -> Verdict { - Verdict { - met: false, - reason: "The evaluator's reply could not be read as a verdict, so the goal is not confirmed — restate the goal as a concrete observable end state and try again.".into(), - } -} - -/// Load config the way the other model-backed RPCs do and ask once. -pub async fn evaluate( - state: &AppState, - goal: &str, - transcript: &str, - project_slug: Option<&str>, -) -> Result { - let mut config = { state.config.read().await.clone() }; - // A per-turn verifier runs on every turn, so it follows the same `judge` - // role routing the graph judge uses: operators who mapped a cheap model to - // that role get it here too. Unmapped, this is a no-op. - crate::config::apply_role_model(&mut config, &["judge".to_string()]); - evaluate_with_config(&config, goal, transcript, project_slug).await -} - -async fn evaluate_with_config( - config: &AppConfig, - goal: &str, - transcript: &str, - project_slug: Option<&str>, -) -> Result { - let goal = goal.trim(); - if goal.is_empty() { - anyhow::bail!("goal must not be empty"); - } - let messages = vec![ - json!({ "role": "system", "content": SYSTEM_PROMPT }), - json!({ "role": "user", "content": build_prompt(goal, transcript, project_slug) }), - ]; - // `None` for tools is the whole design: the evaluator physically cannot - // call one, so it can only judge evidence already in the transcript. - let result = - model::chat_with_effort_session_once(config, &messages, None, None, None, None).await?; - Ok(read_verdict(&result.content).to_json()) -} - -/// Keep the tail of an over-long excerpt: the most recent activity is the -/// evidence a verdict turns on. Returns the kept slice and whether anything -/// was dropped. Shared with `advisor.rs`, which caps the same excerpt for the -/// same reason. -pub fn clamp_transcript(transcript: &str) -> (&str, bool) { - let total = transcript.chars().count(); - if total <= MAX_TRANSCRIPT_CHARS { - return (transcript, false); - } - let start = transcript - .char_indices() - .nth(total - MAX_TRANSCRIPT_CHARS) - .map(|(index, _)| index) - .unwrap_or(0); - (&transcript[start..], true) -} - -fn build_prompt(goal: &str, transcript: &str, project_slug: Option<&str>) -> String { - let (excerpt, truncated) = clamp_transcript(transcript); - let mut prompt = String::new(); - if let Some(slug) = project_slug.map(str::trim).filter(|slug| !slug.is_empty()) { - prompt.push_str("PROJECT: "); - prompt.push_str(slug); - prompt.push_str("\n\n"); - } - prompt.push_str("GOAL:\n"); - prompt.push_str(goal); - prompt.push_str("\n\nTRANSCRIPT EXCERPT (oldest first, most recent last):\n"); - if truncated { - prompt.push_str("[earlier transcript omitted]\n"); - } - if excerpt.trim().is_empty() { - prompt.push_str("(the excerpt is empty — nothing has been demonstrated yet)"); - } else { - prompt.push_str(excerpt); - } - prompt.push_str("\n\nAnswer with the JSON object only."); - prompt -} - -/// Read a verdict out of a model reply, falling back to `met: false`. -pub fn read_verdict(reply: &str) -> Verdict { - parse_verdict(reply).unwrap_or_else(unreadable) -} - -/// Scan for the first balanced `{...}` block that carries a readable `met`. -/// -/// Models wrap the answer in prose or fences, and a chatty one quotes an -/// unrelated object first, so a merely-valid object is not enough — the block -/// must actually look like a verdict. -fn parse_verdict(reply: &str) -> Option { - for candidate in json_objects(reply) { - let Some(met) = candidate.get("met").and_then(coerce_bool) else { - continue; - }; - let reason = candidate - .get("reason") - .and_then(Value::as_str) - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - if met { - "The evaluator reported the goal met but gave no reason.".into() - } else { - "The evaluator reported the goal not met but gave no reason — restate the goal as a concrete observable end state.".into() - } - }); - return Some(Verdict { met, reason }); - } - None -} - -/// `true`/`"true"`/`"yes"`/`1` and their negatives. Anything else is -/// unreadable rather than a guess, and an unreadable field never becomes met. -fn coerce_bool(value: &Value) -> Option { - match value { - Value::Bool(flag) => Some(*flag), - Value::String(text) => match text.trim().to_ascii_lowercase().as_str() { - "true" | "yes" | "y" => Some(true), - "false" | "no" | "n" => Some(false), - _ => None, - }, - Value::Number(number) => match number.as_i64() { - Some(1) => Some(true), - Some(0) => Some(false), - _ => None, - }, - _ => None, - } -} - -/// Every balanced, parseable `{...}` block in `text`, in order. Brace matching -/// is string-aware so `{"reason": "expected }"}` stays intact. -fn json_objects(text: &str) -> Vec { - let mut found = Vec::new(); - for (start, _) in text.char_indices().filter(|(_, ch)| *ch == '{') { - let mut depth = 0usize; - let mut in_string = false; - let mut escaped = false; - for (offset, ch) in text[start..].char_indices() { - if in_string { - if escaped { - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - in_string = false; - } - continue; - } - match ch { - '"' => in_string = true, - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - let candidate = &text[start..start + offset + ch.len_utf8()]; - if let Ok(value) = serde_json::from_str::(candidate) { - found.push(value); - } - break; - } - } - _ => {} - } - } - } - found -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::response::sse::{Event, Sse}; - use axum::routing::post; - use axum::Router; - use std::convert::Infallible; - use std::sync::Arc; - use std::sync::Mutex; - - #[test] - fn clean_json_parses_both_ways() { - let met = read_verdict(r#"{"met": true, "reason": "cargo test printed 555 passed."}"#); - assert!(met.met); - assert_eq!(met.reason, "cargo test printed 555 passed."); - - let unmet = read_verdict(r#"{"met": false, "reason": "Run the tests and paste output."}"#); - assert!(!unmet.met); - assert_eq!(unmet.reason, "Run the tests and paste output."); - } - - #[test] - fn fenced_json_parses() { - let verdict = read_verdict("```json\n{\"met\": false, \"reason\": \"The file was never created — create it.\"}\n```"); - assert!(!verdict.met); - assert_eq!(verdict.reason, "The file was never created — create it."); - } - - #[test] - fn prose_wrapped_json_parses() { - let verdict = read_verdict( - "Looking at the evidence carefully:\n{\"met\": true, \"reason\": \"The transcript shows exit status 0.\"}\nThat is my verdict.", - ); - assert!(verdict.met); - assert_eq!(verdict.reason, "The transcript shows exit status 0."); - } - - #[test] - fn leading_unrelated_object_is_skipped() { - let verdict = read_verdict( - r#"The worker ran {"tool": "bash", "ok": true} and then {"met": false, "reason": "No test output was shown — run the suite."}"#, - ); - assert!(!verdict.met); - assert_eq!(verdict.reason, "No test output was shown — run the suite."); - } - - #[test] - fn stringy_and_numeric_booleans_are_tolerated() { - assert!(read_verdict(r#"{"met": "true", "reason": "it built."}"#).met); - assert!(!read_verdict(r#"{"met": "False", "reason": "not yet."}"#).met); - assert!(read_verdict(r#"{"met": 1, "reason": "it built."}"#).met); - assert!(!read_verdict(r#"{"met": 0, "reason": "not yet."}"#).met); - } - - #[test] - fn braces_inside_the_reason_do_not_break_matching() { - let verdict = read_verdict( - r#"{"met": false, "reason": "The literal {\"ok\": 1} was never printed."}"#, - ); - assert!(!verdict.met); - assert!(verdict.reason.contains("{\"ok\": 1}")); - } - - #[test] - fn unparseable_output_is_never_met() { - for reply in [ - "", - "Yes, the goal is definitely achieved.", - "{ broken", - r#"{"verdict": "pass"}"#, - r#"{"met": "probably", "reason": "hard to say"}"#, - r#"{"met": null, "reason": "hard to say"}"#, - ] { - let verdict = read_verdict(reply); - assert!(!verdict.met, "must not fail open on {reply:?}"); - assert!(!verdict.reason.is_empty()); - } - } - - #[test] - fn a_verdict_without_a_reason_still_carries_one() { - let verdict = read_verdict(r#"{"met": false}"#); - assert!(!verdict.met); - assert!(!verdict.reason.is_empty()); - } - - /// Longest run of `filler` in `text`. The prompt scaffolding contains - /// ordinary letters, so counting every occurrence would measure the - /// wrapper too; the run length measures only the excerpt. - fn longest_run(text: &str, filler: char) -> usize { - text.split(|ch| ch != filler) - .map(str::len) - .max() - .unwrap_or(0) - } - - #[test] - fn over_long_transcripts_are_truncated_to_the_tail() { - let transcript = format!("{}TAIL-EVIDENCE", "x".repeat(MAX_TRANSCRIPT_CHARS * 2)); - let prompt = build_prompt("ship it", &transcript, None); - - assert!(prompt.contains("TAIL-EVIDENCE")); - assert!(prompt.contains("[earlier transcript omitted]")); - let kept = longest_run(&prompt, 'x'); - assert!( - kept < MAX_TRANSCRIPT_CHARS, - "kept {kept} transcript chars, cap is {MAX_TRANSCRIPT_CHARS}" - ); - } - - #[test] - fn short_transcripts_are_passed_through_whole() { - let prompt = build_prompt("ship it", "ran cargo test: ok", Some("demo")); - assert!(prompt.contains("PROJECT: demo")); - assert!(prompt.contains("GOAL:\nship it")); - assert!(prompt.contains("ran cargo test: ok")); - assert!(!prompt.contains("[earlier transcript omitted]")); - } - - #[test] - fn truncation_never_splits_a_multibyte_character() { - let transcript = "é".repeat(MAX_TRANSCRIPT_CHARS + 500); - let (kept, truncated) = clamp_transcript(&transcript); - assert!(truncated); - assert_eq!(kept.chars().count(), MAX_TRANSCRIPT_CHARS); - } - - #[test] - fn an_empty_excerpt_is_described_rather_than_left_blank() { - let prompt = build_prompt("ship it", " ", None); - assert!(prompt.contains("nothing has been demonstrated yet")); - } - - // ---- provider round-trip (stub model over HTTP, like agent.rs tests) ---- - - fn sse_reply(content: &str) -> Sse>> { - let payload = - json!({ "choices": [{ "delta": { "role": "assistant", "content": content } }] }); - Sse::new(futures::stream::iter(vec![ - Ok(Event::default().data(payload.to_string())), - Ok(Event::default().data("[DONE]")), - ])) - } - - async fn mock_provider( - axum::extract::State(mock): axum::extract::State, - axum::Json(body): axum::Json, - ) -> Sse>> { - mock.requests.lock().unwrap().push(body); - sse_reply(&mock.reply) - } - - #[derive(Clone)] - struct MockState { - reply: String, - requests: Arc>>, - } - - async fn mock_config(reply: &str) -> (AppConfig, Arc>>) { - let mock = MockState { - reply: reply.to_string(), - requests: Arc::new(Mutex::new(Vec::new())), - }; - let requests = mock.requests.clone(); - let app = Router::new() - .route("/v1/chat/completions", post(mock_provider)) - .with_state(mock); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - let config = AppConfig { - model: crate::config::ModelConfig { - default: "mock".into(), - provider: "mock".into(), - base_url: format!("http://{}/v1", addr), - api_key_env: "CALI_MOCK_KEY".into(), - temperature: 0.0, - max_tokens: Some(128), - roles: Default::default(), - }, - providers: vec![], - ..Default::default() - }; - (config, requests) - } - - #[tokio::test] - async fn the_evaluator_is_offered_no_tools() { - let (config, requests) = mock_config(r#"{"met": true, "reason": "tests passed."}"#).await; - - let result = evaluate_with_config(&config, "tests pass", "test result: ok", None) - .await - .unwrap(); - - assert_eq!(result, json!({ "met": true, "reason": "tests passed." })); - let requests = requests.lock().unwrap(); - assert_eq!(requests.len(), 1, "a verdict costs exactly one call"); - assert!( - requests[0].get("tools").is_none(), - "the verifier must never be handed a tool schema: {}", - requests[0] - ); - } - - #[tokio::test] - async fn the_transcript_is_capped_before_it_reaches_the_provider() { - let (config, requests) = mock_config(r#"{"met": false, "reason": "run it."}"#).await; - let transcript = "y".repeat(MAX_TRANSCRIPT_CHARS * 3); - - evaluate_with_config(&config, "ship it", &transcript, None) - .await - .unwrap(); - - let requests = requests.lock().unwrap(); - let sent = requests[0]["messages"][1]["content"].as_str().unwrap(); - assert_eq!(longest_run(sent, 'y'), MAX_TRANSCRIPT_CHARS); - } - - #[tokio::test] - async fn an_unreadable_reply_reports_not_met() { - let (config, _) = mock_config("Sure, that looks done to me!").await; - - let result = evaluate_with_config(&config, "ship it", "I finished it", None) - .await - .unwrap(); - - assert_eq!(result["met"], json!(false)); - assert!(result["reason"] - .as_str() - .unwrap() - .contains("could not be read")); - } - - #[tokio::test] - async fn an_empty_goal_is_rejected_before_any_provider_call() { - let (config, requests) = mock_config(r#"{"met": true, "reason": "sure."}"#).await; - - let error = evaluate_with_config(&config, " ", "anything", None) - .await - .unwrap_err(); - - assert!(error.to_string().contains("goal must not be empty")); - assert!(requests.lock().unwrap().is_empty()); - } -} diff --git a/core/src/loop_run.rs b/core/src/loop_run.rs index 8d108915..17889795 100644 --- a/core/src/loop_run.rs +++ b/core/src/loop_run.rs @@ -39,11 +39,6 @@ const AUTO_CHECKPOINT_INTERVAL_MS: u64 = 15 * 60_000; /// Disambiguates loop ids minted inside one millisecond. See `start`. static LOOP_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -/// Runaway backstop, not a product limit. The real exit is the completion -/// gate; this only bounds a loop that is making no progress at all and would -/// otherwise bill forever. Mirrors `MAX_LOOP_ITERATIONS` in the client. -pub const MAX_ITERATIONS: usize = 100; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LoopProfile { @@ -90,7 +85,6 @@ pub struct LoopSpec { pub system: Option, pub context_length: Option, pub guardian_model: Option, - pub max_iterations: usize, } #[derive(Debug, Clone, Serialize)] @@ -102,7 +96,6 @@ pub struct LoopView { pub profile: LoopProfile, pub status: RunStatus, pub iteration: usize, - pub max_iterations: usize, pub started_at_ms: u64, #[serde(skip_serializing_if = "Option::is_none")] pub interval_ms: Option, @@ -147,7 +140,6 @@ impl LoopManager { profile: spec.profile, status: RunStatus::Running, iteration: 0, - max_iterations: spec.max_iterations.clamp(1, MAX_ITERATIONS), started_at_ms, interval_ms: spec.interval_ms, session_id: spec.session_id.clone(), @@ -162,10 +154,6 @@ impl LoopManager { let manager = self.clone(); let state = state.clone(); - let spec = LoopSpec { - max_iterations: view.max_iterations, - ..spec - }; tokio::spawn(async move { let outcome = manager .drive(&state, &loop_id, spec, run.clone(), cancel) @@ -233,7 +221,6 @@ impl LoopManager { cancel: CancellationToken, ) -> Result { let mut session_id = spec.session_id.clone(); - let mut completed_once = false; let mut last_checkpoint_at: Option = None; // Only the pipeline keeps a durable report. A `standard` loop writing // one would be filing paperwork nobody asked for and nothing reads. @@ -253,7 +240,8 @@ impl LoopManager { } } - for iteration in 1..=spec.max_iterations { + let mut iteration = 0usize; + loop { if cancel.is_cancelled() { if reports { close_report( @@ -266,9 +254,12 @@ impl LoopManager { } return Ok(Outcome::Stopped(format!( "stopped after {} iterations", - iteration - 1 + iteration ))); } + iteration = iteration + .checked_add(1) + .context("loop iteration counter overflowed")?; { let mut guard = run.write().await; guard.view.iteration = iteration; @@ -309,7 +300,6 @@ impl LoopManager { "type": "loop.iteration", "loopId": loop_id, "iteration": iteration, - "maxIterations": spec.max_iterations, "prompt": prompt, })); @@ -339,6 +329,15 @@ impl LoopManager { let turn = tokio::select! { biased; _ = cancel.cancelled() => { + if reports { + close_report( + state, + &spec, + loop_id, + crate::loop_report::LoopStatus::Cancelled, + "Loop stopped by the user.", + ); + } return Ok(Outcome::Stopped(format!( "stopped during iteration {iteration}" ))); @@ -368,7 +367,6 @@ impl LoopManager { if says_done(&reply) { match completion_blocker(state, &spec, loop_id) { None => { - completed_once = true; let _ = state.bus.send(json!({ "type": "loop.completed", "loopId": loop_id, @@ -404,38 +402,26 @@ impl LoopManager { } if let Some(interval) = spec.interval_ms { - if iteration < spec.max_iterations { - let wait = std::time::Duration::from_millis(interval); - tokio::select! { - _ = cancel.cancelled() => { - return Ok(Outcome::Stopped(format!( - "watch stopped after {iteration} iterations" - ))); + let wait = std::time::Duration::from_millis(interval); + tokio::select! { + _ = cancel.cancelled() => { + if reports { + close_report( + state, + &spec, + loop_id, + crate::loop_report::LoopStatus::Cancelled, + "Loop stopped by the user.", + ); } - _ = tokio::time::sleep(wait) => {} + return Ok(Outcome::Stopped(format!( + "watch stopped after {iteration} iterations" + ))); } + _ = tokio::time::sleep(wait) => {} } } } - if reports && !completed_once { - close_report( - state, - &spec, - loop_id, - crate::loop_report::LoopStatus::Blocked, - "Loop hit its iteration cap before completion could be proven.", - ); - } - Ok(if completed_once { - // A watch that met its goal and then ran out of iterations is not a - // failure; saying "hit the cap" and nothing else would read as one. - Outcome::Completed(format!( - "watch reached the {}-iteration cap after meeting the goal", - spec.max_iterations - )) - } else { - Outcome::Stopped(format!("hit the {}-iteration cap", spec.max_iterations)) - }) } } @@ -640,7 +626,6 @@ mod tests { system: None, context_length: None, guardian_model: None, - max_iterations: 10, } } @@ -740,6 +725,21 @@ mod tests { panic!("loop {loop_id} never reached a terminal status"); } + /// Wait for the durable report to contain real completed iterations. The + /// run view advances before the provider call, so polling that counter + /// would race cancellation against the report write this test proves. + async fn wait_for_report_iterations(state: &crate::AppState, loop_id: &str, expected: usize) { + for _ in 0..200 { + if crate::loop_report::load(&state.projects_root, "demo", loop_id) + .is_ok_and(|report| report.iterations.len() >= expected) + { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + panic!("loop {loop_id} never recorded {expected} iterations"); + } + #[tokio::test] async fn a_standard_loop_runs_until_the_model_says_done() { let (state, calls) = @@ -758,24 +758,25 @@ mod tests { #[tokio::test] async fn an_aaa_loop_refuses_done_it_cannot_prove() { // The model claims completion on every turn; with no passing report on - // disk the gate must refuse every one and let the cap end the run. - let (state, _) = scripted_state(vec!["DONE".into()]).await; + // disk the gate must refuse every one and keep running until Stop. + let (state, calls) = scripted_state(vec!["DONE".into()]).await; let view = state .loops - .start( - &state, - LoopSpec { - profile: LoopProfile::Aaa, - max_iterations: 3, - ..spec(LoopProfile::Aaa) - }, - ) + .start(&state, spec(LoopProfile::Aaa)) .await .unwrap(); + wait_for_report_iterations(&state, &view.loop_id, 3).await; + assert_eq!( + state.loops.status(&view.loop_id).await.unwrap().status, + RunStatus::Running, + "three refused DONE replies must not impose a hidden cap" + ); + assert!(calls.load(Ordering::SeqCst) >= 3); + state.loops.stop(&view.loop_id).await.unwrap(); let settled = settle(&state, &view.loop_id).await; assert_eq!(settled.status, RunStatus::Stopped, "{settled:?}"); - assert_eq!( - settled.iteration, 3, + assert!( + settled.iteration >= 3, "the gate should have refused all three" ); } @@ -829,24 +830,15 @@ mod tests { let (state, _) = scripted_state(vec!["worked on it".into()]).await; let view = state .loops - .start( - &state, - LoopSpec { - profile: LoopProfile::Aaa, - max_iterations: 2, - ..spec(LoopProfile::Aaa) - }, - ) + .start(&state, spec(LoopProfile::Aaa)) .await .unwrap(); + wait_for_report_iterations(&state, &view.loop_id, 2).await; + state.loops.stop(&view.loop_id).await.unwrap(); settle(&state, &view.loop_id).await; let report = crate::loop_report::load(&state.projects_root, "demo", &view.loop_id).unwrap(); - assert_eq!(report.iterations.len(), 2, "{report:?}"); - assert_ne!( - report.status, - crate::loop_report::LoopStatus::Running, - "a capped loop must close its report" - ); + assert!(report.iterations.len() >= 2, "{report:?}"); + assert_eq!(report.status, crate::loop_report::LoopStatus::Cancelled); } #[tokio::test] diff --git a/core/src/main.rs b/core/src/main.rs index 3f969a64..003dd309 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -18,7 +18,6 @@ mod diagnostics; mod edit_match; mod editor_bridge; mod fileread; -mod goal; mod graph; mod guardian; mod hooks; @@ -122,7 +121,9 @@ async fn main() -> anyhow::Result<()> { sandbox::init(&config.sandbox); let projects_root = config::projects_root(&config); std::fs::create_dir_all(&projects_root)?; - if !projects_root.join("starter").exists() { + if !projects_root.join("starter").exists() + && !projects_root.join(store::EMPTY_PROJECTS_MARKER).exists() + { store::create_project(&projects_root, "starter", "Starter")?; } diff --git a/core/src/rpc.rs b/core/src/rpc.rs index 628e289e..a4091c03 100644 --- a/core/src/rpc.rs +++ b/core/src/rpc.rs @@ -254,8 +254,9 @@ async fn dispatch(state: &AppState, method: &str, params: Value) -> Result { let slug = str_param(¶ms, "slug")?; - // Delete the project first so a failed "last project" guard or - // other store error leaves every session/worktree recoverable. + // Delete the project first so a store error leaves every + // session/worktree recoverable; deleting the last project is a + // supported route to the empty project hub. let project = store::delete_project(&state.projects_root, slug)?; let sessions = delete_project_sessions(state, slug).await?; Ok(json!({ "project": project, "sessions": sessions })) @@ -761,11 +762,6 @@ async fn dispatch(state: &AppState, method: &str, params: Value) -> Result Result { - crate::goal::evaluate( - state, - str_param(¶ms, "goal")?, - str_param(¶ms, "transcript")?, - params.get("projectSlug").and_then(Value::as_str), - ) - .await - } // Side conversation *about* a run. Unlike `agent_chat` this registers // no tools and touches no session: the advisor can only read the // transcript excerpt it is handed, so a question about a run can @@ -2857,25 +2841,6 @@ mod tests { ); } - /// Both strings are required. Reaching the provider without a goal would - /// spend a model call to verify nothing. - #[tokio::test] - async fn goal_evaluate_requires_a_goal_and_a_transcript() { - let projects = tempfile::tempdir().unwrap(); - let sessions = tempfile::tempdir().unwrap(); - let state = test_state(projects.path().to_path_buf(), sessions.path().to_path_buf()); - - let missing_goal = dispatch(&state, "goal_evaluate", json!({ "transcript": "ran it" })) - .await - .unwrap_err(); - assert!(missing_goal.to_string().contains("goal")); - - let missing_transcript = dispatch(&state, "goal_evaluate", json!({ "goal": "tests pass" })) - .await - .unwrap_err(); - assert!(missing_transcript.to_string().contains("transcript")); - } - /// `transcript` is optional — an advisor asked before anything happened is /// legal — but a turn with nothing to answer would spend a model call on /// silence. diff --git a/core/src/store.rs b/core/src/store.rs index 9f43aeaa..1eb51d4f 100644 --- a/core/src/store.rs +++ b/core/src/store.rs @@ -730,8 +730,12 @@ fn git_stdout(directory: &Path, args: &[&str]) -> Result { String::from_utf8(output.stdout).context("git returned non-UTF-8 output") } -/// Permanently remove one explicitly named project. Refuse to remove the last -/// project so the editor always has a valid project to open. +/// Marker written when the user intentionally removes the last project. Core +/// seeds a Starter project for a brand-new install, but must not resurrect it +/// after the user has deliberately chosen an empty project hub. +pub const EMPTY_PROJECTS_MARKER: &str = ".empty-projects"; + +/// Permanently remove one explicitly named project, including the last one. pub fn delete_project(root: &Path, slug: &str) -> Result { let clean = sanitize_slug(slug)?; let directory = project_dir(root, &clean)?; @@ -739,15 +743,18 @@ pub fn delete_project(root: &Path, slug: &str) -> Result { anyhow::bail!("project {clean} not found"); } let count = list_projects(root)?.as_array().map(Vec::len).unwrap_or(0); - if count <= 1 { - anyhow::bail!("cannot remove the last project"); - } let root_real = root.canonicalize()?; let directory_real = directory.canonicalize()?; if directory_real.parent() != Some(root_real.as_path()) { anyhow::bail!("refusing to remove a project outside the projects directory"); } + if count == 1 { + // Write the intent before deleting the directory: a marker failure + // must leave the project recoverable rather than allowing a later + // core restart to recreate Starter behind the user's back. + std::fs::write(root.join(EMPTY_PROJECTS_MARKER), b" intentionally empty\n")?; + } std::fs::remove_dir_all(&directory_real)?; Ok(json!({ "slug": clean, "deleted": true })) } @@ -1085,7 +1092,7 @@ mod tests { } #[test] - fn delete_removes_one_project_but_not_the_last() { + fn delete_removes_one_project_and_can_leave_an_empty_hub() { let root = tempfile::tempdir().unwrap(); create_project(root.path(), "one", "One").unwrap(); create_project(root.path(), "two", "Two").unwrap(); @@ -1093,7 +1100,14 @@ mod tests { delete_project(root.path(), "one").unwrap(); assert!(!root.path().join("one").exists()); assert!(root.path().join("two/project.json").exists()); - assert!(delete_project(root.path(), "two").is_err()); + delete_project(root.path(), "two").unwrap(); + assert!(!root.path().join("two").exists()); + assert!(root.path().join(EMPTY_PROJECTS_MARKER).exists()); + assert!(list_projects(root.path()) + .unwrap() + .as_array() + .unwrap() + .is_empty()); } #[test] diff --git a/docs/game-harness.md b/docs/game-harness.md index cf2a9d11..b06ceb99 100644 --- a/docs/game-harness.md +++ b/docs/game-harness.md @@ -178,8 +178,7 @@ autocomplete. | Command | Usage | What it does | |---|---|---| | `/help` | — | Lists every registered command, skills included | -| `/loop` | `/loop [interval] ` | Autonomous run: re-sends the goal until the agent replies `DONE` alone on a line and the completion gate accepts it, the cap is hit, or you press STOP. With an interval (`30s`, `15m`, `2h`) it becomes a watch: it waits that long between checks and keeps going after the goal is met, until you stop it | -| `/goal` | `/goal [objective \| clear]` | Keeps working toward an objective, re-checked by the evaluator after every turn. Bare prints the current goal | +| `/loop` | `/loop [--aaa] [interval] ` | Autonomous run: keeps working until the agent replies `DONE` alone on a line and the selected completion gate accepts it, or you press STOP. With an interval (`30s`, `15m`, `2h`) it becomes a watch: it waits that long between checks and keeps going after the goal is met, until you stop it | | `/graph` | `/graph ` | Plans a task DAG for the goal and runs it node by node | | `/graph-template` | `/graph-template