diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index b2ec55bea..f59389355 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -341,6 +341,7 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP await page.getByRole("button", { name: /^Create agent/ }).click(); await page.getByLabel("Name").fill("Tool Agent"); await page.getByLabel("Instructions").fill("Use only the configured tools."); + await page.getByLabel("Harness").selectOption("claude_sdk"); await page.getByLabel("Model").selectOption({ label: "Custom model ID…" }); await page.getByLabel("Custom model ID").fill("fixture/tool-model"); @@ -366,6 +367,7 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP await expect(previewBody).toContainText('"name": "lookup_customer"'); await expect(previewBody).toContainText('"type": "mcp"'); await expect(previewBody).toContainText('"required": true'); + await expect(previewBody).toContainText('"harness": "claude_sdk"'); await page.getByRole("button", { name: "Save Agent definition" }).click(); await expect(page.getByRole("status")).toContainText("Agent definition saved as"); @@ -373,6 +375,7 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); expect(creates).toHaveLength(1); expect(creates[0]?.body).toEqual({ + x_agents_core: { harness: "claude_sdk" }, model: "fixture/tool-model", name: "Tool Agent", instructions: "Use only the configured tools.", @@ -403,6 +406,7 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP const setup = page.locator(".agent-setup-page"); await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(setup.getByLabel("Harness")).toHaveValue("claude_sdk"); const editFunction = setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); await editFunction.getByLabel("Name", { exact: true }).fill("lookup_customer_v2"); const editMcp = setup.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); @@ -422,7 +426,9 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP defer_loading: false, }, ]); + expect(updates[0]?.body).not.toHaveProperty("x_agents_core"); + await setup.getByLabel("Harness").selectOption("codex"); await setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first().getByRole("button", { name: "Remove" }).click(); await setup.getByRole("button", { name: "Save changes" }).click(); await expect(setup.getByRole("status")).toContainText("Agent definition updated"); @@ -431,6 +437,7 @@ test("creates, previews, edits, and removes bounded Function and anonymous HTTP updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); expect(updates).toHaveLength(2); expect(updates[1]?.body?.tools).toEqual([]); + expect(updates[1]?.body?.x_agents_core).toEqual({ harness: "codex" }); }); test("keeps Source Files controls out of the System status page", async ({ page, request }) => { @@ -2439,44 +2446,93 @@ test("presents Dashboard page-chain results and System boundaries without extra await page.getByRole("button", { name: "System", exact: true }).click(); const system = page.locator(".system-page"); - await expect(system.getByRole("listitem").filter({ hasText: "Core API" })).toContainText("Available"); - await expect(system.getByRole("listitem").filter({ hasText: "Vaults" })).toContainText("Available"); - await expect(system.getByRole("listitem").filter({ hasText: "Self-hosted" })).toContainText("Enabled"); - await expect(system.getByRole("listitem").filter({ hasText: "Runtime status" })).toContainText("Cannot be pre-checked"); - await expect(system.getByRole("listitem")).toHaveCount(4); + await expect(system.getByRole("listitem").filter({ hasText: "Daemon gateway" })).toContainText("Enabled"); + await expect(system.getByRole("listitem").filter({ hasText: "Managed sandbox" })).toContainText("Docker"); + await expect(system.getByRole("listitem").filter({ hasText: "Endpoint overrides" })).toContainText("Configured"); + await expect(system.getByRole("listitem")).toHaveCount(3); + await expect(system).not.toContainText("Configured for this process"); + await expect(system).not.toContainText("Managed execution"); + await expect(system).not.toContainText("Self-hosted execution"); + await expect(system).not.toContainText("Default harness"); + await expect(system).toContainText("Agent create and edit forms can select any adapter enabled for this Core process"); + await expect(system).not.toContainText("Enabled · default"); + await expect(system).toContainText("Operator endpoint override: configured"); + await expect(system).toContainText("Compiled into this build, but not enabled when this Core process started"); + await expect(system).toContainText("Runtime connection, native binary availability, sandbox health, and model execution belong to the relevant Session or Environment"); await expect(system).not.toContainText("Source Files"); await expect(system).not.toContainText("Public capability surface"); + await expect(system).not.toContainText("Cannot be pre-checked"); const beforeSystemRefresh = await fixtureRequests(request); const systemRefresh = system.getByRole("button", { name: "Refresh System status" }); await systemRefresh.click(); - await expect.poll(async () => { - const entries = await fixtureRequests(request); - return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; - }).toEqual([ - count(beforeSystemRefresh, "/v1/agents") + 1, - count(beforeSystemRefresh, "/v1/agents/sessions") + 1, - ]); + await expect.poll(async () => count(await fixtureRequests(request), "/v1/agents/core/startup-configuration")).toBe( + count(beforeSystemRefresh, "/v1/agents/core/startup-configuration") + 1, + ); const afterSystemRefresh = await fixtureRequests(request); - expect(count(afterSystemRefresh, "/v1/agents")).toBe(count(beforeSystemRefresh, "/v1/agents") + 1); - expect(count(afterSystemRefresh, "/v1/agents/sessions")).toBe(count(beforeSystemRefresh, "/v1/agents/sessions") + 1); + expect(count(afterSystemRefresh, "/v1/agents")).toBe(count(beforeSystemRefresh, "/v1/agents")); + expect(count(afterSystemRefresh, "/v1/agents/sessions")).toBe(count(beforeSystemRefresh, "/v1/agents/sessions")); + expect(count(afterSystemRefresh, "/v1/vaults")).toBe(count(beforeSystemRefresh, "/v1/vaults")); + expect(count(afterSystemRefresh, "/v1/agents/core/startup-configuration")).toBe( + count(beforeSystemRefresh, "/v1/agents/core/startup-configuration") + 1, + ); for (const path of detailPaths) expect(count(afterSystemRefresh, path)).toBe(count(beforeSystemRefresh, path)); await attachScreenshot(page, testInfo, "desktop-system-contract-boundary"); + await page.setViewportSize({ width: 778, height: 844 }); + const compactSystemBounds = await system.evaluate((element) => ({ + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + right: element.getBoundingClientRect().right, + sectionInset: element.querySelector(".system-config-section")!.getBoundingClientRect().left - element.getBoundingClientRect().left, + sectionEdges: [...element.querySelectorAll(".system-config-section")].map((section) => ({ + left: section.getBoundingClientRect().left - element.getBoundingClientRect().left, + right: element.getBoundingClientRect().right - section.getBoundingClientRect().right, + header: section.querySelector("header")!.getBoundingClientRect().left, + content: section.querySelector(".system-harness-grid")!.getBoundingClientRect().left, + explanation: section.querySelector(".system-config-explanation")?.getBoundingClientRect().left ?? null, + })), + boundaryInset: element.querySelector(".system-boundary-note")!.getBoundingClientRect().left - element.getBoundingClientRect().left, + })); + expect(compactSystemBounds.documentWidth).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); + expect(compactSystemBounds.bodyWidth).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); + expect(compactSystemBounds.right).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); + expect(compactSystemBounds.sectionInset).toBe(24); + expect(compactSystemBounds.boundaryInset).toBe(24); + for (const edge of compactSystemBounds.sectionEdges) { + expect({ left: edge.left, right: edge.right }).toEqual({ left: 24, right: 24 }); + expect(edge.header).toBe(edge.content); + if (edge.explanation !== null) expect(edge.explanation).toBe(edge.header); + } + await page.setViewportSize({ width: 390, height: 844 }); const systemBounds = await system.evaluate((element) => { - const rows = [...element.querySelectorAll(".system-summary-cell")].map((row) => row.getBoundingClientRect()); + const rows = [...element.querySelectorAll(".system-summary-cell")]; return { viewportWidth: innerWidth, documentWidth: document.documentElement.scrollWidth, - rowBounds: rows.map((row) => ({ top: row.top, bottom: row.bottom, height: row.height })), + sectionInset: element.querySelector(".system-config-section")!.getBoundingClientRect().left - element.getBoundingClientRect().left, + sectionRightInset: element.getBoundingClientRect().right - element.querySelector(".system-config-section")!.getBoundingClientRect().right, + boundaryInset: element.querySelector(".system-boundary-note")!.getBoundingClientRect().left - element.getBoundingClientRect().left, + rowBounds: rows.map((row) => ({ + top: row.getBoundingClientRect().top, + bottom: row.getBoundingClientRect().bottom, + height: row.getBoundingClientRect().height, + borderBottomWidth: getComputedStyle(row).borderBottomWidth, + })), }; }); expect(systemBounds.documentWidth).toBeLessThanOrEqual(systemBounds.viewportWidth); + expect(systemBounds.sectionInset).toBe(12); + expect(systemBounds.sectionRightInset).toBe(12); + expect(systemBounds.boundaryInset).toBe(12); for (let index = 1; index < systemBounds.rowBounds.length; index += 1) { expect(systemBounds.rowBounds[index]!.top).toBeGreaterThanOrEqual(systemBounds.rowBounds[index - 1]!.bottom); } expect(systemBounds.rowBounds.every((row) => row.height >= 36)).toBe(true); + expect(systemBounds.rowBounds.slice(0, -1).every((row) => row.borderBottomWidth !== "0px")).toBe(true); + expect(systemBounds.rowBounds.at(-1)?.borderBottomWidth).toBe("0px"); await attachScreenshot(page, testInfo, "narrow-system-contract-boundary"); }); diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts index fc101a463..c61f531ac 100644 --- a/apps/web/e2e/core-connection.spec.ts +++ b/apps/web/e2e/core-connection.spec.ts @@ -8,6 +8,10 @@ interface ProbeInstrumentationWindow extends Window { __probeCallCount?: number; __resolveFirstProbe?: (() => void) | null; __stalledProbeCallCount?: number; + __holdNextLocalStartup?: boolean; + __oldStartupAbortCount?: number; + __resolveOldStartup?: (() => void) | null; + __resolveNewStartup?: (() => void) | null; } async function resetFixture(request: APIRequestContext) { @@ -260,6 +264,105 @@ test("switches real connection modes and fences stale probes when the draft chan await expect(reopened.getByRole("alert")).toHaveCount(0); }); +test("clears startup configuration synchronously and fences a stale read when the Core changes", async ({ + page, + request, +}) => { + await page.addInitScript(() => { + const target = window as ProbeInstrumentationWindow; + const originalFetch = window.fetch.bind(window); + const startupResponse = (defaultHarness: "codex" | "claude_sdk", provider: "docker" | "microsandbox") => new Response(JSON.stringify({ + object: "agents.core.startup_configuration", + schema_version: 1, + supported: { + harnesses: ["claude_sdk", "codex", "mcode"], + managed_sandbox_providers: ["docker", "microsandbox"], + }, + configured: { + default_harness: defaultHarness, + enabled_harnesses: [defaultHarness], + daemon_gateway: true, + self_hosted: true, + managed_sandbox: { enabled: true, provider, maintenance: false }, + model_providers: [{ harness: defaultHarness, endpoint_configured: true }], + }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + + target.__holdNextLocalStartup = false; + target.__oldStartupAbortCount = 0; + target.__resolveOldStartup = null; + target.__resolveNewStartup = null; + window.fetch = (input, init) => { + const value = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const candidate = new URL(value, window.location.href); + if (!candidate.pathname.endsWith("/v1/agents/core/startup-configuration")) return originalFetch(input, init); + const authorization = new Headers(init?.headers).get("Authorization"); + if (target.__holdNextLocalStartup && authorization == null) { + target.__holdNextLocalStartup = false; + init?.signal?.addEventListener("abort", () => { + target.__oldStartupAbortCount = (target.__oldStartupAbortCount ?? 0) + 1; + }, { once: true }); + return new Promise((resolve) => { + target.__resolveOldStartup = () => resolve(startupResponse("codex", "docker")); + }); + } + if (authorization === "Bearer replacement-token") { + return new Promise((resolve) => { + target.__resolveNewStartup = () => resolve(startupResponse("claude_sdk", "microsandbox")); + }); + } + return originalFetch(input, init); + }; + }); + + await boot(page, request); + await page.getByRole("button", { name: "System", exact: true }).click(); + const system = page.locator(".system-page"); + await expect(system.locator(".system-harness-card").filter({ hasText: "Codex" })).toContainText("Enabled"); + await page.evaluate(() => { + (window as ProbeInstrumentationWindow).__holdNextLocalStartup = true; + }); + await system.getByRole("button", { name: "Refresh System status" }).click(); + await expect.poll(() => page.evaluate(() => typeof (window as ProbeInstrumentationWindow).__resolveOldStartup)).toBe("function"); + + const { dialog } = await openConnection(page); + await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); + await dialog.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); + await dialog.getByLabel("Bearer token").fill("replacement-token"); + await dialog.getByRole("button", { name: "Apply connection" }).click(); + + const daemonGateway = system.getByRole("listitem").filter({ hasText: "Daemon gateway" }); + await expect(daemonGateway).toContainText("Checking…"); + await expect(system).not.toContainText("Configured for this process"); + await expect.poll(() => page.evaluate(() => (window as ProbeInstrumentationWindow).__oldStartupAbortCount ?? 0)).toBe(1); + await expect.poll(() => page.evaluate(() => typeof (window as ProbeInstrumentationWindow).__resolveNewStartup)).toBe("function"); + await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveNewStartup?.()); + await expect(system.locator(".system-harness-card").filter({ hasText: "Claude SDK" })).toContainText("Enabled"); + await expect(system.getByRole("listitem").filter({ hasText: "Managed sandbox" })).toContainText("Microsandbox"); + + await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveOldStartup?.()); + await expect(system.locator(".system-harness-card").filter({ hasText: "Claude SDK" })).toContainText("Enabled"); + await expect(system).not.toContainText("Docker · Maintenance"); +}); + +for (const status of [404, 405]) { + test(`shows startup configuration as unsupported when an older Core returns ${status}`, async ({ page, request }) => { + await page.route("**/v1/agents/core/startup-configuration", (route) => route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ error: { code: "unsupported", message: "Unavailable." } }), + })); + await boot(page, request); + await page.getByRole("button", { name: "System", exact: true }).click(); + + const system = page.locator(".system-page"); + await expect(system.getByRole("listitem").filter({ hasText: "Daemon gateway" })).toContainText("Not exposed"); + await expect(system).toContainText("This Core version does not expose the startup configuration extension"); + await expect(system).not.toContainText("Configured for this process"); + await expect(system).not.toContainText("Checking…"); + }); +} + test("announces loading, authenticated access, and each safe failure state from one GET", async ({ page, request, diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 00a142d7b..98c1d0a8b 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -875,6 +875,30 @@ const server = http.createServer(async (request, response) => { const body = request.method === "GET" || request.method === "DELETE" ? undefined : await readJson(request); recordRequest(request, url, body); + if (request.method === "GET" && url.pathname === "/v1/agents/core/startup-configuration") { + if (url.search) return sendError(response, 400, "Fixture startup configuration does not accept query parameters."); + response.setHeader("cache-control", "no-store"); + return sendJson(response, { + object: "agents.core.startup_configuration", + schema_version: 1, + supported: { + harnesses: ["claude_sdk", "codex", "mcode"], + managed_sandbox_providers: ["docker", "microsandbox"], + }, + configured: { + default_harness: "codex", + enabled_harnesses: ["claude_sdk", "codex"], + daemon_gateway: true, + self_hosted: true, + managed_sandbox: { enabled: true, provider: "docker", maintenance: false }, + model_providers: [ + { harness: "claude_sdk", endpoint_configured: false }, + { harness: "codex", endpoint_configured: true }, + ], + }, + }); + } + if (url.pathname === "/v1/vaults") { if (request.method === "GET") return sendJson(response, page(state.vaults)); if (request.method === "POST") { @@ -973,6 +997,7 @@ const server = http.createServer(async (request, response) => { const defaults = savedAgent(`agent_created_${state.sequence}`, body.name ?? null, body.model, baseline + state.sequence); const created = { ...defaults, + ...(body.x_agents_core === undefined ? {} : { x_agents_core: body.x_agents_core }), instructions: body.instructions ?? null, metadata: body.metadata ?? {}, multi_agent: body.multi_agent ?? { enabled: false, max_concurrent_subagents: null }, diff --git a/apps/web/e2e/vault-credentials.spec.ts b/apps/web/e2e/vault-credentials.spec.ts index 3019b8685..7e1b98895 100644 --- a/apps/web/e2e/vault-credentials.spec.ts +++ b/apps/web/e2e/vault-credentials.spec.ts @@ -62,8 +62,7 @@ test.describe("Vault capability discovery", () => { await expect(page.getByRole("button", { name: "Vaults", exact: true })).toHaveCount(0); await page.getByRole("button", { name: "System", exact: true }).click(); - await expect(page.locator(".system-summary-cell").filter({ hasText: "Vaults" })) - .toContainText("Unavailable"); + await expect(page.locator(".system-page")).not.toContainText("Vaults"); }); } }); @@ -159,7 +158,7 @@ test("creates, replaces, uses, and deletes a write-only Vault Credential", async const mcpURL = "https://mcp.example/vault-tools"; await openAgents(page, request); - const vaultsNavigation = page.getByRole("button", { name: "Vaults" }); + const vaultsNavigation = page.getByRole("button", { name: "Vaults", exact: true }); await expect(vaultsNavigation).toBeVisible(); await vaultsNavigation.click(); await page.getByRole("button", { name: "New Vault" }).click(); @@ -230,7 +229,7 @@ test("creates, replaces, uses, and deletes a write-only Vault Credential", async expect(sessionCreate?.body?.vault_ids).toEqual([vaultId]); expect(JSON.stringify(requests)).not.toContain('"token":'); - await page.getByRole("button", { name: "Vaults" }).click(); + await page.getByRole("button", { name: "Vaults", exact: true }).click(); const currentVaultCard = page.locator(".vault-card").filter({ hasText: "Runtime credentials" }); await currentVaultCard.getByRole("button", { name: "Delete Private docs MCP", exact: true }).click(); const deleteCredentialDialog = page.getByRole("dialog", { name: "Delete Credential?" }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0bf723930..cc0f0482c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,6 +6,7 @@ import type { AgentCore, AgentSession, AgentTurn, + CoreStartupConfiguration, CreateAgentInput, CreateEnvironmentTemplateInput, FunctionResultInput, @@ -250,6 +251,9 @@ export function App() { const [vaultCollectionState, setVaultCollectionState] = useState("connecting"); const [vaultCollectionError, setVaultCollectionError] = useState(null); const [vaultSupported, setVaultSupported] = useState(null); + const [startupConfiguration, setStartupConfiguration] = useState(null); + const [startupConfigurationState, setStartupConfigurationState] = useState("connecting"); + const [startupConfigurationSupported, setStartupConfigurationSupported] = useState(null); const [environmentTemplates, setEnvironmentTemplates] = useState(null); const [sessions, setSessions] = useState([]); const [selectedId, setSelectedId] = useState(null); @@ -310,8 +314,10 @@ export function App() { const filteredSessionCollectionRequestRef = useRef(0); const sessionAgentFilterRef = useRef(sessionAgentFilter); const vaultCollectionAbortRef = useRef(null); + const startupConfigurationAbortRef = useRef(null); const environmentTemplateAbortRef = useRef(null); const vaultCollectionRequestRef = useRef(0); + const startupConfigurationRequestRef = useRef(0); const agentCollectionRevisionRef = useRef(0); const sessionCollectionRevisionRef = useRef(0); const sessionRequestRef = useRef(new Map()); @@ -621,6 +627,44 @@ export function App() { } }, [core, coreGeneration]); + const refreshStartupConfiguration = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + startupConfigurationAbortRef.current?.abort(); + const controller = new AbortController(); + startupConfigurationAbortRef.current = controller; + const request = startupConfigurationRequestRef.current + 1; + startupConfigurationRequestRef.current = request; + setStartupConfigurationState("connecting"); + try { + const configuration = await core.retrieveStartupConfiguration({ signal: controller.signal }); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== startupConfigurationRequestRef.current + ) return false; + setStartupConfiguration(configuration); + setStartupConfigurationSupported(true); + setStartupConfigurationState("ready"); + return true; + } catch (error) { + if ( + coreGeneration !== connectionGenerationRef.current || + request !== startupConfigurationRequestRef.current || + isAbort(error) + ) return false; + setStartupConfiguration(null); + if (error instanceof AgentCoreError && (error.status === 404 || error.status === 405)) { + setStartupConfigurationSupported(false); + setStartupConfigurationState("ready"); + return false; + } + setStartupConfigurationSupported(null); + setStartupConfigurationState("failed"); + return false; + } finally { + if (startupConfigurationAbortRef.current === controller) startupConfigurationAbortRef.current = null; + } + }, [core, coreGeneration]); + const refreshEnvironmentTemplates = useCallback(async () => { // Managed Environment configuration is only ever read for the Web build that // can request it. An unread catalog stays null, never an implied capability. @@ -859,6 +903,10 @@ export function App() { if (filter) void refreshFilteredSessions(filter); }, [refreshAgents, refreshEnvironmentTemplates, refreshFilteredSessions, refreshSessions, refreshVaults]); + const refreshSystem = useCallback(() => { + void refreshStartupConfiguration(); + }, [refreshStartupConfiguration]); + const changeSessionAgentFilter = useCallback((agentId: string | null) => { if (sessionAgentFilterRef.current === agentId) return; filteredSessionCollectionAbortRef.current?.abort(); @@ -891,6 +939,9 @@ export function App() { setVaultCollectionState("connecting"); setVaultCollectionError(null); setVaultSupported(null); + setStartupConfiguration(null); + setStartupConfigurationState("connecting"); + setStartupConfigurationSupported(null); setSessions([]); setAgentCollectionHasSnapshot(false); setSessionCollectionHasSnapshot(false); @@ -908,7 +959,8 @@ export function App() { void refreshSessions(); void refreshVaults(); void refreshEnvironmentTemplates(); - }, [refreshAgents, refreshEnvironmentTemplates, refreshSessions, refreshVaults]); + void refreshStartupConfiguration(); + }, [refreshAgents, refreshEnvironmentTemplates, refreshSessions, refreshStartupConfiguration, refreshVaults]); useEffect(() => { filteredSessionCollectionAbortRef.current?.abort(); @@ -1951,6 +2003,7 @@ export function App() { sessionCollectionRequestRef.current += 1; filteredSessionCollectionRequestRef.current += 1; vaultCollectionRequestRef.current += 1; + startupConfigurationRequestRef.current += 1; agentCollectionRevisionRef.current = 0; sessionCollectionRevisionRef.current = 0; sessionRequestRef.current.clear(); @@ -1976,6 +2029,8 @@ export function App() { filteredSessionCollectionAbortRef.current = null; vaultCollectionAbortRef.current?.abort(); vaultCollectionAbortRef.current = null; + startupConfigurationAbortRef.current?.abort(); + startupConfigurationAbortRef.current = null; environmentTemplateAbortRef.current?.abort(); environmentTemplateAbortRef.current = null; setEnvironmentTemplates(null); @@ -1997,6 +2052,9 @@ export function App() { setVaultCollectionError(null); setVaultSupported(null); setVaultCatalog(null); + setStartupConfiguration(null); + setStartupConfigurationState("connecting"); + setStartupConfigurationSupported(null); setAgents([]); setSessions([]); setItems([]); @@ -2181,6 +2239,7 @@ export function App() { coreBaseUrl={connection.baseUrl} coreError={agentCollectionError} coreState={agentCollectionState} + startupConfiguration={startupConfigurationState === "ready" && startupConfigurationSupported === true ? startupConfiguration : null} vaultCatalog={sessionVaultCatalog} createRequest={agentCreateRequest ?? 0} onCreateRequestConsumed={consumeAgentCreateRequest} @@ -2205,18 +2264,13 @@ export function App() { {view === "system" ? ( ) : null} diff --git a/apps/web/src/features/agents/AgentForm.tsx b/apps/web/src/features/agents/AgentForm.tsx index 2d5005796..a7e50c586 100644 --- a/apps/web/src/features/agents/AgentForm.tsx +++ b/apps/web/src/features/agents/AgentForm.tsx @@ -1,7 +1,7 @@ import { Info } from "lucide-react"; import { useEffect, useRef, useState, type FormEvent } from "react"; -import type { SavedAgent } from "@agents-core-web/agents-client"; +import type { CoreHarnessKind, SavedAgent } from "@agents-core-web/agents-client"; import { buildModelOptionGroups, @@ -15,7 +15,9 @@ import { type AgentFormSubmitInput, type AgentFormValues, type AgentToolDraft, v interface AgentFormProps { agent?: SavedAgent; + defaultHarness?: CoreHarnessKind; disabled?: boolean; + enabledHarnesses?: readonly CoreHarnessKind[] | null; formId: string; initialValues?: AgentFormValues; knownModels: string[]; @@ -24,9 +26,29 @@ interface AgentFormProps { onSubmit: (input: AgentFormSubmitInput) => Promise; } -export function AgentForm({ agent, disabled = false, formId, initialValues, knownModels, vaultCatalog = null, onDraftChange, onSubmit }: AgentFormProps) { +function harnessLabel(harness: CoreHarnessKind): string { + if (harness === "claude_sdk") return "Claude SDK"; + if (harness === "mcode") return "MiniMax Code"; + return "Codex"; +} + +export function AgentForm({ + agent, + defaultHarness, + disabled = false, + enabledHarnesses = null, + formId, + initialValues, + knownModels, + vaultCatalog = null, + onDraftChange, + onSubmit, +}: AgentFormProps) { const nameRef = useRef(null); const initial = agent ? valuesFromAgent(agent, vaultCatalog) : initialValues ?? valuesFromAgent(undefined, vaultCatalog); + const initialHarness = agent + ? initial.harness + : initial.harness || (defaultHarness && enabledHarnesses?.includes(defaultHarness) ? defaultHarness : ""); const options = buildModelOptionGroups( knownModels, import.meta.env.VITE_AGENT_MODEL_PRESETS, @@ -34,6 +56,8 @@ export function AgentForm({ agent, disabled = false, formId, initialValues, know ); const initialIsSuggested = [...options.configured, ...options.previouslyUsed].includes(initial.model); const [name, setName] = useState(initial.name); + const [harness, setHarness] = useState(initialHarness); + const [harnessModified, setHarnessModified] = useState(initial.harnessModified); const [modelChoice, setModelChoice] = useState( initial.model && !initialIsSuggested ? CUSTOM_MODEL_OPTION : modelOptionValue(initial.model || options.defaultModel), ); @@ -65,9 +89,17 @@ export function AgentForm({ agent, disabled = false, formId, initialValues, know return () => window.cancelAnimationFrame(frame); }, []); + useEffect(() => { + if (!agent && !harness && defaultHarness && enabledHarnesses?.includes(defaultHarness)) { + setHarness(defaultHarness); + } + }, [agent, defaultHarness, enabledHarnesses, harness]); + useEffect(() => { onDraftChange?.({ name, + harness, + harnessModified, model, instructions, metadata, @@ -79,12 +111,14 @@ export function AgentForm({ agent, disabled = false, formId, initialValues, know tools, toolsModified, }); - }, [instructions, metadata, model, name, onDraftChange, reasoningEffort, reasoningSummary, serviceTier, textFormat, textVerbosity, tools, toolsModified]); + }, [harness, harnessModified, instructions, metadata, model, name, onDraftChange, reasoningEffort, reasoningSummary, serviceTier, textFormat, textVerbosity, tools, toolsModified]); const submit = async (event: FormEvent) => { event.preventDefault(); const result = validateAgentForm({ name, + harness, + harnessModified, model, instructions, metadata, @@ -134,6 +168,40 @@ export function AgentForm({ agent, disabled = false, formId, initialValues, know rows={5} /> + {enabledHarnesses !== null ? ( +
+ + + + Only harnesses enabled when this Core process started are selectable. Selection does not prove Runtime, provider, or model readiness. + +
+ ) : null}