diff --git a/README.md b/README.md index ddaccce..898e97a 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,12 @@ Restart `pnpm dev` after changing them. Keep credentials server-side. A direct Core URL in the connection dialog is only for a compatible Core that explicitly allows the Web origin, methods, and headers through CORS. -Do not have a Core running yet? Follow [Connecting Agent Core](docs/core-connection.md). -That guide contains the complete PostgreSQL, caller key, device, daemon, native -harness, `CODEX_HOME`, security, verification, and shutdown procedures. +Do not have a Core running yet? Use the immutable +[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service). +The repository's [legacy Web connection runbook](docs/core-connection.md) is pinned +to the older revision stated at its top; revalidate its PostgreSQL, caller-key, +device, daemon, native-harness, `CODEX_HOME`, verification, and shutdown steps before +applying them to a newer Core. ## First use @@ -110,16 +113,18 @@ See [Architecture](docs/architecture.md) for the full component and trust bounda | --- | --- | | Web cannot reach Core | Confirm the Core address and `AGENTS_API_PROXY_TARGET`, then restart Vite | | `401 invalid_api_key` | The plaintext caller bearer must match the current Core key binding | -| `503 execution_unavailable` / `Execution is not enabled` | Core is reachable but has no enabled execution path; connect its configured executor/daemon | +| `503 execution_unavailable` / `Execution is not enabled` | Core rejected execution; inspect its safe error plus runtime and ownership state. A worker, executor, or daemon may be unconfigured or disconnected, or an execution lease may have been lost | | Agent saves but its model fails | Use a model ID and provider credential supported by the connected runtime | -`/healthz` proves HTTP liveness only, not chat readiness. See the -[full troubleshooting guide](docs/core-connection.md#troubleshooting) before retrying -an uncertain request. +`/healthz` proves HTTP liveness only, not chat readiness. Check durable Core state and +the current pinned Parsar guide before retrying an uncertain request; the +[legacy 043 troubleshooting snapshot](docs/core-connection.md#troubleshooting) is +historical context only. ## Documentation -- [Connect Agent Core](docs/core-connection.md) — full local and deployment setup +- [Current Parsar Core setup](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) — immutable current upstream guide +- [Legacy Web connection runbook](docs/core-connection.md) — historical `0438880` snapshot; revalidate before use - [Protocol coverage](docs/protocol-coverage.md) — exact supported API surface - [Architecture](docs/architecture.md) — ownership, runtime, and trust boundaries - [Roadmap](docs/roadmap.md) — planned Web and Core integrations diff --git a/README.zh-CN.md b/README.zh-CN.md index a186461..badac83 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,9 +70,11 @@ AGENTS_API_PROXY_TOKEN_FILE=/absolute/private/path/to/web-token 修改后重启 `pnpm dev`。凭据应保留在服务端。只有兼容 Core 通过 CORS 明确允许 Web 的源、方法和请求头时,才能在连接对话框中使用 Core 直连 URL。 -还没有运行中的 Core?请参阅[连接 Agent Core](docs/core-connection.md)。 -该文档包含完整的 PostgreSQL、调用方凭据、执行设备、daemon、原生执行适配层(harness)、 -`CODEX_HOME`、安全、验证和停止流程。 +还没有运行中的 Core?请使用不可变的 +[当前 Parsar 配置指南](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service)。 +仓库内的[旧版 Web 连接手册](docs/core-connection.md)固定在文首标注的旧 revision; +将其中 PostgreSQL、调用方凭据、执行设备、daemon、原生执行适配层(harness)、 +`CODEX_HOME`、验证和停止流程用于更新版 Core 前必须重新核对。 ## 第一次使用 @@ -108,15 +110,17 @@ WebSocket 当作 API URL。 | --- | --- | | Web 无法访问 Core | 确认 Core 地址和 `AGENTS_API_PROXY_TARGET`,然后重启 Vite | | `401 invalid_api_key` | 明文调用方 Bearer 凭据必须与 Core 当前的密钥绑定匹配 | -| `503 execution_unavailable` / `Execution is not enabled` | Core 可以访问,但没有已启用的执行链路;请连接其配置的 executor/daemon | +| `503 execution_unavailable` / `Execution is not enabled` | Core 拒绝执行;请检查其安全错误、运行时和 ownership 状态。worker、executor 或 daemon 可能未配置或已断连,也可能丢失了执行 lease | | Agent 保存成功但模型运行失败 | 使用已连接运行时支持的 model ID 和提供商凭据 | `/healthz` 只能证明 HTTP 存活,不能证明聊天已就绪。重试结果不确定的请求前, -请先查看[完整故障排查](docs/core-connection.md#troubleshooting)。 +请先核对 Core 持久状态和当前固定版本的 Parsar 指南; +[旧版 043 故障排查快照](docs/core-connection.md#troubleshooting)仅供历史参考。 ## 文档入口 -- [连接 Agent Core](docs/core-connection.md) — 完整本地和部署配置 +- [当前 Parsar Core 配置](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) — 不可变的当前上游指南 +- [旧版 Web 连接手册](docs/core-connection.md) — 历史 `0438880` 快照,使用前必须重新核对 - [协议覆盖范围](docs/protocol-coverage.md) — 准确的已支持 API 范围 - [架构说明](docs/architecture.md) — 所有权、运行时和信任边界 - [路线图](docs/roadmap.md) — 计划中的 Web 和 Core 集成 diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index e991d14..29ac6c2 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -135,7 +135,7 @@ test("retrieves latest details and reuses a validated create/edit form", async ( await expect(dialog).not.toContainText("stale list"); await expect(dialog).toContainText("agent_a"); await expect(dialog).toContainText("Advanced configuration · read only"); - await expect(dialog).toContainText("does not prove the current executor"); + await expect(dialog).toContainText("known Core Session profile cannot start it"); await expect(dialog.locator("input, textarea, select")).toHaveCount(0); await attachScreenshot(page, testInfo, "desktop-light-agent-details"); @@ -151,9 +151,19 @@ test("retrieves latest details and reuses a validated create/edit form", async ( let requests = await fixtureRequests(request); expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); + await page.getByLabel("Metadata").fill(JSON.stringify(Object.fromEntries( + Array.from({ length: 17 }, (_, index) => [`key-${index}`, "value"]), + ))); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Agent metadata supports at most 16 pairs.")).toBeVisible(); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); + await name.fill(""); await page.getByLabel("Instructions").fill(""); await page.getByLabel("Metadata").fill('{"team":"acceptance"}'); + await page.getByLabel("Reasoning effort").selectOption(""); + await page.getByLabel("Reasoning summary").selectOption(""); await page.getByRole("button", { name: "Save changes" }).click(); await expect(page.getByRole("button", { name: "Edit" })).toBeFocused(); @@ -164,34 +174,72 @@ test("retrieves latest details and reuses a validated create/edit form", async ( name: null, instructions: null, metadata: { team: "acceptance" }, + reasoning: { effort: null, summary: null }, }); expect(browserErrors).toEqual([]); }); -test("supports keyboard creation, traps focus, and returns focus on Escape", async ({ page, request }) => { +test("supports global Create keyboard navigation and consumes setup requests once", async ({ page, request }) => { await openAgents(page, request); - const trigger = page.getByRole("button", { name: "New Agent" }); - await trigger.click(); + const sidebar = page.locator(".app-sidebar"); + const productNavigation = sidebar.getByRole("navigation", { name: "Agents product" }); + await expect(productNavigation).toBeVisible(); + await expect(productNavigation.getByRole("button", { name: "Agents", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".product-header").getByRole("navigation", { name: "Agents product" })).toHaveCount(0); + const createMenu = page.getByRole("button", { name: "Create", exact: true }); + await createMenu.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("menuitem", { name: /^Agent\b/ })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(createMenu).toBeFocused(); + await page.keyboard.press("ArrowDown"); + const createAgentItem = page.getByRole("menuitem", { name: /^Agent\b/ }); + const startSessionItem = page.getByRole("menuitem", { name: /^Start Session\b/ }); + await expect(createAgentItem).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(startSessionItem).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await expect(createAgentItem).toBeFocused(); + await page.keyboard.press("Enter"); await expect(page.getByLabel("Name")).toBeFocused(); + await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); - const close = page.getByRole("button", { name: "Close dialog" }); - await close.focus(); - await page.keyboard.press("Shift+Tab"); - await expect(page.getByRole("button", { name: "Create Agent" })).toBeFocused(); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "New Agent" })).toHaveCount(0); - await page.getByLabel("Name").focus(); - await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(trigger).toBeFocused(); const detailTrigger = page.getByRole("button", { name: /Open details for Lifecycle Agent/ }); await detailTrigger.focus(); await detailTrigger.evaluate((button) => button.click()); await expect(page.getByRole("dialog").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); await expect(detailTrigger).toBeFocused(); await expect(page.locator(".modal-backdrop")).toHaveCount(0); - await trigger.click(); + await createMenu.click(); + await startSessionItem.click(); + const sessionDialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent")).toHaveValue("agent_b"); + await expect(sessionDialog.locator('option[value="agent_a"]')).toHaveAttribute("disabled", ""); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toHaveAttribute("disabled", ""); + const sessionPostsBeforeCancel = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await sessionDialog.getByRole("button", { name: "Cancel" }).click(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(sessionPostsBeforeCancel); + await expect(createMenu).toBeFocused(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Start an idle Session" })).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await createMenu.click(); + await createAgentItem.click(); const createMetadata = page.locator(".agent-metadata-input"); const createName = page.locator('input[data-agent-initial-focus="true"]'); await expect(createName).toBeFocused(); @@ -200,7 +248,20 @@ test("supports keyboard creation, traps focus, and returns focus on Escape", asy await expect(createName).toHaveValue(""); await createName.focus(); await page.keyboard.press("Enter"); - await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + + await createMenu.click(); + await createAgentItem.click(); + await expect(page.getByLabel("Name")).toHaveValue(""); + await expect(page.getByLabel("Name")).toBeEnabled(); + await expect(page.getByRole("status")).toHaveCount(0); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(createMenu).toBeFocused(); + + const ledgerCreate = page.getByRole("button", { name: "New Agent" }); + await ledgerCreate.click(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(ledgerCreate).toBeFocused(); const requests = await fixtureRequests(request); const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); @@ -209,7 +270,132 @@ test("supports keyboard creation, traps focus, and returns focus on Escape", asy name: null, instructions: null, metadata: { owner: "local-test" }, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, }); + expect(creates[0]?.body).not.toHaveProperty("reasoning"); +}); + +test("continues from a default Agent definition into an admitted idle Session", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: "New Agent" }).click(); + await page.getByLabel("Name").fill("Session-safe Agent"); + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + + const requestsAfterSave = await fixtureRequests(request); + const create = requestsAfterSave.find((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(create?.body).toMatchObject({ + name: "Session-safe Agent", + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + }); + expect(create?.body).not.toHaveProperty("reasoning"); + + await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); + await page.getByRole("button", { name: "Start Session" }).click(); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + + const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(sessionCreates).toHaveLength(1); + expect(sessionCreates[0]?.body).toMatchObject({ + agent_id: expect.stringMatching(/^agent_created_/), + environment: { type: "none" }, + stream: false, + }); +}); + +test("rechecks the saved response before offering the setup-page Session continuation", async ({ page, request }) => { + await openAgents(page, request); + await controlFixture(request, { createAgentResponseVariant: "reasoning" }); + await page.getByRole("button", { name: "New Agent" }).click(); + await page.getByLabel("Name").fill("Core-adjusted Agent"); + await page.getByRole("button", { name: "Save Agent definition" }).click(); + + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + await expect(page.locator("#created-agent-session-blocker")).toContainText("Start Session is unavailable"); + const startSession = page.getByRole("button", { name: "Start Session" }); + await expect(startSession).toBeDisabled(); + + // Bypass the setup view's disabled control to prove App's final admission + // guard independently blocks the write if a caller reaches it anyway. + await startSession.evaluate((button) => { + const propsKey = Object.getOwnPropertyNames(button).find((key) => key.startsWith("__reactProps$")); + if (!propsKey) throw new Error("React event props were not found on the Start Session button."); + const props = (button as unknown as Record void }>)[propsKey]; + if (!props?.onClick) throw new Error("Start Session does not have an onClick handler."); + props.onClick(); + }); + await expect(page.getByText(/Session was not created\./)).toBeVisible(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(0); +}); + +test("starts only Agents that pass known Session admission", async ({ page, request }) => { + await openAgents(page, request); + + const blockedStart = page.getByRole("button", { name: /Start a Session with Lifecycle Agent/ }); + await expect(blockedStart).toHaveAttribute("aria-disabled", "true"); + await blockedStart.focus(); + await expect(blockedStart.locator("xpath=..").getByRole("tooltip")).toBeVisible(); + const blockedSessionCount = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await page.keyboard.press("Enter"); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(blockedSessionCount); + + const toolOnlyStart = page.getByRole("button", { name: "Start a Session with Saved-only Tool Agent" }); + await expect(toolOnlyStart).toHaveAttribute("aria-disabled", "true"); + await toolOnlyStart.focus(); + await expect(toolOnlyStart.locator("xpath=..").getByRole("tooltip")).toContainText("tool_search is saved-only"); + await page.keyboard.press("Enter"); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(blockedSessionCount); + await expect(page.getByRole("button", { name: "Start a Session with Second Agent" })).toBeEnabled(); + + const before = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await page.getByRole("button", { name: "Start a Session with Second Agent" }).click(); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + + const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(sessionCreates).toHaveLength(before + 1); + expect(sessionCreates.at(-1)?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + stream: false, + }); +}); + +test("keeps the New Session reason keyboard-accessible when every loaded Agent is incompatible", async ({ page, request }) => { + await openAgents(page, request); + const deleted = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); + expect(deleted.ok()).toBe(true); + await page.getByRole("button", { name: "Refresh Agents" }).click(); + await expect(page.getByRole("button", { name: "Open details for Second Agent" })).toHaveCount(0); + + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + const newSession = page.getByRole("button", { name: "New Session" }); + await expect(newSession).toHaveAttribute("aria-disabled", "true"); + await newSession.focus(); + await expect(newSession.locator("xpath=..").getByRole("tooltip")).toContainText("No loaded Agent matches"); + const before = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )).length; + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog", { name: "Start an idle Session" })).toHaveCount(0); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(before); }); test("keeps failures visible, rejects stale async continuations, and never retries writes", async ({ page, request }) => { @@ -317,17 +503,39 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode expect(metrics.ledger?.right).toBeLessThanOrEqual(390); await attachScreenshot(page, testInfo, "narrow-light-agent-ledger"); - await page.getByRole("button", { name: "New Agent" }).click(); - const dialog = page.getByRole("dialog"); - const box = await dialog.boundingBox(); + await expect(page.getByRole("button", { name: "Environments", exact: true })).toHaveCount(0); + const sessionsNavigation = page.getByRole("button", { name: "Sessions", exact: true }); + await sessionsNavigation.click(); + await expect(sessionsNavigation).toHaveAttribute("aria-current", "page"); + await expect(page.locator(".session-page")).toBeVisible(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + + const globalCreate = page.getByRole("button", { name: "Create", exact: true }); + await globalCreate.click(); + const createPanel = page.getByRole("menu", { name: "Create" }); + await expect(createPanel).toBeVisible(); + await expect(createPanel.getByRole("menuitem")).toHaveCount(2); + await expect(createPanel.getByRole("menuitem", { name: /Environment template/i })).toHaveCount(0); + await expect(createPanel.getByRole("menuitem", { name: /Environment key/i })).toHaveCount(0); + const createPanelBox = await createPanel.boundingBox(); + expect(createPanelBox).not.toBeNull(); + expect(createPanelBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((createPanelBox?.x ?? 0) + (createPanelBox?.width ?? 0)).toBeLessThanOrEqual(390); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await createPanel.getByRole("menuitem", { name: /^Agent\b/ }).click(); + const setup = page.locator(".agent-setup-page"); + const box = await setup.boundingBox(); expect(box).not.toBeNull(); expect(box?.x ?? -1).toBeGreaterThanOrEqual(0); expect(box?.y ?? -1).toBeGreaterThanOrEqual(0); - expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390); - expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(844); - await expect(page.getByRole("button", { name: "Create Agent" })).toBeInViewport(); - await attachScreenshot(page, testInfo, "narrow-light-create-dialog"); - await page.keyboard.press("Escape"); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390.5); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await expect(page.getByRole("button", { name: "Save Agent definition" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); + await expect(page.getByLabel("Text format")).toHaveValue("Text"); + await attachScreenshot(page, testInfo, "narrow-light-agent-setup"); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(globalCreate).toBeFocused(); await page.getByRole("button", { name: "Dark theme" }).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); @@ -837,7 +1045,10 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect(panel.getByRole("link", { name: "Launcher setup" })).toBeVisible(); await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); await expect(page.getByRole("region", { name: "Function result required" })).toBeVisible(); - await expect(page.getByLabel("Function result or error")).toBeVisible(); + await expect(page.getByLabel("Function result or error")).toBeEnabled(); + await expect(page.getByRole("button", { name: "Return error" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Submit result" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); await expect(page.locator("body")).not.toContainText("launcher:private"); await expect(page.locator("body")).not.toContainText("executor_token=secret"); await expect(page.locator('a[href^="file:"]')).toHaveCount(0); @@ -1198,3 +1409,25 @@ test("manually retries uncertain sends with the original key only while the payl expect(sends[5]?.idempotencyKey).not.toBe(sends[4]?.idempotencyKey); await attachScreenshot(page, testInfo, "desktop-send-recovery"); }); + +test("keeps cancellation available for an Environment-only required action", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 6 }); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); + const writesBefore = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + const cancel = page.getByRole("button", { name: "Cancel active Turn" }); + await expect(cancel).toBeEnabled(); + await cancel.click(); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(writesBefore + 1); + const writes = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(writes.at(-1)?.body).toEqual({ events: [{ type: "agent.session.input.cancel" }] }); +}); diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts index c7b86c4..e357bcd 100644 --- a/apps/web/e2e/core-connection.spec.ts +++ b/apps/web/e2e/core-connection.spec.ts @@ -303,7 +303,7 @@ test("announces loading, authenticated access, and each safe failure state from methods.push(route.request().method()); const reply = replies.shift(); if (!reply) return route.abort("failed"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 150)); if ("abort" in reply) return route.abort("failed"); return route.fulfill({ status: reply.status, @@ -333,7 +333,8 @@ test("announces loading, authenticated access, and each safe failure state from await expect(loading).toContainText("Testing Core connection…"); const terminal = dialog.getByRole(expected.role); await expect(terminal).toContainText(expected.text); - await expect(terminal).toContainText("Execution readiness: Unknown / not verified"); + await expect(terminal).toContainText("Chat uses the current Agents API contract"); + await expect(terminal).toContainText("does not start a Turn or verify its runtime dependencies"); if (expected.absentText) await expect(terminal).not.toContainText(expected.absentText); } @@ -373,7 +374,8 @@ test("turns a stalled probe into one bounded unreachable result", async ({ page, (window as ProbeInstrumentationWindow).__stalledProbeCallCount ?? 0 ))).toBe(1); await expect(dialog.getByRole("alert")).toContainText("Core unreachable", { timeout: 7_500 }); - await expect(dialog.getByRole("alert")).toContainText("Execution readiness: Unknown / not verified"); + await expect(dialog.getByRole("alert")).toContainText("Chat uses the current Agents API contract"); + await expect(dialog.getByRole("alert")).toContainText("does not start a Turn or verify its runtime dependencies"); expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__stalledProbeCallCount)).toBe(1); }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 0d72f98..577736b 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -63,6 +63,97 @@ function savedAgent(id, name, model, updatedAt) { }; } +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasOnlyKeys(value, allowed) { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function isEmptyObject(value) { + return value == null || isRecord(value) && Object.keys(value).length === 0; +} + +function isSafeMcpUrl(value) { + if ( + typeof value !== "string" + || /^\p{White_Space}|\p{White_Space}$/u.test(value) + || /[\u0000-\u0020\u007f\\]/u.test(value) + || value.includes("?") + || value.includes("#") + || /%(?![0-9A-Fa-f]{2})/u.test(value) + ) return false; + const schemeSeparator = value.indexOf("://"); + const authority = schemeSeparator >= 0 ? value.slice(schemeSeparator + 3).split("/", 1)[0] : ""; + if (!authority || authority.includes("@") || authority.includes("%") || /[{}\x60]/u.test(authority)) return false; + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) && Boolean(url.hostname) && !url.username && !url.password; + } catch { + return false; + } +} + +function isCanonicalExecutionFunction(tool) { + return hasOnlyKeys(tool, ["type", "name", "description", "parameters", "defer_loading"]) + && typeof tool.name === "string" + && typeof tool.description === "string" + && isRecord(tool.parameters) + && (tool.defer_loading === undefined || typeof tool.defer_loading === "boolean"); +} + +function isCanonicalExecutionMcp(tool) { + const transport = tool.transport; + const allowedTools = tool.allowed_tools; + return hasOnlyKeys(tool, [ + "type", "server_label", "transport", "allowed_tools", "connection_origin", + "credential_id", "request_metadata", "required", + ]) + && typeof tool.server_label === "string" + && !/^\p{White_Space}*$/u.test(tool.server_label) + && isRecord(transport) + && hasOnlyKeys(transport, ["type", "server_url", "headers"]) + && transport.type === "http" + && isSafeMcpUrl(transport.server_url) + && isEmptyObject(transport.headers) + && tool.connection_origin === "service" + && (tool.credential_id == null || typeof tool.credential_id === "string") + && isEmptyObject(tool.request_metadata) + && (tool.required === undefined || typeof tool.required === "boolean") + && (allowedTools == null || Array.isArray(allowedTools) && allowedTools.every((name) => typeof name === "string" && name.length > 0)); +} + +function sessionAdmissionError(agent) { + if (/^\p{White_Space}*$/u.test(agent.model)) return "Execution currently requires a nonempty model."; + if (agent.multi_agent.enabled || agent.multi_agent.max_concurrent_subagents !== null) return "Enabled multi_agent execution is not supported by this service yet."; + if (agent.reasoning.effort != null || agent.reasoning.summary != null) return "Explicit reasoning execution options are not supported by this service yet."; + if (agent.service_tier !== "auto") return "Execution currently supports service_tier=auto only."; + if (agent.text.format.type !== "text") return "Execution currently supports text.format.type=text only."; + + const functionNames = new Set(); + const mcpLabels = new Set(); + let functionCount = 0; + for (const tool of agent.tools) { + if (tool.type === "function") { + functionCount += 1; + if (!isCanonicalExecutionFunction(tool) || /^\p{White_Space}*$/u.test(tool.name) || Buffer.byteLength(tool.name, "utf8") > 512 || functionNames.has(tool.name) || tool.defer_loading === true) { + return "Invalid execution function fields."; + } + functionNames.add(tool.name); + } else if (tool.type === "mcp") { + if (!isCanonicalExecutionMcp(tool) || mcpLabels.has(tool.server_label) || tool.credential_id != null) { + return "Invalid execution MCP fields."; + } + mcpLabels.add(tool.server_label); + } else { + return "Execution currently supports non-deferred functions and the service-origin HTTP MCP profile only."; + } + } + if (functionCount > 64) return "This service supports at most 64 function tools."; + return null; +} + function sessionSnapshot(agent) { const { object: _object, metadata: _metadata, created_at: _created, updated_at: _updated, ...snapshot } = agent; return snapshot; @@ -71,8 +162,10 @@ function sessionSnapshot(agent) { function initialState() { const first = savedAgent("agent_a", "Lifecycle Agent", "fixture/model-a", baseline - 60); const second = savedAgent("agent_b", "Second Agent", "fixture/model-b", baseline - 30); + const savedOnlyTool = savedAgent("agent_tool_only", "Saved-only Tool Agent", "fixture/model-tool", baseline - 20); + savedOnlyTool.tools = [{ type: "tool_search" }]; return { - agents: [first, second], + agents: [first, second, savedOnlyTool], sessions: [{ id: "session_snapshot", object: "agent.session", @@ -90,6 +183,7 @@ function initialState() { turns: [], requests: [], controls: { + createAgentResponseVariant: "valid", retrieveDelayMs: 0, retrieveStatus: 200, updateDelayMs: 0, @@ -159,7 +253,7 @@ function applyEnvironmentScenario(value) { const session = state.sessions[0]; if (!session) return; const hostileRemote = "https://launcher:private@executor.example.test/connect?executor_token=secret#credential"; - if (value === 1 || value === 4 || value === 5) { + if (value === 1 || value === 4 || value === 5 || value === 6) { session.environment = { type: "self_hosted", id: value === 5 ? canonicalEnvironmentUuid.toUpperCase() : "environment_fixture", @@ -167,11 +261,15 @@ function applyEnvironmentScenario(value) { workspace_directory: `/workspace//${"long/".repeat(45)}project`, capability_directories: ["/capabilities/read-only", `/capabilities/${"wide/".repeat(55)}`], }; - session.status = value === 1 ? "requires_action" : "idle"; - session.required_actions = value === 1 ? [ - { type: "environment_connection", environment_id: "environment_fixture" }, - { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, - ] : []; + session.status = value === 1 || value === 6 ? "requires_action" : "idle"; + session.required_actions = value === 1 + ? [ + { type: "environment_connection", environment_id: "environment_fixture" }, + { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, + ] + : value === 6 + ? [{ type: "environment_connection", environment_id: "environment_fixture" }] + : []; return; } if (value === 2) { @@ -347,11 +445,24 @@ const server = http.createServer(async (request, response) => { if (request.method === "POST" && url.pathname === "/v1/agents") { state.sequence += 1; + const defaults = savedAgent(`agent_created_${state.sequence}`, body.name ?? null, body.model, baseline + state.sequence); const created = { - ...savedAgent(`agent_created_${state.sequence}`, body.name, body.model, baseline + state.sequence), - instructions: body.instructions, - metadata: body.metadata, + ...defaults, + instructions: body.instructions ?? null, + metadata: body.metadata ?? {}, + multi_agent: body.multi_agent ?? { enabled: false, max_concurrent_subagents: null }, + reasoning: body.reasoning ?? {}, + service_tier: body.service_tier ?? "auto", + text: { + format: body.text?.format ?? { type: "text" }, + verbosity: body.text?.verbosity ?? "medium", + }, + tools: body.tools ?? [], }; + if (state.controls.createAgentResponseVariant === "reasoning") { + created.reasoning = { effort: "high" }; + state.controls.createAgentResponseVariant = "valid"; + } state.agents.unshift(created); return sendJson(response, created, 201); } @@ -363,6 +474,8 @@ const server = http.createServer(async (request, response) => { if (request.method === "POST" && url.pathname === "/v1/agents/sessions") { const agent = state.agents.find((candidate) => candidate.id === body.agent_id); if (!agent) return sendError(response, 404, "Fixture Agent not found for Session."); + const admissionError = sessionAdmissionError(agent); + if (admissionError) return sendError(response, 400, admissionError); state.sequence += 1; const created = { id: `session_created_${state.sequence}`, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5e3a667..c9fb58e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,9 +1,4 @@ -import { - Bot, - Layers3, - MessageSquare, - Settings2, -} from "lucide-react"; +import { Layers3, Settings2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AgentCoreError } from "@agents-core-web/agents-client"; @@ -20,10 +15,13 @@ import type { } from "@agents-core-web/agents-client"; import { ConnectionModal } from "./components/ConnectionModal"; +import { CreateMenu } from "./components/CreateMenu"; +import { ProductNavigation, type ProductView } from "./components/ProductNavigation"; import { StatusIcon } from "./components/StatusIcon"; import { ThemeMenu } from "./components/ThemeMenu"; import { useToast } from "./components/Toast"; import { AgentsView } from "./features/agents/AgentsView"; +import { knownSessionAdmissionBlocker } from "./features/agents/session-admission"; import { removeSavedAgent, replaceSavedAgent, @@ -99,7 +97,7 @@ import { waitForStreamReconnect, } from "./lib/stream-reconnect"; -type View = "sessions" | "agents" | "system"; +type View = ProductView | "system"; interface StreamConnection { sessionId: string | null; @@ -210,6 +208,10 @@ export function App() { () => new Map(), ); const [busy, setBusy] = useState(false); + const [agentCreateRequest, setAgentCreateRequest] = useState(null); + const [sessionCreateRequest, setSessionCreateRequest] = useState(null); + const agentCreateSequenceRef = useRef(0); + const sessionCreateSequenceRef = useRef(0); const selectedIdRef = useRef(selectedId); const sessionsRef = useRef(sessions); const itemsSessionIdRef = useRef(itemsSessionId); @@ -841,9 +843,10 @@ export function App() { const createAgent = async (input: CreateAgentInput) => { const agent = await run(() => core.createAgent(input), "Agent created."); - if (!agent || coreGeneration !== connectionGenerationRef.current) return; + if (!agent || coreGeneration !== connectionGenerationRef.current) return undefined; agentCollectionRevisionRef.current += 1; setAgents((current) => [agent, ...current]); + return agent; }; const retrieveAgent = async (agentId: string) => { @@ -866,6 +869,13 @@ export function App() { }; const createSession = async (agentId: string) => { + const savedAgent = agents.find((agent) => agent.id === agentId); + const admissionBlocker = savedAgent ? knownSessionAdmissionBlocker(savedAgent) : "The selected saved Agent is not loaded."; + if (admissionBlocker) { + const error = new Error(`Session was not created. ${admissionBlocker}`); + notify(error.message, "error"); + throw error; + } const session = await run( () => core.createSession({ agent_id: agentId, environment: { type: "none" }, stream: false }), "Idle Session created. Opening live events…", @@ -1117,11 +1127,25 @@ export function App() { setConnectionOpen(false); }; - const navItems: Array<{ id: View; label: string; icon: typeof MessageSquare }> = [ - { id: "sessions", label: "Sessions", icon: MessageSquare }, - { id: "agents", label: "Agents", icon: Bot }, - { id: "system", label: "Architecture", icon: Layers3 }, - ]; + const openAgentSetup = () => { + setView("agents"); + agentCreateSequenceRef.current += 1; + setAgentCreateRequest(agentCreateSequenceRef.current); + }; + + const openSessionSetup = () => { + setView("sessions"); + sessionCreateSequenceRef.current += 1; + setSessionCreateRequest(sessionCreateSequenceRef.current); + }; + + const consumeAgentCreateRequest = useCallback((request: number) => { + setAgentCreateRequest((current) => current === request ? null : current); + }, []); + + const consumeSessionCreateRequest = useCallback((request: number) => { + setSessionCreateRequest((current) => current === request ? null : current); + }, []); return (
@@ -1135,43 +1159,23 @@ export function App() { Agents Core Web
-