diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b58a813f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# Server-side Vite development proxy target. This value is never exposed to +# browser JavaScript. Keep the browser-facing API base at /v1. +AGENTS_API_PROXY_TARGET=http://127.0.0.1:8091 + +# Preferred: plaintext execution-tenant bearer file read only by the local +# Vite server. If unset, Web also checks this conventional path automatically. +AGENTS_API_PROXY_TOKEN_FILE=~/.parsar/agents-api/web-token + +# Alternative server-side value. Never use a VITE_ prefix, and do not set this +# together with AGENTS_API_PROXY_TOKEN_FILE. +# AGENTS_API_PROXY_TOKEN= + +# Public, non-secret opt-in for the reviewed Codex self_hosted Session profile. +# Leave unset unless Core execution, its executor registry, and executor origin +# are configured. This flag is presentation policy, not capability discovery. +# AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 + +# Public, non-secret opt-in for the operator-qualified basic Codex +# openai_hosted Session profile. Leave unset unless Core was started with a +# qualified managed Runtime provider. This flag does not probe runtime readiness. +# AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1 + +# Public, non-secret opt-in for the complete Environment Files profile. Leave +# unset for older Core revisions. Enable only after the connected Core has been +# qualified for Files.list and managed Files.create; self_hosted reads use its +# exact workspace_directory root. +# AGENTS_CORE_WEB_ENVIRONMENT_FILES=1 + +# Optional local-only Docker backend recovery guide shown in the connection +# panel. Web renders copyable `docker start` and loopback health commands; it +# never accesses the Docker socket or executes them. Values are compiled into +# the browser bundle and must contain non-secret container names only. +# AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE=1 +# AGENTS_CORE_WEB_DOCKER_DATABASE_CONTAINER=parsar-agents-api-web-smoke-db +# AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api +# AGENTS_CORE_WEB_DOCKER_DAEMON_CONTAINER=agents-core-web-daemon +# AGENTS_CORE_WEB_DOCKER_CORE_PORT=8091 + +# Optional local-only Docker connection recipe. This renders a copyable command; +# it never gives the browser Docker access or reads the credential file. Every +# value below is compiled into the browser bundle, so values must be non-secret. +# Enable only for the matching operator-controlled local stack. +# AGENTS_CORE_WEB_DOCKER_GUIDE=1 +# AGENTS_CORE_WEB_DOCKER_IMAGE=agents-core-web-executor:2b34ea46-codex-0.153.4 +# AGENTS_CORE_WEB_DOCKER_API_CONTAINER=agents-core-web-api +# AGENTS_CORE_WEB_DOCKER_USER=501:20 +# AGENTS_CORE_WEB_DOCKER_CREDENTIALS_HOME_PATH=.parsar/agents-api-web-smoke/executor-key.json +# AGENTS_CORE_WEB_DOCKER_RUNTIME_HOME_PATH=.parsar/agents-api-web-smoke/executors + +# Public, non-secret suggestions shown by the Create Agent model picker. The +# first entry is the default unless VITE_AGENT_DEFAULT_MODEL overrides it. These +# do not claim live availability; the connected runtime remains authoritative. +VITE_AGENT_MODEL_PRESETS=gpt-6-astra,gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna,gpt-5.5,gpt-5.3-codex-spark +VITE_AGENT_DEFAULT_MODEL=gpt-5.6-sol diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c806fa626..de1461dce 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -54,6 +54,10 @@ jobs: ~/.parsar/cache/executor-cargo ~/.parsar/cache/executor-target key: core-executor-${{ runner.os }}-${{ hashFiles('packages/codex-executor/Cargo.lock') }} + - name: Install Web acceptance browser + run: | + pnpm install --frozen-lockfile + pnpm exec playwright install --with-deps chrome - name: Check standalone repository env: PARSAR_AGENTS_API_TEST_DATABASE_URL: postgres://agents_api:core_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/parsar_agents_api_core_ci_tests?sslmode=disable diff --git a/.gitignore b/.gitignore index da6269529..4b69c8cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ __pycache__/ /state/ /cache/ /target/ +/playwright-report/ +/test-results/ +/.agents/ +/.issue-agent/ +/ISSUE_AGENT.md diff --git a/AGENTS.md b/AGENTS.md index 441f5c7ca..a5ad47173 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,11 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing code. Work in an isolate Git worktree and submit a PR. Keep product business code in the Parsar repository. Preserve the pinned public Agent API and documented extensions. Do not add product -database dependencies or bypass Core execution ownership. Update architecture and -generated contracts with changes. Run `make check` before reporting completion. +database dependencies or bypass Core execution ownership. Core Web must consume +that public contract through `packages/agents-client`; it must not reproduce Core +execution truth or expose server-side credentials in the browser. Update +architecture and generated contracts with changes. Run `make check` before +reporting completion. After implementation and verification, request an independent blind review with only requirements, acceptance criteria, boundaries, repository path and baseline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df13826fe..92844521b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,10 +4,11 @@ This repository is the standalone execution substrate copied from Parsar at the revision in `provenance/source.json`. Keep the API, its migrations, protocol, -execution daemon, runtime adapters, shared execution packages and build/test tools -here. Product users, workspaces, model catalogs, business assets, web UI, product -API and product migrations remain in Parsar. Do not import `server/`, `apps/web/`, -`apps/parsar/`, product CLI/plugin packages or their deployment stack. +execution daemon, runtime adapters, shared execution packages, the standalone Core +Web console and build/test tools here. Product users, workspaces, model catalogs, +business assets, the Parsar product Web, product API and product migrations remain +in Parsar. Do not import `server/`, `apps/parsar/`, product CLI/plugin packages or +their deployment stack. Preserve copied runtime and protocol behavior. Existing Go import paths, binary names and runtime environment variables remain unchanged for this copy; they do @@ -33,7 +34,8 @@ split oversized components before extending them. Use `internal/obs/log` for log ## Required checks Run `make check` before completion. The standalone gate includes all daemon/shared -Go tests, Core contract/client/service tests, a real dedicated PostgreSQL test +Go tests, Core contract/client/service tests, Core Web and TypeScript client +checks (including fixture-only Playwright acceptance), a real dedicated PostgreSQL test database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK tests and packaging, MiniMax companion checks, and Rust filesystem-helper tests/format/Clippy. It intentionally has no product Web/server/installer gates. The full gate fails when the database variable is missing. diff --git a/Makefile b/Makefile index f5fa0935e..0ae2fd788 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,12 @@ SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) SWAG_VERSION ?= v1.16.4 -.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-executor check-agents-executor build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-executor check-agents-executor build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime help: @printf '%s\n' 'make build-agents-api Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' -check: check-database check-sqlc check-go check-agents-api check-claude-sdk check-mcode-harness check-agents-executor +check: check-database check-sqlc check-go check-agents-api check-claude-sdk check-web check-mcode-harness check-agents-executor @printf 'Parsar Core checks passed.\n' check-database: @@ -62,6 +62,13 @@ check-claude-sdk: node-deps pnpm --filter @parsar/claude-sdk-adapter test $(MAKE) build-claude-sdk-runtime +check-web: node-deps + pnpm typecheck + pnpm test:core-doctor + pnpm test:web + pnpm --filter @agents-core-web/web build + pnpm test:web:acceptance + build-claude-sdk-runtime: ./scripts/build-claude-sdk-runtime.sh diff --git a/README.md b/README.md index 23eff57e1..cc80ee81a 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ The source repository retains both its product and its existing Core copy. This repository contains the API service, PostgreSQL migrations, pinned public protocol, execution daemon, the Docker provider, native Harness adapters, -runtime image builders, client library, tests and operator documentation. -It does not contain the Parsar web application, product backend, product database, -business CLI or product deployment stack. +runtime image builders, Go and TypeScript client libraries, the standalone Core +Web console, tests and operator documentation. It does not contain the Parsar +product application, product backend, product database, business CLI or product +deployment stack. V1 user-managed deployments colocate our daemon, selected harness, tools and `/workspace`. Core manages Docker only; users provision, renew and destroy E2B @@ -26,12 +27,16 @@ for tested deployments and remaining limits. - [Docker Runtime](services/agents-api/deploy/codex/README.md) - [Protocol coverage and known gaps](contracts/agents-api/README.md) - [Harness selection](contracts/agents-api/harness-selection.md) +- [Core Web overview](docs/web/README.md) +- [Core Web 中文说明](docs/web/README.zh-CN.md) +- [Connect Core Web to Core](docs/web/core-connection.md) - [Contributor rules](CONTRIBUTING.md) - [Copy provenance and validation](provenance/README.md) ```sh make build-agents-api make build-daemon +pnpm dev:web ``` These builds require the Go version pinned in `go.mod`. Output goes under @@ -40,6 +45,12 @@ Provision a dedicated Core PostgreSQL database and caller credentials using the operator guide before starting the service. Native execution also needs the appropriate Runtime image and provider configuration. +Core Web lives in `apps/web` and talks only to the public `/v1/agents/**` +HTTP/SSE contract through the TypeScript implementation in +`packages/agents-client`. The Go client remains in +`packages/agents-client/v1`; both clients live next to the contract they consume +without coupling browser state to Core execution internals. + The copied Go module/import paths, executable names and `PARSAR_*` environment variables intentionally retain their existing names. They resolve to source in this checkout, not a dependency on the Parsar product repository. This extraction @@ -49,13 +60,19 @@ sources and packages remain pinned dependencies, not vendored binaries. ## Validate On Linux with Go, Node 22, pnpm 10.30.3, Python 3.9+, Rust 1.95.0 (including -rustfmt/Clippy), OpenSSL development libraries and a dedicated test PostgreSQL: +rustfmt/Clippy), OpenSSL development libraries, Chrome for Playwright, and a +dedicated test PostgreSQL: ```sh export PARSAR_AGENTS_API_TEST_DATABASE_URL='postgres://.../parsar_agents_api_core_tests?sslmode=disable' make check ``` +Install the pinned Playwright browser on a fresh validation host with +`pnpm exec playwright install --with-deps chrome`. Fixture acceptance uses +loopback ports `18092` and `4174`; set `AGENTS_FIXTURE_PORT` and +`AGENTS_WEB_PORT` to unused ports when either default is occupied. + The full gate requires the test database rather than silently skipping persistence tests. Native model/provider fixtures remain explicit, credential-dependent acceptance checks; see the [coverage ledger](contracts/agents-api/README.md). diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts new file mode 100644 index 000000000..640a954ef --- /dev/null +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -0,0 +1,3215 @@ +import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test"; + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; + +interface FixtureRequest { + method: string; + path: string; + query?: string; + beta: string | null; + authorizationPresent: boolean; + idempotencyKeyPresent: boolean; + idempotencyKey: string | null; + body?: Record; +} + +async function resetFixture(request: APIRequestContext) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function controlFixture(request: APIRequestContext, control: Record) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/control`, { data: control }); + expect(response.ok()).toBe(true); +} + +interface FixtureState { + sessions: Array<{ id: string; metadata: Record }>; + aborts: { sessionListReads: number; sessionReads: number; itemReads: number; turnReads: number; streams: number; environmentFileReads: number }; + openStreams: string[]; +} + +async function fixtureState(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/state`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function setFixtureSessionMetadata( + request: APIRequestContext, + id: string, + metadata: Record, +) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/session-metadata`, { data: { id, metadata } }); + expect(response.ok()).toBe(true); +} + +async function removeFixtureSession(request: APIRequestContext, id: string) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/remove-session`, { data: { id } }); + expect(response.ok()).toBe(true); +} + +async function emitTurnFixture(request: APIRequestContext, status: "completed" | "failed" | "cancelled") { + const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-turn`, { data: { status } }); + expect(response.ok()).toBe(true); +} + +async function emitSessionFixture(request: APIRequestContext, status: "in_progress" | "idle" | "failed") { + const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-session`, { data: { status } }); + expect(response.ok()).toBe(true); +} + +async function fixtureRequests(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function createFixtureSession(request: APIRequestContext, label: string) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { + headers: { + "OpenAI-Beta": "agents=v1", + "Idempotency-Key": `fixture-filter-${label}`, + }, + data: { + agent_id: "agent_b", + environment: { type: "none" }, + metadata: { filter_fixture: label }, + stream: false, + vault_ids: [], + }, + }); + expect(response.status()).toBe(201); +} + +async function expectSelectedDeleteAbortsSessionRead( + page: Page, + request: APIRequestContext, + startRead: () => Promise, +) { + const path = "/v1/agents/sessions/session_snapshot"; + const failedReads = new Map(); + page.on("requestfailed", (failedRequest) => { + const failedPath = new URL(failedRequest.url()).pathname; + if (failedRequest.method() === "GET") { + failedReads.set(failedPath, failedRequest.failure()?.errorText ?? "unknown failure"); + } + }); + const before = await fixtureState(request); + const previousReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length; + + await startRead(); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length).toBeGreaterThan(previousReads); + + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + + await expect.poll(() => failedReads.get(path)).toContain("ERR_ABORTED"); + await expect.poll(async () => (await fixtureState(request)).aborts.sessionReads) + .toBeGreaterThan(before.aborts.sessionReads); + expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); +} + +/** + * Single-Environment reads only. The reusable Template collection shares this + * path prefix but is durable configuration, not an Environment resource read. + */ +function isEnvironmentResourcePath(path: string): boolean { + return path.startsWith("/v1/agents/environments/") && !path.startsWith("/v1/agents/environments/templates"); +} + +async function openAgents(page: Page, request: APIRequestContext) { + await resetFixture(request); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents" }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); +} + +async function startSessionWithSecondAgent(page: Page) { + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Create Session" }).click(); +} + +async function openAdvancedSessionSettings(dialog: Locator) { + const toggle = dialog.getByRole("button", { name: /Advanced settings/ }); + if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(dialog.getByRole("region", { name: "Advanced Session settings" })).toBeVisible(); +} + +function environmentTrigger(page: Page) { + return page.getByRole("button", { name: /Environment|Connect environment/i }); +} + +function connectedLiveEvents(page: Page) { + return page.getByRole("status", { name: "Session live events: connected" }); +} + +async function openEnvironmentDialog(page: Page) { + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByRole("region", { name: "Environment and Workspace status" })).toHaveCount(0); + + const trigger = environmentTrigger(page); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + await trigger.click(); + + const dialog = page.getByRole("dialog", { name: "Environment" }); + await expect(dialog).toBeVisible(); + const panel = dialog.getByRole("region", { name: "Environment and Workspace status" }); + await expect(panel).toBeVisible(); + return { dialog, panel, trigger }; +} + +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { + await testInfo.attach(name, { + body: await page.screenshot({ fullPage: true, animations: "disabled" }), + contentType: "image/png", + }); +} + +async function attachElementScreenshot(locator: Locator, testInfo: TestInfo, name: string) { + await testInfo.attach(name, { + body: await locator.screenshot({ animations: "disabled" }), + contentType: "image/png", + }); +} + +async function openSessionsFromHome(page: Page) { + await page.goto("/"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); +} + +test("opens Dashboard as the default landing page", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Dashboard", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); + await expect(page.locator(".dashboard-page")).toBeVisible(); +}); + +test("explains a 502 Core backend failure and opens copyable Docker recovery steps", async ({ page }) => { + await page.route("**/v1/agents**", async (route) => { + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ + error: { message: "Agent core request failed (502).", type: "gateway_error" }, + }), + }); + }); + await page.goto("/"); + + const recovery = page.getByRole("button", { + name: "Agent Core backend is not ready. Open Docker startup guide", + }); + await expect(recovery).toBeVisible(); + await expect(recovery).toContainText("HTTP 502"); + await expect(page.getByText("Agents: Agent core request failed (502).", { exact: true })).toHaveCount(0); + await expect(page.getByText( + "Agent Core backend is not ready. Open the Dashboard startup guide.", + { exact: true }, + )).toHaveCount(1); + await expect(page.getByText("Agent core request failed (502).", { exact: true })).toHaveCount(0); + await recovery.click(); + + const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(dialog).toContainText("Start a local Docker backend"); + await expect(dialog).toContainText("docker start parsar-agents-api-web-smoke-db"); + await expect(dialog).toContainText("docker start agents-core-web-api agents-core-web-daemon"); + await expect(dialog).toContainText("http://127.0.0.1:8091/healthz"); + await expect(dialog).toContainText("First time on this computer"); + await expect(dialog).toContainText("make docker-build-agents-api"); + await expect(dialog.getByRole("link", { name: "Core container setup" })).toHaveAttribute( + "href", + /parsar-core\/blob\/main\/services\/agents-api\/CONTAINER\.md$/, + ); + await expect(dialog.getByRole("button", { name: "Copy database start command" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Copy Core image build command" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Test connection" })).toBeVisible(); +}); + +test("retrieves the latest Agent and opens its validated edit setup directly", async ({ page, request }, testInfo) => { + const browserErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") browserErrors.push(message.text()); + }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + + await openAgents(page, request); + await expect(page.getByText("Lifecycle Agent · stale list", { exact: true })).toBeVisible(); + + await controlFixture(request, { retrieveDelayMs: 250 }); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); + const setup = page.locator(".agent-setup-page"); + await expect(setup.getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(setup).not.toContainText("stale list"); + await expect(setup).toContainText("agent_a"); + await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); + await expect(setup).toContainText("Start Session is unavailable. Current Core Session admission requires"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(setup.getByRole("button", { name: "Save changes" })).toBeInViewport(); + expect(await setup.locator(".agent-setup-actions").evaluate((element) => getComputedStyle(element).position)).toBe("sticky"); + await attachScreenshot(page, testInfo, "desktop-light-agent-edit-setup"); + + const name = page.getByLabel("Name"); + await expect(name).toBeFocused(); + await expect(setup).toContainText("Web-side suggestions, not a discovered Core catalog"); + await expect(setup).toContainText("Never store secrets in Agent metadata"); + + await page.getByLabel("Metadata").fill('{"retries":3}'); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Every metadata value must be a string.")).toBeVisible(); + 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("status")).toContainText("Agent definition updated"); + await expect(name).toBeFocused(); + + requests = await fixtureRequests(request); + const updates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a"); + expect(updates).toHaveLength(1); + expect(updates[0]?.body).toMatchObject({ + name: null, + instructions: null, + metadata: { team: "acceptance" }, + reasoning: { effort: null, summary: null }, + }); + expect(updates[0]?.body).not.toHaveProperty("tools"); + expect(browserErrors).toEqual([]); +}); + +test("fails closed when the latest Agent response has a different identity", async ({ page, request }) => { + await openAgents(page, request); + await page.route("**/v1/agents/agent_a", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const upstream = await route.fetch(); + const payload = await upstream.json() as Record; + await route.fulfill({ response: upstream, json: { ...payload, id: "agent_other" } }); + }); + + const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await editTrigger.click(); + await expect(page.getByRole("alert")).toContainText("returned a different Agent than the one requested"); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(editTrigger).toBeFocused(); + + const reads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/agent_a" + )); + expect(reads).toHaveLength(1); +}); + +test("creates, previews, edits, and removes bounded Function and anonymous HTTP MCP tools", async ({ page, request }) => { + await openAgents(page, request); + 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("Model").selectOption({ label: "Custom model ID…" }); + await page.getByLabel("Custom model ID").fill("fixture/tool-model"); + + await page.getByRole("button", { name: "Add Function" }).click(); + const createFunction = page.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); + await createFunction.getByLabel("Name", { exact: true }).fill("lookup_customer"); + await createFunction.getByLabel("Description", { exact: true }).fill("Look up a customer."); + await createFunction.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); + + await page.getByRole("button", { name: "Add HTTP MCP" }).click(); + const createMcp = page.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); + await createMcp.getByLabel("Server label").fill("docs"); + await createMcp.getByLabel("Server URL").fill("https://mcp.example/tools"); + await createMcp.getByLabel("Only the listed tools").check(); + await createMcp.getByLabel("Allowed tools for docs").fill("search\nread_document"); + await createMcp.getByLabel("Require this server for Core execution").check(); + + const previewBody = page.getByRole("region", { name: "Request preview" }) + .locator(".agent-preview-block") + .filter({ has: page.getByText("agent.json", { exact: true }) }) + .locator("pre"); + await expect(previewBody).toContainText('"type": "function"'); + await expect(previewBody).toContainText('"name": "lookup_customer"'); + await expect(previewBody).toContainText('"type": "mcp"'); + await expect(previewBody).toContainText('"required": true'); + + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + let requests = await fixtureRequests(request); + const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toEqual({ + model: "fixture/tool-model", + name: "Tool Agent", + instructions: "Use only the configured tools.", + metadata: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [ + { + type: "function", + name: "lookup_customer", + description: "Look up a customer.", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }, + { + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: "https://mcp.example/tools" }, + allowed_tools: ["search", "read_document"], + connection_origin: "service", + required: true, + }, + ], + }); + + await page.getByRole("button", { name: "Back to Agents" }).click(); + await page.getByRole("button", { name: /^Edit Tool Agent/ }).click(); + const setup = page.locator(".agent-setup-page"); + await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + 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(); + await editMcp.getByRole("button", { name: "Remove" }).click(); + await setup.getByRole("button", { name: "Save changes" }).click(); + await expect(setup.getByRole("status")).toContainText("Agent definition updated"); + + requests = await fixtureRequests(request); + let updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); + expect(updates).toHaveLength(1); + expect(updates[0]?.body?.tools).toEqual([ + { + type: "function", + name: "lookup_customer_v2", + description: "Look up a customer.", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }, + ]); + + 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"); + + requests = await fixtureRequests(request); + updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); + expect(updates).toHaveLength(2); + expect(updates[1]?.body?.tools).toEqual([]); +}); + +test("keeps Source Files controls out of the System status page", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "System", exact: true }).click(); + await expect(page.getByRole("region", { name: "Source Files" })).toHaveCount(0); + await expect(page.locator(".system-page")).not.toContainText("Source Files"); +}); + +test("supports global Create keyboard navigation and consumes setup requests once", async ({ page, request }) => { + await openAgents(page, request); + 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(); + + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "New Agent" })).toHaveCount(0); + + const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await editTrigger.focus(); + await editTrigger.click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(editTrigger).toBeFocused(); + await expect(page.locator(".modal-backdrop")).toHaveCount(0); + + await createMenu.click(); + await startSessionItem.click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_a"); + await expect(sessionDialog.locator('option[value="agent_a"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_a"]')).toContainText("Lifecycle Agent"); + await expect(sessionDialog.locator('option[value="agent_a"]')).not.toContainText("Session requires changes"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toContainText("Saved-only Tool Agent"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).not.toContainText("Session requires changes"); + 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: "Create a 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(); + await createMetadata.fill('{"owner":"local-test"}'); + await expect(createMetadata).toHaveValue('{"owner":"local-test"}'); + await expect(createName).toHaveValue(""); + await createName.focus(); + await page.keyboard.press("Enter"); + 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 catalogCreate = page.getByRole("button", { name: /^Create agent/ }); + await catalogCreate.click(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(catalogCreate).toBeFocused(); + + const requests = await fixtureRequests(request); + const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toMatchObject({ + 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: /^Create 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(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/); + await expect(sessionDialog.getByRole("button", { name: /Advanced settings/ })).toHaveAttribute("aria-expanded", "false"); + await expect(sessionDialog.getByRole("textbox", { name: /^Additional metadata\b/u })).toHaveCount(0); + await expect(sessionDialog.getByRole("radio", { name: /No environment/ })).toBeChecked(); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(environmentTrigger(page)).toHaveCount(0); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByRole("region", { name: "Environment and Workspace status" }), + ).toHaveCount(0); + + 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("opens repair setup for a saved response while blocking an invalid Session write", async ({ page, request }) => { + await openAgents(page, request); + await controlFixture(request, { createAgentResponseVariant: "reasoning" }); + await page.getByRole("button", { name: /^Create 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 inspect the explicit repair + // surface. The dialog remains fail-closed until the saved-only field is reset. + 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(); + }); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/u); + await expect(sessionDialog.getByRole("alert")).toContainText("explicit reasoning options are saved-only"); + await expect(sessionDialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + 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); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); + + const blockedStart = page.getByRole("button", { name: /^Start a Session with Lifecycle Agent/ }); + await expect(blockedStart).toContainText("Unavailable"); + 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(); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); + + 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(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); + await sessionDialog.getByRole("button", { name: "Create 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(before + 1); + expect(sessionCreates.at(-1)?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + stream: false, + }); +}); + +test("serializes complete saved-Agent overrides while keeping idle creation unstreamed", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.getByRole("button", { name: "New Session" }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_a"); + await expect(dialog.getByRole("alert")).toContainText("multi-agent execution is not supported"); + + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); + await dialog.getByRole("checkbox", { name: "Replace model" }).check(); + await dialog.getByLabel("Session model").fill("fixture/session-model"); + await dialog.getByRole("checkbox", { name: "Replace instructions" }).check(); + await dialog.getByLabel("Session instructions").fill(" "); + await dialog.getByRole("checkbox", { name: /Replace text configuration/ }).check(); + await dialog.getByLabel("Text verbosity").selectOption("low"); + await dialog.getByRole("checkbox", { name: "Reset multi-agent to disabled" }).check(); + await dialog.getByRole("checkbox", { name: "Reset reasoning to Core defaults" }).check(); + await dialog.getByRole("checkbox", { name: "Reset service tier to auto" }).check(); + await dialog.getByRole("radio", { name: "Clear all" }).check(); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "New Session" }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b"); + await openAdvancedSessionSettings(dialog); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(2); + expect(creates[0]?.body).toEqual({ + agent_id: "agent_a", + agent: { + model: "fixture/session-model", + instructions: null, + multi_agent: null, + reasoning: null, + service_tier: null, + text: { format: { type: "text" }, verbosity: "low" }, + tools: [], + }, + environment: { type: "none" }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(creates[1]?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(creates[1]?.body).not.toHaveProperty("agent"); + expect(creates[1]?.body).not.toHaveProperty("input"); +}); + +test("derives manual Vault attachments for anonymous and explicit MCP Credentials", async ({ page, request }) => { + await resetFixture(request); + const serverURL = "https://mcp.vault-fixture.test/tools"; + const createVault = async (name: string) => { + const response = await request.post(`${fixtureBaseUrl}/v1/vaults`, { data: { name, metadata: {} } }); + expect(response.ok()).toBe(true); + return response.json() as Promise<{ id: string }>; + }; + const createCredential = async (vaultId: string, name: string) => { + const response = await request.post(`${fixtureBaseUrl}/v1/vaults/${vaultId}/credentials`, { data: { + name, + auth: { type: "static_bearer", mcp_server_url: serverURL, token: "fixture-secret-never-rendered" }, + } }); + expect(response.ok()).toBe(true); + return response.json() as Promise<{ id: string }>; + }; + const vaultAlpha = await createVault("Vault Alpha"); + const vaultBeta = await createVault("Vault Beta"); + const credentialAlpha = await createCredential(vaultAlpha.id, "Credential Alpha"); + await createCredential(vaultBeta.id, "Credential Beta"); + const mcpTool = (credentialId: string | null) => ({ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: serverURL, headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: credentialId, + request_metadata: {}, + required: false, + }); + for (const [name, credentialId] of [ + ["Anonymous MCP Agent", null], + ["Explicit MCP Agent", credentialAlpha.id], + ] as const) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp", + name, + tools: [mcpTool(credentialId)], + } }); + expect(response.status()).toBe(201); + } + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await expect(dialog).toContainText("Anonymous for this Session"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); + await expect(dialog).toContainText("Implicit unique match · Credential Alpha · Vault Alpha"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); + await dialog.getByRole("checkbox", { name: "Vault Beta" }).check(); + await expect(dialog.getByRole("alert")).toContainText("matches multiple Credentials"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + const requiredVault = dialog.getByRole("checkbox", { name: /Vault Alpha · attached automatically/ }); + await expect(requiredVault).toBeChecked(); + await expect(requiredVault).toBeDisabled(); + await expect(dialog).toContainText("Explicit · Credential Alpha · Vault Alpha"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + let creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(3); + expect(creates.map((entry) => entry.body?.vault_ids)).toEqual([ + [], + [vaultAlpha.id], + [vaultAlpha.id], + ]); + const createsBeforeCredentialDelete = creates.length; + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + const deleted = await request.delete(`${fixtureBaseUrl}/v1/vaults/${vaultAlpha.id}/credentials/${credentialAlpha.id}`); + expect(deleted.ok()).toBe(true); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("explicit MCP Credential is unavailable"); + creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates.length - createsBeforeCredentialDelete).toBeLessThanOrEqual(1); +}); + +test("clears manual Vault attachments when overrides remove HTTP MCP tools", async ({ page, request }) => { + await resetFixture(request); + const serverURL = "https://mcp.vault-clear.test/tools"; + const vaultResponse = await request.post(`${fixtureBaseUrl}/v1/vaults`, { + data: { name: "Vault to clear", metadata: {} }, + }); + expect(vaultResponse.ok()).toBe(true); + const vault = await vaultResponse.json() as { id: string }; + const agentResponse = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp-clear", + name: "MCP Clear Agent", + tools: [{ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: serverURL, headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + }], + } }); + expect(agentResponse.status()).toBe(201); + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault to clear" }).check(); + await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); + await dialog.getByRole("radio", { name: "Clear all" }).check(); + + await expect(dialog.getByRole("heading", { name: "Tools & Vaults" })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toMatchObject({ + agent: { tools: [] }, + vault_ids: [], + }); + expect(creates[0]?.body?.vault_ids).not.toContain(vault.id); +}); + +test("creates the bounded self-hosted profile and renders a secret-free connection guide", async ({ page, request }) => { + await openAgents(page, request); + const sessionPostsBefore = (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(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); + await dialog.getByRole("radio", { name: /Self-hosted/ }).check(); + const workspace = dialog.getByLabel("Workspace directory"); + await workspace.fill("relative/workspace"); + await expect(dialog.getByText("Workspace directory must be an absolute POSIX path")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(sessionPostsBefore); + + await workspace.fill("/executor/workspace"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + const { dialog: environmentDialog, panel: environmentPanel, trigger } = await openEnvironmentDialog(page); + await expect(environmentPanel).toBeVisible(); + await expect(environmentPanel).toContainText("Self-hosted Environment"); + await expect(environmentPanel).toContainText("0f745b0d-b545-49cd-8d7e-4c31c80dc564"); + await expect(environmentPanel).toContainText("https://executor.example.test"); + await expect(environmentPanel).toContainText("/executor/workspace"); + await expect(environmentPanel).toContainText("Pending"); + const files = environmentPanel.getByRole("region", { name: "Workspace files" }); + await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); + await expect(files).toContainText("Workspace root"); + + await expect(environmentPanel.locator("details.environment-launcher-guide")).toHaveAttribute("open", ""); + await expect(environmentPanel.getByRole("button", { name: "Copy native command" })).toBeVisible(); + await expect(environmentPanel).toContainText("agents-api-codex-executor"); + await expect(environmentPanel).toContainText("$HOME/.parsar/executor-key.json"); + await expect(environmentPanel).toContainText("Web copies its path but never creates, reads, stores, or transmits the key"); + await expect(environmentPanel).not.toContainText("executor_token"); + await expect(environmentPanel).not.toContainText("Authorization: Bearer"); + + await page.keyboard.press("Escape"); + await expect(environmentDialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + + const requests = await fixtureRequests(request); + const sessionCreates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); + expect(sessionCreates).toHaveLength(sessionPostsBefore + 1); + expect(sessionCreates.at(-1)?.body).toEqual({ + agent_id: "agent_b", + environment: { + type: "self_hosted", + workspace_directory: "/executor/workspace", + capability_directories: [], + }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(requests.filter((entry) => entry.method === "POST" && entry.path.endsWith("/events"))).toHaveLength(0); +}); + +test("saves a reusable Environment Template and references it from a managed Session", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + + const templateSelect = dialog.getByLabel("Reusable Environment Template"); + await expect(templateSelect).toBeDisabled(); + await expect(dialog).toContainText("This Core exposes no saved Templates"); + + await dialog.getByRole("button", { name: "New Template" }).click(); + await dialog.getByLabel("Template name").fill("Restricted outbound access"); + await dialog.getByLabel("Template network access").selectOption("disabled"); + await dialog.getByRole("button", { name: "Save Template" }).click(); + + await expect(templateSelect).toBeEnabled(); + await expect(templateSelect).toHaveValue(/^4/); + const selectedTemplateId = await templateSelect.inputValue(); + await expect(dialog.getByLabel("Managed Environment network access")) + .toContainText("Inherit from Template (disabled)"); + + const templateCreates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/environments/templates" + )); + expect(templateCreates).toHaveLength(1); + expect(templateCreates[0]?.beta).toBe("agents=v1"); + expect(templateCreates[0]?.body).toEqual({ + name: "Restricted outbound access", + network: { access: "disabled" }, + }); + + // A Session can keep or narrow the Template policy, never widen it. + await dialog.getByLabel("Managed Environment network access").selectOption("enabled"); + await expect(dialog).toContainText("never widen it"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + await dialog.getByLabel("Managed Environment network access").selectOption("default"); + + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + const latest = creates.at(-1)?.body; + expect(latest?.environment).toEqual({ + type: "openai_hosted", + environment_template_id: selectedTemplateId, + }); + expect(latest?.stream).toBe(true); +}); + +test("hides Template selection when the connected Core lacks the resource", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentTemplateListStatus: 400 }); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + + await expect(dialog).toContainText("does not expose the Environment Template resource"); + await expect(dialog.getByLabel("Reusable Environment Template")).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "New Template" })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); +}); + +test("creates managed hosted default, enabled and disabled profiles through POST SSE", async ({ page, request }) => { + await openAgents(page, request); + const profiles = [ + { label: "default", option: "default", environment: { type: "openai_hosted" }, input: undefined }, + { label: "enabled", option: "enabled", environment: { type: "openai_hosted", network: { access: "enabled" } }, input: "Managed initial input" }, + { label: "disabled", option: "disabled", environment: { type: "openai_hosted", network: { access: "disabled" } }, input: undefined }, + ] as const; + + for (const [index, profile] of profiles.entries()) { + if (index > 0) await page.getByRole("button", { name: "Agents", exact: true }).click(); + if (index === 0) await controlFixture(request, { environmentEventStatus: 3, environmentEventCount: 1 }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + const network = dialog.getByLabel("Managed Environment network access"); + await network.selectOption(profile.option); + if (profile.input) await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(profile.input); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + const latest = creates.at(-1)?.body; + expect(latest).toMatchObject({ + agent_id: "agent_b", + environment: profile.environment, + stream: true, + vault_ids: [], + }); + expect(latest?.environment).toEqual(profile.environment); + if (profile.input) expect(latest?.input).toBe(profile.input); + else expect(latest).not.toHaveProperty("input"); + + if (index === 0) { + const { dialog: environmentDialog, panel } = await openEnvironmentDialog(page); + await expect(panel).toContainText("Managed hosted Environment"); + await expect(panel).toContainText("7a263c51-6bf0-4d53-8518-c792eb1f0d21"); + await expect(panel).toContainText("Network access"); + await expect(panel).toContainText("Enabled"); + await expect(panel).toContainText("/workspace"); + await expect(panel).toContainText("None installed by the basic profile"); + await expect(panel).toContainText("Workspace files"); + await expect(panel.getByText("Add inline Workspace file", { exact: true })).toBeVisible(); + await expect(panel).not.toContainText("Connect Environment"); + await expect(panel).not.toContainText("Remote URL"); + + await panel.getByLabel("Local file").setInputFiles({ + name: "managed.txt", + mimeType: "text/plain", + buffer: Buffer.from("managed bytes", "utf8"), + }); + await panel.getByLabel("Destination path").fill("/workspace/managed.txt"); + await panel.getByRole("button", { name: "Write selected file" }).click(); + await expect(panel).toContainText("Write confirmed:"); + await expect(panel).toContainText("/workspace/managed.txt"); + let writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + )); + expect(writes.at(-1)?.body).toEqual({ + type: "inline", + data: Buffer.from("managed bytes", "utf8").toString("base64"), + path: "/workspace/managed.txt", + }); + + await panel.getByLabel("Local file").setInputFiles({ + name: "unknown.txt", + mimeType: "text/plain", + buffer: Buffer.from("uncertain bytes", "utf8"), + }); + await panel.getByLabel("Destination path").fill("/workspace/unknown.txt"); + await controlFixture(request, { environmentFileCreateResponseLoss: 1 }); + const writesBeforeUnknown = writes.length; + await panel.getByRole("button", { name: "Write selected file" }).click(); + await expect(panel.getByRole("alert")).toContainText("Web did not retry the request"); + writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + )); + expect(writes).toHaveLength(writesBeforeUnknown + 1); + await page.waitForTimeout(250); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + ))).toHaveLength(writesBeforeUnknown + 1); + await environmentDialog.getByRole("button", { name: "Done" }).click(); + } + } + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); +}); + +test("blocks hosted MCP before persistence while allowing a Function-only managed Session", async ({ page, request }) => { + await resetFixture(request); + const mcp = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp", + name: "Hosted MCP Agent", + tools: [{ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: "https://mcp.example/tools", headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + }], + } }); + expect(mcp.status()).toBe(201); + const fn = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-function", + name: "Hosted Function Agent", + tools: [{ type: "function", name: "lookup", description: "", parameters: {}, defer_loading: false }], + } }); + expect(fn.status()).toBe(201); + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + 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 Hosted MCP Agent/ }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + await expect(dialog.getByRole("alert")).toContainText("Managed hosted Sessions do not yet support MCP tools"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + ))).toHaveLength(before); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Hosted Function Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(before + 1); + expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: true }); +}); + +test("keeps managed Environment resource and terminal event states fail-closed", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 8, environmentResourceStatus: "expired" }); + await openSessionsFromHome(page); + let trigger = environmentTrigger(page); + await expect(trigger).toContainText("Managed Environment expired"); + let opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment expired"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + await opened.dialog.getByRole("button", { name: "Done" }).click(); + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await controlFixture(request, { environmentResourceStatus: "pending", environmentResourceVariant: "wrong_type" }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + trigger = environmentTrigger(page); + await expect(trigger).toContainText("Managed Environment unavailable"); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Durable managed Environment status is unavailable"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await opened.dialog.getByRole("button", { name: "Done" }).click(); + + await controlFixture(request, { + environmentResourceVariant: "valid", + environmentEventStatus: 5, + environmentEventCount: 1, + }); + await page.reload(); + await expect(environmentTrigger(page)).toContainText("Managed Environment failed"); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + + await opened.dialog.getByRole("button", { name: "Done" }).click(); + await controlFixture(request, { + environmentScenario: 9, + environmentResourceStatus: "failed", + environmentResourceVariant: "valid", + environmentEventCount: 0, + }); + await page.reload(); + await expect(page.getByText("Session cannot continue", { exact: true })).toBeVisible(); + await expect(page.getByText("The environment is no longer available for this input.", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeDisabled(); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + + await opened.dialog.getByRole("button", { name: "Done" }).click(); + await controlFixture(request, { + environmentScenario: 8, + environmentResourceStatus: "pending", + environmentResourceVariant: "populated", + }); + await page.reload(); + opened = await openEnvironmentDialog(page); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); +}); + +test("creates an inline Session without saved Agents and preserves ordered user-message input", async ({ page, request }) => { + await openAgents(page, request); + for (const agentId of ["agent_a", "agent_b", "agent_tool_only"]) { + const deleted = await request.delete(`${fixtureBaseUrl}/v1/agents/${agentId}`); + expect(deleted.ok()).toBe(true); + } + await page.getByRole("button", { name: "Refresh Agents" }).click(); + await expect(page.getByText("No saved Agents", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + const newSession = page.getByRole("button", { name: "New Session" }); + await expect(newSession).toBeEnabled(); + await newSession.click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await expect(dialog.getByRole("radio", { name: /Inline Agent/ })).toBeChecked(); + await expect(dialog.getByRole("radio", { name: /Saved Agent/ })).toBeDisabled(); + await dialog.getByLabel("Inline model").fill("fixture/inline-model"); + await dialog.getByLabel("Inline instructions").fill("Keep exact input structure."); + const tools = dialog.getByRole("region", { name: "Inline tools" }); + await tools.getByRole("button", { name: "Add Function" }).click(); + await tools.getByLabel("Name", { exact: true }).fill("lookup_fixture"); + await tools.getByLabel("Description", { exact: true }).fill("Look up fixture data"); + await tools.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); + + await dialog.getByRole("radio", { name: "Message array" }).check(); + await expect(dialog.getByRole("alert").filter({ hasText: "User message 1 needs nonblank text across its parts." })).toHaveCount(1); + const advancedToggle = dialog.getByRole("button", { name: /Advanced settings/ }); + await advancedToggle.click(); + await expect(dialog.getByRole("button", { name: "Edit messages", exact: true })).toBeVisible(); + await dialog.getByRole("button", { name: "Edit messages", exact: true }).click(); + await expect(advancedToggle).toHaveAttribute("aria-expanded", "true"); + let messages = dialog.locator(".session-initial-message"); + await messages.nth(0).locator(".session-initial-part textarea").nth(0).fill("first-message-part-one"); + await dialog.getByRole("button", { name: "Add text part" }).click(); + await messages.nth(0).locator(".session-initial-part textarea").nth(1).fill("first-message-part-two"); + await dialog.getByRole("button", { name: "Add message" }).click(); + messages = dialog.locator(".session-initial-message"); + await messages.nth(1).locator(".session-initial-part textarea").fill("second-message"); + await dialog.getByRole("button", { name: "Move user message 2 up" }).click(); + await dialog.getByRole("button", { name: "Move text part 2 of user message 2 up" }).click(); + await expect(dialog).not.toContainText("Creation events stream automatically"); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + await expect(dialog).toHaveCount(0); + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByText("second-message", { exact: true })).toBeVisible(); + await expect(conversation).toContainText("first-message-part-two"); + await expect(conversation).toContainText("first-message-part-one"); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toEqual({ + agent: { + model: "fixture/inline-model", + instructions: "Keep exact input structure.", + tools: [{ + type: "function", + name: "lookup_fixture", + description: "Look up fixture data", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }], + }, + environment: { type: "none" }, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "second-message" }] }, + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "first-message-part-two" }, + { type: "input_text", text: "first-message-part-one" }, + ], + }, + ], + metadata: {}, + stream: true, + vault_ids: [], + }); + expect(creates[0]?.body).not.toHaveProperty("agent_id"); +}); + +test("keeps failures visible, rejects stale async continuations, and never retries writes", async ({ page, request }) => { + await openAgents(page, request); + + await controlFixture(request, { retrieveStatus: 500 }); + const failedEditTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await failedEditTrigger.click(); + const openError = page.getByRole("alert"); + await expect(openError).toContainText("Fixture retrieve failed."); + await expect(failedEditTrigger).toBeEnabled(); + await expect(failedEditTrigger).toBeFocused(); + await openError.getByRole("button", { name: "Retry" }).click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(page.locator(".agent-setup-page")).not.toContainText("stale list"); + await page.getByRole("button", { name: "Back to Agents" }).click(); + + await controlFixture(request, { retrieveDelayMs: 400 }); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.waitForTimeout(500); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.locator(".agent-setup-page")).toBeVisible(); + await page.getByLabel("Name").fill("Preserved after failure"); + await controlFixture(request, { updateStatus: 500 }); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("alert")).toContainText("Fixture update failed."); + await expect(page.getByLabel("Name")).toHaveValue("Preserved after failure"); + + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(1); + + await controlFixture(request, { updateDelayMs: 600 }); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByRole("button", { name: "Back to Agents" })).toBeDisabled(); + await page.waitForTimeout(100); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.waitForTimeout(700); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); + + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); +}); + +test("requires delete confirmation, preserves failures, and keeps Session snapshots", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await page.getByRole("button", { name: "Delete Agent" }).click(); + let dialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await expect(dialog).toContainText("Existing Sessions keep their durable Agent snapshots"); + await expect(dialog).toContainText("agent_a"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("button", { name: "Delete Agent" })).toBeFocused(); + + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); + + await page.getByRole("button", { name: "Delete Agent" }).click(); + dialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await controlFixture(request, { deleteStatus: 500 }); + await dialog.getByRole("button", { name: "Delete Agent" }).click(); + await expect(dialog.getByRole("alert")).toContainText("Fixture delete failed."); + await expect(dialog).toContainText("Lifecycle Agent"); + + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(1); + + await controlFixture(request, { deleteDelayMs: 300 }); + await dialog.getByRole("button", { name: "Delete Agent" }).click(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeDisabled(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toHaveCount(0); + + await page.getByRole("button", { name: "Sessions" }).click(); + await expect(page.getByRole("button", { name: /idle Lifecycle Agent/ })).toBeVisible(); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); +}); + +test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px", async ({ page, request }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgents(page, request); + + const agentList = page.getByRole("list", { name: "Agents", exact: true }); + await expect(agentList.getByRole("listitem")).toHaveCount(2); + await expect(agentList.getByRole("listitem").first()).toContainText("Create agent"); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Second Agent/ })).toHaveCount(0); + const showMore = page.getByRole("button", { name: "Show 2 more" }); + await expect(showMore).toHaveAttribute("aria-expanded", "false"); + await showMore.click(); + await expect(page.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true"); + await expect(agentList.getByRole("listitem")).toHaveCount(4); + const secondAgent = page.getByRole("button", { name: /^Edit Second Agent/ }); + await expect(secondAgent).toBeVisible(); + await secondAgent.click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Second Agent", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(secondAgent).toBeFocused(); + await expect(page.getByRole("button", { name: "Show less" })).toBeVisible(); + + const metrics = await page.evaluate(() => { + const main = document.querySelector(".app-main")?.getBoundingClientRect(); + const trigger = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Create agent"))?.getBoundingClientRect(); + const grid = document.querySelector(".agent-card-grid")?.getBoundingClientRect(); + const sessionTrigger = document.querySelector('button[aria-label^="Start a Session with Second Agent ("]')?.getBoundingClientRect(); + return { + innerWidth, + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + main: main && { left: main.left, right: main.right, width: main.width }, + trigger: trigger && { left: trigger.left, right: trigger.right, width: trigger.width }, + grid: grid && { left: grid.left, right: grid.right, width: grid.width }, + sessionTrigger: sessionTrigger && { left: sessionTrigger.left, right: sessionTrigger.right, width: sessionTrigger.width }, + }; + }); + expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); + expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); + expect(metrics.main?.left).toBeGreaterThanOrEqual(0); + expect(metrics.main?.right).toBeLessThanOrEqual(390); + expect(metrics.trigger?.left).toBeGreaterThanOrEqual(0); + expect(metrics.trigger?.right).toBeLessThanOrEqual(390); + expect(metrics.grid?.left).toBeGreaterThanOrEqual(0); + expect(metrics.grid?.right).toBeLessThanOrEqual(390); + expect(metrics.sessionTrigger?.left).toBeGreaterThanOrEqual(metrics.grid?.left ?? 0); + expect(metrics.sessionTrigger?.right).toBeLessThanOrEqual(metrics.grid?.right ?? 390); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); + await attachScreenshot(page, testInfo, "narrow-light-agent-card-grid"); + + 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.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 expect(page.getByLabel("Reasoning effort")).toBeDisabled(); + await expect(setup).toContainText("Creation is locked to the current Session-compatible profile"); + 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"); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + const editSetup = page.locator(".agent-setup-page"); + await expect(editSetup).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const editLayout = await editSetup.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(editLayout.documentScrollWidth).toBeLessThanOrEqual(390); + expect(editLayout.bodyScrollWidth).toBeLessThanOrEqual(390); + expect(editLayout.left).toBeGreaterThanOrEqual(0); + expect(editLayout.right).toBeLessThanOrEqual(390.5); + await attachScreenshot(page, testInfo, "narrow-dark-agent-edit-setup"); + + const deleteAction = editSetup.getByRole("button", { name: "Delete Agent" }); + await deleteAction.scrollIntoViewIfNeeded(); + await expect(deleteAction).toBeInViewport(); + await deleteAction.click(); + const deleteDialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await expect(deleteDialog).toBeVisible(); + await expect(deleteDialog).toContainText("agent_a"); + await expect(deleteDialog.getByRole("button", { name: "Cancel" })).toBeInViewport(); + await expect(deleteDialog.getByRole("button", { name: "Delete Agent" })).toBeInViewport(); + const dialogBox = await deleteDialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((dialogBox?.x ?? 0) + (dialogBox?.width ?? 0)).toBeLessThanOrEqual(390.5); + await attachScreenshot(page, testInfo, "narrow-dark-agent-delete-confirmation"); + await deleteDialog.getByRole("button", { name: "Cancel" }).click(); + await expect(editSetup).toBeVisible(); + await expect(editSetup.getByRole("button", { name: "Delete Agent" })).toBeFocused(); +}); + +test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => { + await openAgents(page, request); + await startSessionWithSecondAgent(page); + await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + + const requests = await fixtureRequests(request); + const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); + expect(creates).toHaveLength(1); + expect(creates[0]?.idempotencyKeyPresent).toBe(true); + expect(creates[0]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, stream: false }); + for (const entry of requests.filter((candidate) => candidate.path.startsWith("/v1/"))) { + expect(entry.beta).toBe("agents=v1"); + expect(entry.authorizationPresent).toBe(false); + } +}); + +test("filters every Session page by Agent and aborts a stale filter read", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { sessionListPageSize: 1 }); + + await openSessionsFromHome(page); + const filter = page.getByLabel("Filter Sessions by Agent"); + await expect(filter).toBeVisible(); + await expect(page.locator(".session-row")).toHaveCount(1); + + // These Sessions are deliberately created after the global snapshot loads. + // The Agent-scoped list must be able to select them without inflating Dashboard totals. + await createFixtureSession(request, "first"); + await createFixtureSession(request, "second"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".session-row")).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const filteredReads = (await fixtureRequests(request)).filter((entry) => { + if (entry.method !== "GET" || entry.path !== "/v1/agents/sessions") return false; + return new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b"; + }); + expect(filteredReads).toHaveLength(2); + expect(filteredReads.every((entry) => ( + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b" + ))).toBe(true); + expect(filteredReads.some((entry) => ( + new URLSearchParams(entry.query ?? "").has("after") + ))).toBe(true); + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await expect(filter).toHaveValue("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const before = await fixtureState(request); + const agentAReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && + entry.path === "/v1/agents/sessions" && + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" + )).length; + await controlFixture(request, { sessionListDelayMs: 500 }); + await filter.selectOption("agent_a"); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && + entry.path === "/v1/agents/sessions" && + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" + )).length).toBeGreaterThan(agentAReadsBefore); + + await filter.selectOption("agent_b"); + await expect.poll(async () => (await fixtureState(request)).aborts.sessionListReads) + .toBeGreaterThan(before.aborts.sessionListReads); + await expect(page.locator(".session-row")).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + await page.waitForTimeout(550); + await expect(filter).toHaveValue("agent_b"); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + + await filter.selectOption(""); + await expect(filter).toHaveValue(""); + await expect(page.locator(".session-row")).toHaveCount(1); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const lifecycleRow = page.getByRole("table", { name: "Recent Sessions" }) + .getByRole("row") + .filter({ hasText: "Lifecycle Agent" }); + await lifecycleRow.getByRole("button").click(); + await expect(filter).toHaveValue(""); + await expect(page.locator(".session-row")).toHaveCount(1); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); +}); + +test("fences the filtered workspace across loading, errors, unavailable Agents, and deletes", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await createFixtureSession(request, "delete-first"); + await createFixtureSession(request, "delete-second"); + + const filter = page.getByLabel("Filter Sessions by Agent"); + await controlFixture(request, { sessionListDelayMs: 500 }); + await filter.selectOption("agent_b"); + await expect(page.getByLabel("Loading Session workspace")).toBeVisible(); + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + await controlFixture(request, { sessionListStatus: 503 }); + await filter.selectOption("agent_a"); + await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await page.locator(".workspace-error").getByRole("button", { name: "Retry" }).click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + const deletedAgent = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); + expect(deletedAgent.ok()).toBe(true); + await page.getByRole("button", { name: "Recover durable state" }).click(); + await expect(filter).toHaveValue("agent_b"); + await expect(filter.locator("option:checked")).toHaveText("Unavailable Agent (not loaded)"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + for (const remainingRows of [1, 0]) { + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".session-row")).toHaveCount(remainingRows); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + if (remainingRows) { + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + } + } + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await expect(page.locator(".workspace-empty")).toContainText("Select or create a Session"); +}); + +test("streams initial Session creation, captures early events, then hands off to one GET stream", async ({ page, request }) => { + await openAgents(page, request); + await controlFixture(request, { sessionCreateStreamCloseDelayMs: 1_500 }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^Title\b/u }).fill("Streamed creation"); + const exactInput = " Initial streamed input \n"; + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(exactInput); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Streamed creation"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByText("Initial streamed input", { exact: true }), + ).toBeVisible(); + + await expect.poll(async () => (await fixtureState(request)).sessions[0]?.id).toMatch(/^session_created_/u); + const createdSessionId = (await fixtureState(request)).sessions[0]?.id; + if (!createdSessionId) throw new Error("Fixture did not retain the streamed Session."); + const createRequests = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(createRequests).toHaveLength(1); + expect(createRequests[0]?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + input: exactInput, + metadata: { title: "Streamed creation" }, + stream: true, + vault_ids: [], + }); + + await page.waitForTimeout(250); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + ))).toHaveLength(0); + + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + )).length).toBe(1); + const handoffRequests = await fixtureRequests(request); + const getStreamIndex = handoffRequests.findIndex((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + )); + expect(getStreamIndex).toBeGreaterThan(-1); + expect(handoffRequests.slice(0, getStreamIndex).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}` + )).length).toBeGreaterThanOrEqual(2); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByText("Initial streamed input", { exact: true }), + ).toBeVisible(); +}); + +test("keeps one Session create attempt across response loss and an unchanged manual retry", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + const create = dialog.getByRole("button", { name: "Create Session" }); + await controlFixture(request, { sessionCreateDelayMs: 1_500, sessionCreateResponseLoss: 1 }); + + await create.evaluate((button) => { + button.click(); + button.click(); + }); + await expect(dialog.getByRole("alert")).toContainText("Agent core request failed (502)."); + await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible(); + + let creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + const originalKey = creates[0]?.idempotencyKey; + expect(originalKey).toBeTruthy(); + expect((await fixtureState(request)).sessions).toHaveLength(2); + + await create.click(); + await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); + creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(2); + expect(creates[1]?.idempotencyKey).toBe(originalKey); + expect((await fixtureState(request)).sessions).toHaveLength(2); +}); + +test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const activity = page.locator(".conversation-activity"); + await expect(activity).toHaveCount(0); + await emitSessionFixture(request, "in_progress"); + await expect(activity).toBeVisible(); + await expect(activity).toContainText("Lifecycle Agent is working…"); + await expect(activity).toHaveAttribute("aria-live", "polite"); + await expect(activity).toHaveAttribute("aria-atomic", "true"); + await expect(activity.locator(".status-running")).toBeVisible(); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeVisible(); + await attachElementScreenshot(activity, testInfo, "conversation-activity"); + + await emitSessionFixture(request, "idle"); + await expect(activity).toHaveCount(0); + await expect(page.getByRole("button", { name: "Send message" })).toBeVisible(); +}); + +test("shows immediate local feedback while a message submission is waiting for Core", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + let interceptedSends = 0; + await page.route("**/v1/agents/sessions/*/events", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + interceptedSends += 1; + await sendGate; + await route.continue(); + }); + + const composer = page.getByLabel("Message the Agent"); + await composer.fill("Immediate pending message"); + await composer.press("Enter"); + + const pending = page.locator('[data-send-state="sending"]'); + await expect(pending).toBeVisible(); + await expect(pending).toContainText("Immediate pending message"); + await expect(pending).toContainText("Sending…"); + await expect(page.locator(".conversation-activity")).toContainText("Sending message…"); + await expect(page.getByText("Lifecycle Agent is working…")).toHaveCount(0); + await expect(composer).toHaveValue(""); + await expect.poll(() => interceptedSends).toBe(1); + + releaseSend?.(); + await expect(pending).toHaveCount(0); + await expect(page.locator(".conversation-activity")).toHaveCount(0); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(1); +}); + +test("updates Session title and metadata after a latest read while preserving failed and unknown drafts", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/events") + )).length; + + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Renamed Session"); + await dialog.getByLabel("Additional Session metadata", { exact: true }).fill('{"team":"web","note":"safe"}'); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("heading", { name: "Renamed Session" })).toBeVisible(); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + + let requests = await fixtureRequests(request); + const updateIndex = requests.findLastIndex((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + const latestReadIndex = requests.findLastIndex((entry, index) => ( + index < updateIndex && entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(latestReadIndex).toBeGreaterThanOrEqual(0); + expect(latestReadIndex).toBeLessThan(updateIndex); + expect(requests[updateIndex]?.body).toEqual({ + metadata: { team: "web", note: "safe", title: "Renamed Session" }, + }); + expect(requests.filter((entry) => entry.method === "GET" && entry.path.endsWith("/events"))).toHaveLength(streamReadsBefore); + + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Draft survives 503"); + await controlFixture(request, { sessionUpdateStatus: 503 }); + const postsBefore503 = requests.filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("503"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + await page.waitForTimeout(350); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot")) + .toHaveLength(postsBefore503 + 1); + + await controlFixture(request, { sessionUpdateResponseLoss: 1 }); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("result is unknown"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + const postsAfterLoss = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(postsAfterLoss); +}); + +test("preserves and safely rebases a Session metadata draft after a same-key conflict", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("My preserved draft"); + await setFixtureSessionMetadata(request, "session_snapshot", { + title: "Concurrent title", + concurrent: "must survive", + }); + + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("Metadata changed in Agent Core"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("My preserved draft"); + await expect(dialog.getByLabel("Additional Session metadata", { exact: true })).toContainText('"concurrent": "must survive"'); + await expect(page.locator(".conversation-header h2")).toHaveText("Concurrent title"); + let writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(writes).toHaveLength(0); + + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("heading", { name: "My preserved draft" })).toBeVisible(); + writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(writes).toHaveLength(1); + expect(writes[0]?.body).toEqual({ + metadata: { title: "My preserved draft", concurrent: "must survive" }, + }); +}); + +test("rejects wrong-id and deep-malformed Session reads before writes or delete retries", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await controlFixture(request, { sessionRetrieveVariant: "wrong_id" }); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("alert")).toContainText("invalid Session resource"); + await expect(dialog).toContainText("session_snapshot"); + await expect(dialog).not.toContainText("another_session"); + + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Draft stays local"); + await controlFixture(request, { sessionRetrieveVariant: "deep_malformed" }); + const writesBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("invalid Session retrieval response"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft stays local"); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(writesBefore); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog).toContainText("Exact Session: session_snapshot"); + await controlFixture(request, { + sessionDeleteResponseLoss: 2, + sessionRetrieveVariant: "deep_malformed", + }); + const deletesBefore = (await fixtureRequests(request)).filter((entry) => entry.method === "DELETE").length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); + await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")) + .toHaveLength(deletesBefore + 1); +}); + +test("requires confirmation and reconciles unknown Session deletes once without retrying the write", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog).toContainText("Exact Session: session_snapshot"); + await expect(dialog).toContainText("not a promise of physical history erasure"); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(0); + + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteStatus: 409 }); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("compatible Core rejected"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await controlFixture(request, { sessionDeleteStatus: 503 }); + const readsBefore503 = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("503"); + await expect(dialog.getByRole("alert")).toContainText("refresh confirmed that the Session is still present"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(2); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBefore503 + 1); + + await controlFixture(request, { + sessionDeleteResponseLoss: 2, + sessionRetrieveStatus: 503, + }); + const readsBeforeUnresolved = requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("result is unknown"); + await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(3); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBeforeUnresolved + 1); + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(3); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + await manage.click(); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteResponseLoss: 1 }); + const readsBeforeAppliedLoss = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(4); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBeforeAppliedLoss + 1); + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(4); +}); + +test("keeps a stale Session row and surfaces each explicit repeated 404 deletion", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await removeFixtureSession(request, "session_snapshot"); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + + for (const expectedDeletes of [1, 2]) { + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("not found in Agent Core"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(expectedDeletes); + } +}); + +test("deletes an inactive Session without disturbing the active composer or live event stream", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await startSessionWithSecondAgent(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + const composer = page.getByLabel("Message the Agent"); + await composer.fill("active draft must survive"); + const before = await fixtureState(request); + const activeId = before.sessions.find((session) => session.id !== "session_snapshot")?.id; + expect(activeId).toBeTruthy(); + const activeStreamReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` + )).length; + + const inactiveRow = page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }); + await inactiveRow.locator(".session-row-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteDelayMs: 1_500 }); + const deleteButton = dialog.locator(".modal-footer .button.danger"); + const deleteClick = deleteButton.click(); + await expect(deleteButton).toBeDisabled(); + await expect(deleteButton).toHaveText("Deleting…"); + await expect(inactiveRow).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + await deleteClick; + await expect(dialog).toHaveCount(0); + await expect(inactiveRow).toHaveCount(0); + await expect(composer).toHaveValue("active draft must survive"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.locator(".conversation-session-action")).toBeFocused(); + + const after = await fixtureState(request); + expect(after.aborts.streams).toBe(before.aborts.streams); + expect(after.openStreams).toContain(activeId); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` + ))).toHaveLength(activeStreamReads); +}); + +test("continues a Session with a new Turn after its latest attempt fails", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await emitSessionFixture(request, "failed"); + await expect(page.getByText("Latest attempt failed", { exact: true })).toBeVisible(); + await expect(page.locator(".session-runtime-error")).toContainText( + "You can send another message to start a new Turn in this Session.", + ); + + const composer = page.getByLabel("Message the Agent"); + await expect(composer).toBeEnabled(); + await composer.fill("Continue after the failed Turn"); + const send = page.getByRole("button", { name: "Send message" }); + await expect(send).toBeEnabled(); + await send.click(); + + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length).toBe(1); + const submission = (await fixtureRequests(request)).find((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )); + expect(submission?.body).toEqual({ + events: [{ + type: "agent.session.input.message", + input: [{ + role: "user", + content: [{ type: "input_text", text: "Continue after the failed Turn" }], + }], + }], + }); +}); + +for (const pendingRead of [ + { + label: "Session", + path: "/v1/agents/sessions/session_snapshot", + control: { sessionRetrieveDelayMs: 5_000 }, + }, + { + label: "Item", + path: "/v1/agents/sessions/session_snapshot/items", + control: { itemsRetrieveDelayMs: 5_000 }, + }, + { + label: "Turn", + path: "/v1/agents/sessions/session_snapshot/turns", + control: { turnsRetrieveDelayMs: 5_000 }, + }, +] as const) { + test(`aborts the selected Session's pending ${pendingRead.label} read and SSE after confirmed delete`, async ({ page, request }) => { + const failedReads = new Map(); + page.on("requestfailed", (failedRequest) => { + const path = new URL(failedRequest.url()).pathname; + if (failedRequest.method() === "GET") { + failedReads.set(path, failedRequest.failure()?.errorText ?? "unknown failure"); + } + }); + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await startSessionWithSecondAgent(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await controlFixture(request, { turnsScenario: 1 }); + await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + const before = await fixtureState(request); + const previousReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === pendingRead.path + )).length; + await controlFixture(request, pendingRead.control); + await emitTurnFixture(request, "completed"); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === pendingRead.path + )).length).toBeGreaterThan(previousReads); + + await controlFixture(request, { sessionDeleteStreamCloseDelayMs: 3_000 }); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); + await expect(page.locator('[data-turn-id="turn_completed"]')).toHaveCount(0); + await expect(page.locator(".conversation-session-action")).toBeFocused(); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await expect.poll(() => failedReads.get(pendingRead.path)).toContain("ERR_ABORTED"); + await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); + const after = await fixtureState(request); + expect(after.sessions.some((session) => session.id === "session_snapshot")).toBe(false); + expect(after.openStreams).not.toContain("session_snapshot"); + }); +} + +test("aborts a pending manual recovery read after deleting the selected Session", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); + + await expectSelectedDeleteAbortsSessionRead(page, request, () => ( + page.getByRole("button", { name: "Recover durable state" }).click() + )); +}); + +test("aborts a pending detail retry read after deleting the selected Session", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await controlFixture(request, { sessionRetrieveStatus: 503 }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + const detailError = page.locator(".session-detail-error"); + await expect(detailError).toBeVisible(); + await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); + + await expectSelectedDeleteAbortsSessionRead(page, request, () => ( + detailError.getByRole("button", { name: "Retry" }).click() + )); +}); + +test("deletes the selected Session while its SSE is still connecting", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await startSessionWithSecondAgent(page); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await controlFixture(request, { streamOpenDelayMs: 3_000 }); + const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length; + await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(page.getByText("Connecting events…", { exact: true })).toBeVisible(); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length).toBeGreaterThan(streamReadsBefore); + const before = await fixtureState(request); + + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); + expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); +}); + +test("keeps Session actions accessible and contained at 390 px in dark mode", async ({ page, request }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await page.getByRole("button", { name: "Dark theme" }).click(); + const manage = page.locator(".conversation-session-action"); + await manage.focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + const metrics = await dialog.evaluate((element) => { + const box = element.getBoundingClientRect(); + const footer = element.querySelector(".modal-footer")?.getBoundingClientRect(); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + left: box.left, + right: box.right, + bottom: box.bottom, + footerBottom: footer?.bottom ?? Number.POSITIVE_INFINITY, + }; + }); + expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.left).toBeGreaterThanOrEqual(0); + expect(metrics.right).toBeLessThanOrEqual(390); + expect(metrics.bottom).toBeLessThanOrEqual(844); + expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.bottom); + + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(manage).toBeFocused(); +}); + +test("presents Dashboard page-chain results and System boundaries without extra detail reads", async ({ page, request }, testInfo) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.getByText("Session is ready", { exact: true })).toBeVisible(); + const initialDetailPaths = [ + "/v1/agents/sessions/session_snapshot", + "/v1/agents/sessions/session_snapshot/items", + "/v1/agents/sessions/session_snapshot/turns", + ]; + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return initialDetailPaths.every((path) => entries.filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length >= 2); + }).toBe(true); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.getByRole("heading", { name: /Dashboard/ })).toBeVisible(); + await expect(dashboard).toContainText("Latest complete paginated reads"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "In progress" })).toContainText("0"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Needs attention" })).toContainText("0"); + await expect(dashboard).not.toContainText("Reported aggregate tokens"); + await expect(dashboard).toContainText("No Sessions currently need attention."); + await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); + await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); + + const before = await fixtureRequests(request); + const count = (entries: FixtureRequest[], path: string) => entries.filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length; + const detailPaths = [ + "/v1/agents/sessions/session_snapshot", + "/v1/agents/sessions/session_snapshot/items", + "/v1/agents/sessions/session_snapshot/turns", + ]; + const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + await refresh.click(); + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; + }).toEqual([count(before, "/v1/agents") + 1, count(before, "/v1/agents/sessions") + 1]); + const after = await fixtureRequests(request); + expect(count(after, "/v1/agents")).toBe(count(before, "/v1/agents") + 1); + expect(count(after, "/v1/agents/sessions")).toBe(count(before, "/v1/agents/sessions") + 1); + for (const path of detailPaths) expect(count(after, path)).toBe(count(before, path)); + await attachScreenshot(page, testInfo, "desktop-dashboard-loaded-snapshot"); + + await page.setViewportSize({ width: 390, height: 844 }); + const dashboardBounds = await dashboard.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(dashboardBounds.documentWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); + expect(dashboardBounds.bodyWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); + expect(dashboardBounds.left).toBeGreaterThanOrEqual(0); + expect(dashboardBounds.right).toBeLessThanOrEqual(390); + await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); + await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); + await attachScreenshot(page, testInfo, "narrow-dashboard-loaded-snapshot"); + await page.setViewportSize({ width: 1280, height: 720 }); + + await dashboard.getByRole("button", { name: /Create agent/ }).click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "New Agent" })).toBeVisible(); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await dashboard.getByRole("button", { name: /Start session/ }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await sessionDialog.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click(); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); + + 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).not.toContainText("Source Files"); + await expect(system).not.toContainText("Public capability surface"); + + 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, + ]); + 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); + 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: 390, height: 844 }); + const systemBounds = await system.evaluate((element) => { + const rows = [...element.querySelectorAll(".system-summary-cell")].map((row) => row.getBoundingClientRect()); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + rowBounds: rows.map((row) => ({ top: row.top, bottom: row.bottom, height: row.height })), + }; + }); + expect(systemBounds.documentWidth).toBeLessThanOrEqual(systemBounds.viewportWidth); + 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); + await attachScreenshot(page, testInfo, "narrow-system-contract-boundary"); +}); + +test("publishes Dashboard counts only after every top-level Agent and Session page loads", async ({ page, request }) => { + await resetFixture(request); + const agentAfters: Array = []; + const sessionAfters: Array = []; + let sessionTemplate: Record | null = null; + + await page.route("**/v1/agents**", async (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET" || !["/v1/agents", "/v1/agents/sessions"].includes(url.pathname)) { + await route.continue(); + return; + } + + const upstream = await route.fetch(); + const payload = await upstream.json() as { + object: string; + data: Array>; + }; + const after = url.searchParams.get("after"); + expect(url.searchParams.get("limit")).toBe("100"); + expect(url.searchParams.get("order")).toBe("desc"); + + if (url.pathname === "/v1/agents") { + agentAfters.push(after); + const data = after === null ? payload.data.slice(0, 2) : payload.data.slice(2); + await route.fulfill({ + response: upstream, + json: { + object: "list", + data, + has_more: after === null, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }, + }); + return; + } + + sessionAfters.push(after); + if (after === null) sessionTemplate = payload.data[0] ?? null; + if (sessionTemplate === null) throw new Error("The fixture did not provide a Session pagination template."); + const original = sessionTemplate; + const second = { + ...original, + id: "session_second_page", + metadata: { fixture: "second-page" }, + created_at: Number(original?.created_at ?? 0) - 1, + last_active_at: Number(original?.last_active_at ?? 0) - 1, + }; + const data = after === null ? [original] : [second]; + await route.fulfill({ + response: upstream, + json: { + object: "list", + data, + has_more: after === null, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }, + }); + }); + + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); + expect(agentAfters).toEqual([null, "agent_b"]); + expect(sessionAfters).toEqual([null, "session_snapshot"]); +}); + +test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { + await resetFixture(request); + await openSessionsFromHome(page); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + const dashboard = page.locator(".dashboard-page"); + const loadedAgents = dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" }); + await expect(loadedAgents).toContainText("3"); + + let template: Record | null = null; + let reads = 0; + await page.route("**/v1/agents**", async (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET" || url.pathname !== "/v1/agents") { + await route.continue(); + return; + } + reads += 1; + if (template === null) { + const upstream = await route.fetch(); + const payload = await upstream.json() as { data: Array> }; + template = payload.data[0] ?? null; + } + expect(template).not.toBeNull(); + const id = `agent_safety_page_${reads}`; + await route.fulfill({ + json: { + object: "list", + data: [{ ...template, id, name: `Safety page ${reads}` }], + has_more: true, + first_id: id, + last_id: id, + }, + }); + }); + + const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + await refresh.click(); + await expect.poll(() => reads).toBe(100); + await expect(refresh).toBeEnabled(); + await expect(dashboard).toContainText("Using the last successful snapshot"); + await expect(dashboard).toContainText("collection pagination exceeded the Web safety limit"); + await expect(loadedAgents).toContainText("3"); + await dashboard.getByRole("button", { name: "Connection settings" }).click(); + const connectionDialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(connectionDialog).toBeVisible(); + await connectionDialog.getByRole("button", { name: "Cancel" }).click(); + expect(reads).toBe(100); +}); + +test("renders self-hosted Environment and Workspace state safely across reconnect and narrow themes", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 1, + environmentEventStatus: 1, + environmentEventCount: 1, + streamCloseCount: 1, + streamCloseDelayMs: 1_000, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const { dialog, panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment pending"); + await expect(panel).toContainText("Self-hosted Environment"); + await expect(panel).toContainText("Pending"); + await expect(panel).toContainText("environment_fixture"); + await expect(panel).toContainText("Workspace is this Environment’s execution directory, not a top-level workspaces API"); + await expect(panel).toContainText("https://executor.example.test/connect"); + await expect(panel.locator('a[href="https://executor.example.test/connect"]')).toHaveCount(0); + await expect(panel.getByRole("link", { name: "Core setup" })).toBeVisible(); + 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" })).toHaveCount(0); + 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); + + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/events")).length).toBeGreaterThanOrEqual(2); + await expect(panel).toContainText("Pending"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); + await panel.evaluate((element) => element.scrollIntoView({ block: "start" })); + await attachElementScreenshot(panel, testInfo, "desktop-light-environment-panel"); + await attachScreenshot(page, testInfo, "desktop-light-self-hosted-environment"); + + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "Dark theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + const { panel: narrowPanel } = await openEnvironmentDialog(page); + const widths = await narrowPanel.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.left).toBeGreaterThanOrEqual(0); + expect(widths.right).toBeLessThanOrEqual(390); + await narrowPanel.getByRole("link", { name: "Launcher setup" }).focus(); + await expect(narrowPanel.getByRole("link", { name: "Launcher setup" })).toBeFocused(); + await narrowPanel.evaluate((element) => element.scrollIntoView({ block: "start" })); + await attachElementScreenshot(narrowPanel, testInfo, "narrow-dark-environment-panel"); + await attachScreenshot(page, testInfo, "narrow-dark-self-hosted-environment"); + + await controlFixture(request, { environmentScenario: 2, environmentEventStatus: 0 }); + await page.reload(); + const { panel: unknown, trigger: unknownTrigger } = await openEnvironmentDialog(page); + await expect(unknownTrigger).toHaveAccessibleName("Environment unavailable"); + await expect(unknown).toContainText("Environment unavailable"); + await expect(unknown).toContainText("Unknown type"); + await expect(unknown).not.toContainText("/must-not-render"); + await expect(unknown.locator("a")).toHaveCount(0); + + await controlFixture(request, { environmentScenario: 3, environmentEventStatus: 0 }); + await page.reload(); + await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); + await expect(page.locator(".workspace-error")).toContainText("invalid Session resource"); + await expect(environmentTrigger(page)).toHaveCount(0); +}); + +test("lists Workspace file metadata explicitly, paginates, fails closed, and fences Environment changes", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 7 }); + await openSessionsFromHome(page); + + const { panel } = await openEnvironmentDialog(page); + const files = panel.getByRole("region", { name: "Workspace files" }); + await expect(files).toBeVisible(); + await expect(panel).toContainText("/executor/workspace"); + await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); + await expect(files).toContainText("Files are loaded only when requested"); + expect((await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files"))).toHaveLength(0); + + await files.getByRole("button", { name: "List files" }).click(); + const table = files.getByRole("table", { name: "Workspace file metadata" }); + await expect(table).toContainText("/executor/workspace/file-01.txt"); + await expect(table).toContainText("/executor/workspace/file-20.txt"); + await expect(table).not.toContainText("/executor/workspace/file-21.txt"); + await expect(files.getByRole("button", { name: "Load more" })).toBeVisible(); + + let reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); + expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc"); + expect(reads.at(-1)?.beta).toBe("agents=v1"); + await files.getByRole("button", { name: "Load more" }).click(); + await expect(table).toContainText("/executor/workspace/file-21.txt"); + await expect(files.getByRole("button", { name: "Load more" })).toHaveCount(0); + reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); + expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc&page=fixture-page-2"); + await attachElementScreenshot(files, testInfo, "workspace-file-metadata-list"); + + await controlFixture(request, { environmentFilesStatus: 503 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect(files.getByRole("alert")).toContainText("Workspace files are temporarily unavailable"); + await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); + await expect(files).not.toContainText("No direct regular files were returned"); + + await controlFixture(request, { environmentFilesStatus: 404 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect(files.getByRole("alert")).toContainText("not supported by the connected Core"); + await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); + + const beforeAbort = (await fixtureState(request)).aborts.environmentFileReads; + await controlFixture(request, { environmentFilesStatus: 200, environmentFilesDelayMs: 2_000 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.path.endsWith("/files")).length).toBeGreaterThan(reads.length + 1); + await controlFixture(request, { environmentScenario: 2 }); + await page.getByRole("button", { name: "Recover durable state" }).evaluate((button) => ( + button as HTMLButtonElement + ).click()); + await expect(page.getByRole("region", { name: "Workspace files" })).toHaveCount(0); + await expect.poll(async () => (await fixtureState(request)).aborts.environmentFileReads).toBeGreaterThan(beforeAbort); +}); + +test("hydrates durable expired and unavailable Environment states without a write or paid Turn", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentResourceStatus: "expired", + environmentEventStatus: 0, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + let { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment expired"); + await expect(panel).toContainText("Expired"); + await expect(panel).toContainText("Environment expired"); + await expect(panel).toContainText("no API-managed files, plugins, or skills"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + const initialRequests = await fixtureRequests(request); + const sessionReadIndex = initialRequests.findIndex( + (entry) => entry.method === "GET" && /^\/v1\/agents\/sessions\/[^/]+$/.test(entry.path), + ); + const environmentReadIndex = initialRequests.findIndex( + (entry) => entry.method === "GET" && isEnvironmentResourcePath(entry.path), + ); + expect(sessionReadIndex).toBeGreaterThanOrEqual(0); + expect(environmentReadIndex).toBeGreaterThan(sessionReadIndex); + + let environmentRequests = initialRequests.filter( + (entry) => isEnvironmentResourcePath(entry.path), + ); + expect(environmentRequests.length).toBeGreaterThanOrEqual(1); + expect(environmentRequests.every((entry) => entry.method === "GET" && entry.body === undefined)).toBe(true); + + await controlFixture(request, { environmentRetrieveStatus: 503 }); + await page.reload(); + ({ panel, trigger } = await openEnvironmentDialog(page)); + await expect(trigger).toHaveAccessibleName("Environment unavailable"); + await expect(panel).toContainText("Unavailable"); + await expect(panel).toContainText("conversation remains usable"); + await expect(panel).not.toContainText("Expired"); + await expect(panel).not.toContainText("Connected"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + await controlFixture(request, { + environmentRetrieveStatus: 200, + environmentResourceVariant: "missing_skills", + }); + await page.reload(); + ({ panel, trigger } = await openEnvironmentDialog(page)); + await expect(trigger).toHaveAccessibleName("Environment unavailable"); + await expect(panel).toContainText("Unavailable"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + environmentRequests = (await fixtureRequests(request)).filter( + (entry) => isEnvironmentResourcePath(entry.path), + ); + expect(environmentRequests.every((entry) => entry.method === "GET")).toBe(true); +}); + +test("hydrates durable Environment state even when the live stream is rejected", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentResourceStatus: "expired", + streamStatus: 401, + }); + await openSessionsFromHome(page); + + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment expired"); + await expect(panel).toContainText("Expired"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); + await expect(page.getByText("Events unavailable", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + const requests = await fixtureRequests(request); + expect(requests.some((entry) => ( + entry.method === "GET" && isEnvironmentResourcePath(entry.path) + ))).toBe(true); +}); + +test("keeps canonical Environment UUID identity across Session and resource projections", async ({ page, request }) => { + await resetFixture(request); + const canonicalEnvironmentUuid = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; + await controlFixture(request, { + environmentScenario: 5, + environmentResourceStatus: "connected", + environmentEventStatus: 0, + }); + await openSessionsFromHome(page); + + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment connected"); + await expect(panel).toContainText("Connected"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); + await expect(panel).not.toContainText("Durable Environment status is unavailable"); + await expect(panel).toContainText(canonicalEnvironmentUuid.toUpperCase()); + + const environmentRequests = (await fixtureRequests(request)).filter( + (entry) => entry.method === "GET" && isEnvironmentResourcePath(entry.path), + ); + expect(environmentRequests.length).toBeGreaterThanOrEqual(1); + expect(environmentRequests.every( + (entry) => entry.path === `/v1/agents/environments/${canonicalEnvironmentUuid.toUpperCase()}` && + entry.body === undefined, + )).toBe(true); +}); + +test("applies a buffered live Environment event after an earlier durable snapshot", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentResourceStatus: "pending", + streamStatus: 401, + }); + await openSessionsFromHome(page); + + await expect(environmentTrigger(page)).toHaveAccessibleName("Environment pending"); + const streamError = page.locator(".session-stream-error"); + await expect(streamError.getByText("Couldn’t open live events")).toBeVisible(); + + await controlFixture(request, { + environmentRetrieveDelayMs: 500, + environmentEventStatus: 3, + environmentEventCount: 1, + streamStatus: 200, + }); + await streamError.getByRole("button", { name: "Retry" }).click(); + + const { panel, trigger } = await openEnvironmentDialog(page); + await expect(trigger).toHaveAccessibleName("Environment connected"); + await expect(panel).toContainText("Connected"); + await expect(panel).toContainText("last supported live event observed after the durable Environment snapshot"); + await expect(panel).not.toContainText("Pending"); +}); + +test("loads every Turn page, reconciles terminal events, and keeps diagnostics out of Conversation", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsPageSize: 2, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const conversationTab = page.getByRole("tab", { name: "Conversation" }); + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); + + await page.getByRole("tab", { name: "Trace" }).click(); + const trace = page.getByRole("tabpanel", { name: "Trace" }); + const diagnostics = trace.locator("details.trace-turn-diagnostics"); + await expect(diagnostics).not.toHaveAttribute("open", ""); + await diagnostics.locator("summary").click(); + await expect(diagnostics).toHaveAttribute("open", ""); + + const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); + await expect(timeline).toContainText("7 observed Turns"); + for (const status of ["Queued", "In progress", "Waiting", "Completed", "Failed", "Cancelled"]) { + await expect(timeline.getByRole("img", { name: `Turn status: ${status}` }).first()).toBeVisible(); + } + await expect(timeline).toContainText("Running ·"); + await expect(timeline.locator('[data-turn-id="turn_completed"]')).toContainText("7s"); + await expect(timeline.getByRole("region", { name: "Session aggregate usage" })).toContainText("26"); + await expect(timeline.locator('[data-turn-id="turn_completed"]').getByRole("group", { name: "Usage for Turn turn_completed" })).toContainText("13"); + const failed = timeline.locator('[data-turn-id="turn_failed"]'); + await expect(failed).toContainText("The execution could not complete."); + await expect(failed).toContainText("1 linked Item"); + await expect(trace.getByText("Persisted input before the Turn failed.")).toBeVisible(); + await expect(timeline).toContainText("1 Item is not associated with an observed Turn yet."); + + const readsBeforeTerminal = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/turns") + )); + expect(readsBeforeTerminal.length).toBeGreaterThanOrEqual(4); + expect(readsBeforeTerminal.some((entry) => entry.query === "?limit=100&order=asc")).toBe(true); + expect(readsBeforeTerminal.some((entry) => entry.query?.includes("after=turn_in_progress"))).toBe(true); + expect(readsBeforeTerminal.every((entry) => entry.body === undefined)).toBe(true); + + const terminal = timeline.locator('[data-turn-id="turn_terminal_refresh"]'); + await expect(terminal).toHaveAttribute("data-turn-status", "in_progress"); + await emitTurnFixture(request, "completed"); + await expect(terminal).toHaveAttribute("data-turn-status", "completed"); + await expect(terminal).toContainText("Turn usage"); + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/turns")).length).toBeGreaterThan(readsBeforeTerminal.length); + + await controlFixture(request, { turnsRetrieveStatus: 503 }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + await expect(trace.locator(".trace-load-error").filter({ hasText: "Turn history is incomplete" })).toBeVisible(); + await expect(timeline.locator(".turn-timeline-failure")).toContainText("Couldn’t load Turn history"); + await expect(timeline).toContainText("last observed Turn timeline remains visible"); + await expect(trace.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await diagnostics.evaluate((element) => element.scrollIntoView({ block: "start" })); + const diagnosticsLayout = await diagnostics.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + overflowY: getComputedStyle(element).overflowY, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }; + }); + expect(diagnosticsLayout.document).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.body).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.left).toBeGreaterThanOrEqual(0); + expect(diagnosticsLayout.right).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.overflowY).toBe("auto"); + expect(diagnosticsLayout.scrollHeight).toBeGreaterThan(diagnosticsLayout.clientHeight); + await diagnostics.evaluate((element) => { element.scrollTop = element.scrollHeight; }); + await expect(timeline.locator(".turn-card").last()).toBeVisible(); + await attachElementScreenshot(diagnostics, testInfo, "narrow-turn-diagnostics"); + + await conversationTab.click(); + await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); + await expect(conversation.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); +}); + +test("presents an honest searchable Trace workbench without changing the conversation draft", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsPageSize: 2, + itemsScenario: 2, + }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const viewTabs = page.getByRole("tablist", { name: "Session view" }); + const conversationTab = viewTabs.getByRole("tab", { name: "Conversation" }); + const traceTab = viewTabs.getByRole("tab", { name: "Trace" }); + const composer = page.getByLabel("Message the Agent"); + await expect(page.locator("#session-trace-panel")).toBeHidden(); + await composer.fill("Draft survives Trace inspection\nwith a second line"); + const sendsBefore = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + + await conversationTab.focus(); + await page.keyboard.press("ArrowRight"); + await expect(traceTab).toHaveAttribute("aria-selected", "true"); + const trace = page.getByRole("tabpanel", { name: "Trace" }); + await expect(trace).toBeVisible(); + await expect(trace).toContainText("Known Turn time"); + const diagnostics = trace.locator("details.trace-turn-diagnostics"); + await expect(diagnostics).not.toHaveAttribute("open", ""); + await expect(diagnostics.locator("summary")).toContainText("Turn diagnostics"); + await expect(trace).toContainText("Turns"); + await expect(trace).toContainText("Tool calls"); + await expect(trace).toContainText("Equal-width sequence · not time-scaled"); + await expect(trace.locator(".trace-order-scroll")).toHaveCount(1); + await expect(trace).toContainText("Core reports Turn wall-clock time, but not per-item timing"); + await expect(trace).toContainText("Configured instructions"); + await expect(trace).toContainText("Completed Turn output remains in the conversation."); + await expect(trace).not.toContainText("TTFT"); + await expect(trace).not.toContainText("Throughput"); + + const search = trace.getByRole("searchbox", { name: "Search trace" }); + await search.fill("Persisted input failed"); + await expect(trace.locator(".trace-ledger-row")).toHaveCount(1); + await expect(trace).toContainText("Persisted input before the Turn failed."); + const messageDuration = trace.locator(".trace-ledger-row").filter({ hasText: "Persisted input before the Turn failed." }).locator(".trace-row-duration"); + await expect(messageDuration.locator('[aria-hidden="true"]')).toHaveText("—"); + await expect(messageDuration).toHaveAttribute("title", "Core does not provide per-item timing."); + await expect(messageDuration.locator(".trace-visually-hidden")).toHaveText("Per-item timing not provided by Core."); + await search.fill(""); + + const patchRow = trace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await expect(patchRow.locator('.trace-row-duration [aria-hidden="true"]')).toHaveText("41 ms"); + await patchRow.click(); + const detail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(detail).toBeVisible(); + await expect(detail.getByRole("button", { name: "Close trace details" })).toBeFocused(); + const previewTab = detail.getByRole("tab", { name: "Preview" }); + await expect(previewTab).toHaveAttribute("aria-controls", "session-trace-panel-detail-content"); + await previewTab.click(); + await expect(detail.getByRole("tabpanel", { name: "Preview" })).toHaveAttribute("tabindex", "0"); + const viewer = detail.getByRole("region", { name: "Parsar apply patch diff" }); + await expect(viewer).toContainText("3 files"); + await expect(viewer).toContainText("Completed"); + const desktopSplit = await Promise.all([ + trace.locator(".trace-ledger").boundingBox(), + detail.boundingBox(), + ]); + expect(desktopSplit[0]).not.toBeNull(); + expect(desktopSplit[1]).not.toBeNull(); + expect(desktopSplit[1]!.x).toBeGreaterThanOrEqual(desktopSplit[0]!.x + desktopSplit[0]!.width - 1); + await attachScreenshot(page, testInfo, "desktop-trace-detail"); + await page.keyboard.press("Escape"); + await expect(detail).toHaveCount(0); + await expect(patchRow).toBeFocused(); + + await patchRow.click(); + await search.fill("filter selected row out"); + await expect(patchRow).toBeHidden(); + await page.getByRole("button", { name: "Close trace details" }).click(); + await expect(search).toBeFocused(); + await search.fill(""); + + await conversationTab.click(); + await expect(composer).toBeVisible(); + await expect(composer).toHaveValue("Draft survives Trace inspection\nwith a second line"); + const sendsAfter = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + expect(sendsAfter).toBe(sendsBefore); + + await page.setViewportSize({ width: 390, height: 844 }); + await traceTab.click(); + const narrowTrace = page.getByRole("tabpanel", { name: "Trace" }); + const narrowPatchRow = narrowTrace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await narrowPatchRow.click(); + const narrowDetail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(narrowDetail).toBeVisible(); + await expect(narrowTrace.locator(".trace-ledger")).toBeHidden(); + const widths = await narrowDetail.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.left).toBeGreaterThanOrEqual(0); + expect(widths.right).toBeLessThanOrEqual(widths.viewport); + await attachScreenshot(page, testInfo, "narrow-trace-detail"); +}); + +test("drops a delayed Turn page after switching Sessions", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsRetrieveDelayMs: 700, + turnsPageSize: 2, + }); + await openSessionsFromHome(page); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible({ timeout: 1_500 }); + await page.getByRole("tab", { name: "Trace" }).click(); + const diagnostics = page.locator("details.trace-turn-diagnostics"); + await diagnostics.locator("summary").click(); + const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); + await expect(timeline).toContainText("Loading every Turn page"); + await page.getByRole("button", { name: "Agents" }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await startSessionWithSecondAgent(page); + + await page.getByRole("tab", { name: "Trace" }).click(); + const nextDiagnostics = page.locator("details.trace-turn-diagnostics"); + await nextDiagnostics.locator("summary").click(); + const nextTimeline = nextDiagnostics.getByRole("region", { name: "Turn timeline" }); + await expect(nextTimeline).toContainText("No Turns reported yet."); + await page.waitForTimeout(3_000); + await expect(nextTimeline).not.toContainText("turn_queued"); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); + + const turnReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/turns") + )); + expect(turnReads.some((entry) => entry.path.includes("session_snapshot"))).toBe(true); + expect(turnReads.some((entry) => entry.path.includes("session_created_"))).toBe(true); +}); + +test("renders Parsar patches as accessible read-only diffs in desktop and narrow themes", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { itemsScenario: 1 }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + + const completedTrace = page.locator('[data-work-trace="completed"]'); + await completedTrace.getByRole("button", { name: /Completed/ }).click(); + const completedStep = completedTrace.locator('[data-trace-step="patch_completed"]'); + await completedStep.getByRole("button", { name: /apply_patch/ }).click(); + const viewer = completedStep.getByRole("region", { name: "Parsar apply patch diff" }); + await expect(viewer).toContainText("3 files"); + await expect(viewer).toContainText("Completed"); + await expect(viewer.getByRole("button", { name: /modify src\/modify.ts/ })).toHaveAttribute("aria-expanded", "true"); + await viewer.getByRole("button", { name: /modify src\/modify.ts/ }).focus(); + await page.keyboard.press("Enter"); + await expect(viewer.getByRole("button", { name: /modify src\/modify.ts/ })).toHaveAttribute("aria-expanded", "false"); + await viewer.getByText("Raw arguments").click(); + await viewer.getByText("Raw result").click(); + await expect(viewer).toContainText('"applied": true'); + await expect(viewer.locator("script")).toHaveCount(0); + const runningStep = page.locator('[data-trace-step="patch_running"]'); + await runningStep.getByRole("button", { name: /apply_patch/ }).click(); + await expect(runningStep.locator('[data-patch-status="in_progress"]')).toContainText("In progress"); + const failedTrace = page.locator('[data-work-trace="failed"]'); + await failedTrace.getByRole("button", { name: /Failed/ }).click(); + const failedStep = failedTrace.locator('[data-trace-step="patch_failed"]'); + await failedStep.getByRole("button", { name: /apply_patch/ }).click(); + await expect(failedStep.locator('[data-patch-status="failed"]')).toContainText("Failed"); + const fallback = page.locator('[data-trace-step="patch_alternate"]'); + await fallback.getByRole("button", { name: /apply_patch/ }).click(); + await expect(fallback).toContainText("malformed alternate shape"); + await expect(fallback.getByRole("region", { name: "Parsar apply patch diff" })).toHaveCount(0); + await attachScreenshot(page, testInfo, "desktop-light-parsar-diff"); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "Dark theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + const widths = await viewer.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { viewport: innerWidth, document: document.documentElement.scrollWidth, body: document.body.scrollWidth, viewerLeft: box.left, viewerRight: box.right }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.viewerLeft).toBeGreaterThanOrEqual(0); + expect(widths.viewerRight).toBeLessThanOrEqual(widths.viewport); + await expect(viewer).toBeVisible(); + await attachScreenshot(page, testInfo, "narrow-dark-parsar-diff"); +}); + +test("manually retries uncertain sends with the original key only while the payload is unchanged", async ({ page, request }, testInfo) => { + await resetFixture(request); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const composer = page.getByLabel("Message the Agent"); + + await controlFixture(request, { sendResponseLoss: 1 }); + await composer.fill("uncertain payload"); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.locator(".session-send-error")).toContainText("may have accepted this message"); + await expect(composer).toHaveValue("uncertain payload"); + await attachScreenshot(page, testInfo, "desktop-uncertain-send"); + await page.getByRole("button", { name: "Send message" }).click(); + + let sends = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(sends).toHaveLength(2); + expect(sends[0]?.idempotencyKey).toBeTruthy(); + expect(sends[1]?.idempotencyKey).toBe(sends[0]?.idempotencyKey); + + await controlFixture(request, { sendResponseLoss: 1 }); + await composer.fill("original before edit"); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(composer).toHaveValue("original before edit"); + await composer.fill("edited payload"); + await page.getByRole("button", { name: "Send message" }).click(); + + sends = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(sends).toHaveLength(4); + expect(sends[3]?.idempotencyKey).not.toBe(sends[2]?.idempotencyKey); + + await controlFixture(request, { sendStatus: 422 }); + await composer.fill("permanently rejected"); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.locator(".session-send-error")).toContainText("Agent Core rejected the message"); + await page.getByRole("button", { name: "Send message" }).click(); + + sends = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + expect(sends).toHaveLength(6); + expect(sends[5]?.idempotencyKey).not.toBe(sends[4]?.idempotencyKey); + await attachScreenshot(page, testInfo, "desktop-send-recovery"); +}); + +test("reuses Function result identity only for an unchanged uncertain explicit retry", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 10 }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).toBeVisible(); + const editor = page.getByLabel("Function result or error"); + const submit = page.getByRole("button", { name: "Submit result" }); + const eventWrites = async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + + await editor.fill("uncertain result"); + await controlFixture(request, { sendResponseLoss: 1 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await expect(editor).toHaveValue("uncertain result"); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(2); + let writes = await eventWrites(); + expect(writes[0]?.body).toEqual(writes[1]?.body); + expect(writes[0]?.idempotencyKey).toBeTruthy(); + expect(writes[1]?.idempotencyKey).toBe(writes[0]?.idempotencyKey); + + await editor.fill("before edit"); + await controlFixture(request, { sendResponseLoss: 1 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await editor.fill("after edit"); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(4); + writes = await eventWrites(); + expect(writes[3]?.idempotencyKey).not.toBe(writes[2]?.idempotencyKey); + + await editor.fill("definite rejection"); + await controlFixture(request, { sendStatus: 422 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(6); + writes = await eventWrites(); + expect(writes[5]?.idempotencyKey).not.toBe(writes[4]?.idempotencyKey); +}); + +test("keeps cancellation available for an Environment-only required action", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 6 }); + await openSessionsFromHome(page); + await expect(connectedLiveEvents(page)).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 controlFixture(request, { sendResponseLoss: 1 }); + await cancel.click(); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(writesBefore + 1); + 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 + 2); + 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" }] }); + expect(writes.at(-2)?.idempotencyKey).toBeTruthy(); + expect(writes.at(-1)?.idempotencyKey).toBe(writes.at(-2)?.idempotencyKey); +}); diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts new file mode 100644 index 000000000..e357bcd74 --- /dev/null +++ b/apps/web/e2e/core-connection.spec.ts @@ -0,0 +1,456 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +const probeRequestPattern = /\/v1\/agents\?limit=1$/; + +interface ProbeInstrumentationWindow extends Window { + __probeAbortCount?: number; + __probeCallCount?: number; + __resolveFirstProbe?: (() => void) | null; + __stalledProbeCallCount?: number; +} + +async function resetFixture(request: APIRequestContext) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function boot(page: Page, request: APIRequestContext) { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toBeVisible(); +} + +async function openConnection(page: Page) { + const trigger = page.getByRole("button", { name: "Configure Agent Core connection" }); + await trigger.click(); + const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(dialog).toBeVisible(); + return { dialog, trigger }; +} + +test("migrates a stale local token without sending browser authorization when proxy auth is disabled", async ({ + page, + request, +}) => { + await page.addInitScript(() => { + localStorage.setItem("agents-core-web.core-base-url", "/v1"); + sessionStorage.setItem("agents-core-web.core-token", "historical-local-token"); + }); + let browserAuthorizationSeen = false; + page.on("request", (browserRequest) => { + const url = new URL(browserRequest.url()); + if (url.pathname.startsWith("/v1/") && browserRequest.headers().authorization) { + browserAuthorizationSeen = true; + } + }); + await page.route(probeRequestPattern, (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ object: "list", data: [], has_more: false, first_id: null, last_id: null }), + })); + + await boot(page, request); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); + const { dialog } = await openConnection(page); + await expect(dialog).toContainText("Server-managed key not detected"); + await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); + await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); + await dialog.getByRole("button", { name: "Test connection" }).click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + expect(browserAuthorizationSeen).toBe(false); + + await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); + await expect(dialog.getByLabel("Bearer token")).toHaveValue(""); +}); + +test("scrubs a legacy remote HTTP connection before any bearer can leave the page", async ({ page, request }) => { + await page.addInitScript(() => { + localStorage.setItem("agents-core-web.core-base-url", "http://core.example/v1"); + sessionStorage.setItem("agents-core-web.core-token", "legacy-remote-token"); + }); + let unsafeRequests = 0; + await page.route("http://core.example/**", (route) => { + unsafeRequests += 1; + return route.abort("blockedbyclient"); + }); + + await boot(page, request); + + await expect.poll(() => page.evaluate(() => localStorage.getItem("agents-core-web.core-base-url"))).toBe("/v1"); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); + expect(unsafeRequests).toBe(0); + const { dialog } = await openConnection(page); + await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); +}); + +test("rejects an unsafe runtime target before a local proxy write can be attempted", async ({ page, request }) => { + await boot(page, request); + + const outcome = await page.evaluate(async () => { + const modulePath = "/src/lib/connection.ts"; + const { createCore } = await import(/* @vite-ignore */ modulePath); + const originalFetch = window.fetch; + const calls: Array<{ method: string; url: string }> = []; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + method: init?.method ?? "GET", + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + }); + return new Response(JSON.stringify({ id: "must-not-be-created" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + try { + const core = createCore({ + baseUrl: "http://core.example/v1", + token: "must-not-leave-browser", + }); + await core.createAgent({ model: "must-not-create-locally" }); + return { calls, error: null }; + } catch (error) { + return { + calls, + error: error instanceof Error + ? { + code: "code" in error ? String(error.code) : null, + message: error.message, + name: error.name, + } + : null, + }; + } finally { + window.fetch = originalFetch; + } + }); + + expect(outcome.calls).toEqual([]); + expect(outcome.error).toEqual({ + code: "invalid_core_base_url", + message: "The Agent Core base URL is invalid or unsafe.", + name: "InvalidCoreConnectionError", + }); + expect(JSON.stringify(outcome)).not.toContain("must-not-leave-browser"); + expect(JSON.stringify(outcome)).not.toContain("http://core.example/v1"); +}); + +test("switches real connection modes and fences stale probes when the draft changes or reopens", async ({ + page, + request, +}) => { + await page.addInitScript(() => { + const target = window as ProbeInstrumentationWindow; + const originalFetch = window.fetch.bind(window); + let holdFirstProbe = true; + target.__probeAbortCount = 0; + target.__probeCallCount = 0; + target.__resolveFirstProbe = null; + + window.fetch = (input, init) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const candidate = new URL(url, window.location.href); + const isProbe = candidate.pathname.endsWith("/v1/agents") + && candidate.searchParams.get("limit") === "1" + && [...candidate.searchParams].length === 1; + if (isProbe) target.__probeCallCount = (target.__probeCallCount ?? 0) + 1; + if (holdFirstProbe && isProbe) { + holdFirstProbe = false; + init?.signal?.addEventListener("abort", () => { + target.__probeAbortCount = (target.__probeAbortCount ?? 0) + 1; + }, { once: true }); + return new Promise((resolve) => { + target.__resolveFirstProbe = () => resolve(new Response(JSON.stringify({ + object: "list", + data: [], + has_more: false, + first_id: null, + last_id: null, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }); + } + return originalFetch(input, init); + }; + }); + await boot(page, request); + const { dialog } = await openConnection(page); + const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); + const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); + const testConnection = dialog.getByRole("button", { name: "Test connection" }); + + await expect(local).toBeChecked(); + await expect(dialog.getByLabel("Compatible Core base URL")).toHaveCount(0); + await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__probeCallCount ?? 0 + ))).toBe(1); + await expect.poll(() => page.evaluate(() => ( + typeof (window as ProbeInstrumentationWindow).__resolveFirstProbe + ))).toBe("function"); + + await advanced.click(); + await expect(advanced).toBeChecked(); + await expect(dialog.getByRole("status")).toHaveCount(0); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__probeAbortCount ?? 0 + ))).toBe(1); + await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveFirstProbe?.()); + await expect(dialog.getByText("Core API authenticated")).toHaveCount(0); + + const directBase = `${new URL(page.url()).origin}/v1`; + const baseUrl = dialog.getByLabel("Compatible Core base URL"); + const token = dialog.getByLabel("Bearer token"); + for (const loopback of [ + "http://localhost:8091/v1", + "http://worker.localhost:8091/v1", + "http://127.0.0.42:8091/v1", + "http://[::1]:8091/v1", + ]) { + await baseUrl.fill(loopback); + await expect(testConnection).toBeEnabled(); + } + for (const unsafe of [ + "http://core.example/v1", + `${directBase}?`, + `${directBase}#`, + ]) { + await baseUrl.fill(unsafe); + await expect(dialog.getByText(/Enter an HTTPS URL, or an HTTP loopback URL/)).toBeVisible(); + await expect(testConnection).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "Apply connection" })).toBeDisabled(); + } + expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__probeCallCount)).toBe(1); + + await baseUrl.fill(directBase); + await token.fill("current-tab-token"); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await token.fill("changed-current-tab-token"); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await baseUrl.fill(`${directBase}/`); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await local.click(); + await expect(local).toBeChecked(); + await expect(dialog.getByRole("status")).toHaveCount(0); + await testConnection.click(); + await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); + const reopened = (await openConnection(page)).dialog; + await expect(reopened.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); + await expect(reopened.getByRole("status")).toHaveCount(0); + await expect(reopened.getByRole("alert")).toHaveCount(0); +}); + +test("announces loading, authenticated access, and each safe failure state from one GET", async ({ + page, + request, +}) => { + const replies = [ + { + status: 200, + body: { + object: "list", + data: [{ + id: "agent_empty_model", + object: "agent", + model: "", + name: null, + instructions: null, + metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + created_at: 1_700_000_000, + updated_at: 1_700_000_001, + }], + has_more: false, + first_id: "agent_empty_model", + last_id: "agent_empty_model", + }, + }, + { status: 202, body: { object: "list", data: [], has_more: false, first_id: null, last_id: null } }, + { status: 200, body: { object: "list", data: [null], has_more: false, first_id: null, last_id: null } }, + { status: 401, body: { error: { code: "invalid_api_key", message: "safe fixture failure" } } }, + { status: 401, body: { error: { code: "gateway_auth_required", message: "safe fixture failure" } } }, + { status: 400, body: { error: { code: "invalid_beta_header", message: "safe fixture failure" } } }, + { status: 503, body: { error: { code: "unavailable", message: "safe fixture failure" } } }, + { abort: true }, + ]; + const methods: string[] = []; + + await page.route(probeRequestPattern, async (route) => { + methods.push(route.request().method()); + const reply = replies.shift(); + if (!reply) return route.abort("failed"); + await new Promise((resolve) => setTimeout(resolve, 150)); + if ("abort" in reply) return route.abort("failed"); + return route.fulfill({ + status: reply.status, + contentType: "application/json", + body: JSON.stringify(reply.body), + }); + }); + + await boot(page, request); + const { dialog } = await openConnection(page); + const action = dialog.getByRole("button", { name: "Test connection" }); + const cases: Array<{ role: "status" | "alert"; text: string; absentText?: string }> = [ + { role: "status", text: "Core API authenticated" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Authentication failed" }, + { role: "alert", text: "Core returned HTTP 401", absentText: "invalid_api_key" }, + { role: "alert", text: "Agents API protocol mismatch" }, + { role: "alert", text: "Core returned HTTP 503" }, + { role: "alert", text: "Core unreachable" }, + ]; + + for (const expected of cases) { + await action.click(); + const loading = dialog.getByRole("status"); + await expect(loading).toHaveAttribute("aria-live", "polite"); + await expect(loading).toContainText("Testing Core connection…"); + const terminal = dialog.getByRole(expected.role); + await expect(terminal).toContainText(expected.text); + 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); + } + + expect(methods).toEqual(["GET", "GET", "GET", "GET", "GET", "GET", "GET", "GET"]); + expect(replies).toHaveLength(0); +}); + +test("turns a stalled probe into one bounded unreachable result", async ({ page, request }) => { + await page.addInitScript(() => { + const target = window as ProbeInstrumentationWindow; + const originalFetch = window.fetch.bind(window); + target.__stalledProbeCallCount = 0; + 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); + const stalledProbe = candidate.pathname.endsWith("/v1/agents") + && candidate.searchParams.get("limit") === "1"; + if (!stalledProbe) return originalFetch(input, init); + target.__stalledProbeCallCount = (target.__stalledProbeCallCount ?? 0) + 1; + return new Promise((_resolve, reject) => { + const rejectAbort = () => reject(init?.signal?.reason ?? new DOMException("Aborted", "AbortError")); + if (init?.signal?.aborted) rejectAbort(); + else init?.signal?.addEventListener("abort", rejectAbort, { once: true }); + }); + }; + }); + + await boot(page, request); + const { dialog } = await openConnection(page); + await dialog.getByRole("button", { name: "Test connection" }).click(); + await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); + await expect.poll(() => page.evaluate(() => ( + (window as ProbeInstrumentationWindow).__stalledProbeCallCount ?? 0 + ))).toBe(1); + await expect(dialog.getByRole("alert")).toContainText("Core unreachable", { timeout: 7_500 }); + 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); +}); + +test("supports keyboard mode selection, traps focus, and returns focus on Escape", async ({ page, request }) => { + await boot(page, request); + const { dialog, trigger } = await openConnection(page); + const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); + const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); + const apply = dialog.getByRole("button", { name: "Apply connection" }); + const close = dialog.getByRole("button", { name: "Close dialog" }); + + await expect(local).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(advanced).toBeChecked(); + await expect(advanced).toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(local).toBeChecked(); + await expect(local).toBeFocused(); + + await close.focus(); + await page.keyboard.press("Shift+Tab"); + await expect(apply).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(close).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test("keeps both connection modes operable without horizontal overflow at 390 px", async ({ page, request }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await boot(page, request); + const { dialog } = await openConnection(page); + const apply = dialog.getByRole("button", { name: "Apply connection" }); + const cancel = dialog.getByRole("button", { name: "Cancel" }); + + const assertContained = async () => { + const metrics = await page.evaluate(() => ({ + viewportWidth: innerWidth, + viewportHeight: innerHeight, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + })); + const box = await dialog.boundingBox(); + expect(box).not.toBeNull(); + expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.bodyWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(box?.x ?? -1).toBeGreaterThanOrEqual(0); + expect(box?.y ?? -1).toBeGreaterThanOrEqual(0); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(metrics.viewportWidth); + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(metrics.viewportHeight); + const widths = await dialog.evaluate((card) => { + const body = card.querySelector(".modal-body"); + return { + cardClient: card.clientWidth, + cardScroll: card.scrollWidth, + bodyClient: body?.clientWidth ?? 0, + bodyScroll: body?.scrollWidth ?? 0, + }; + }); + expect(widths.cardScroll).toBeLessThanOrEqual(widths.cardClient); + expect(widths.bodyScroll).toBeLessThanOrEqual(widths.bodyClient); + await expect(apply).toBeInViewport(); + await expect(cancel).toBeInViewport(); + }; + + await assertContained(); + const testConnection = dialog.getByRole("button", { name: "Test connection" }); + await testConnection.scrollIntoViewIfNeeded(); + await expect(testConnection).toBeInViewport(); + + await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); + await dialog.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); + await assertContained(); + await dialog.getByLabel("Compatible Core base URL").scrollIntoViewIfNeeded(); + await expect(dialog.getByLabel("Compatible Core base URL")).toBeInViewport(); +}); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs new file mode 100644 index 000000000..5aebe44dd --- /dev/null +++ b/apps/web/e2e/fixture-core.mjs @@ -0,0 +1,1648 @@ +import http from "node:http"; + +const host = "127.0.0.1"; +const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); +const baseline = 1_789_438_800; +const canonicalEnvironmentUuid = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; +const hostedEnvironmentUuid = "7a263c51-6bf0-4d53-8518-c792eb1f0d21"; +const sourceFileUuid = "16e1f26e-8cf6-4272-9c31-d470b08d31af"; +const canonicalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +function patchItems() { + const longLine = `+export const longValue = "${"x".repeat(2_000)}";`; + return [ + { id: "patch_intro", turn_id: "turn_patch_intro", type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: "Review the fixture patch" }] }, + { + id: "patch_completed", turn_id: "turn_patch_completed", type: "function_call", status: "completed", name: "apply_patch", call_id: "call_completed", + arguments: { changes: [ + { path: "src/.ts", kind: { type: "add", move_path: null }, diff: `--- /dev/null\n+++ b/src/.ts\n@@ -0,0 +1,2 @@\n+\n${longLine}` }, + { path: "src/modify.ts", kind: { type: "update", move_path: null }, diff: "@@ -1 +1 @@\n-old value\n+new value" }, + { path: "src/delete.ts", kind: { type: "delete", move_path: null }, diff: "@@ -1 +0,0 @@\n-removed" }, + ] }, + }, + { id: "patch_output", turn_id: "turn_patch_completed", type: "function_call_output", status: "completed", call_id: "call_completed", output: { applied: true }, duration_ms: 41 }, + { id: "patch_running", turn_id: "turn_patch_running", type: "function_call", status: "in_progress", name: "apply_patch", call_id: "call_running", arguments: { changes: [{ path: "src/running.ts", kind: { type: "update" }, diff: "@@ -1 +1 @@\n-wait\n+working" }] } }, + { id: "patch_failed", turn_id: "turn_patch_failed", type: "function_call", status: "failed", name: "apply_patch", call_id: "call_failed", arguments: { changes: [{ path: "src/failed.ts", kind: { type: "delete" }, diff: "@@ -1 +0,0 @@\n-failed" }] }, error: { message: "fixture failure" } }, + { id: "patch_alternate", turn_id: "turn_patch_alternate", type: "function_call", status: "in_progress", name: "apply_patch", call_id: "call_alternate", arguments: { patch: "*** Begin Patch\nmalformed alternate shape" } }, + ]; +} + +function observableTurns() { + return [ + { id: "turn_queued", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "queued", created_at: baseline - 18, started_at: null, completed_at: null, error: null, usage: null }, + { id: "turn_in_progress", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "in_progress", created_at: baseline - 17, started_at: baseline - 16, completed_at: null, error: null, usage: null }, + { id: "turn_waiting", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "waiting", created_at: baseline - 15, started_at: baseline - 14, completed_at: null, error: null, usage: null }, + { id: "turn_completed", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "completed", created_at: baseline - 13, started_at: baseline - 12, completed_at: baseline - 5, error: null, usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13, input_tokens_details: { cached_tokens: 4 }, output_tokens_details: { reasoning_tokens: 2 } } }, + { id: "turn_failed", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "failed", created_at: baseline - 4, started_at: baseline - 3, completed_at: baseline - 2, error: { code: "internal_error", message: "The execution could not complete." }, usage: null }, + { id: "turn_cancelled", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "cancelled", created_at: baseline - 1, started_at: baseline, completed_at: baseline + 1, error: null, usage: null }, + { id: "turn_terminal_refresh", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "in_progress", created_at: baseline + 2, started_at: baseline + 3, completed_at: null, error: null, usage: null }, + ]; +} + +function observableTurnItems() { + return [ + { id: "turn_message", turn_id: "turn_completed", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: "Completed Turn output remains in the conversation." }] }, + { id: "failed_input", turn_id: "turn_failed", type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: "Persisted input before the Turn failed." }] }, + { id: "unassociated", turn_id: "turn_not_loaded", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: "This Item is waiting for its Turn page." }] }, + ]; +} + +function savedAgent(id, name, model, updatedAt) { + return { + id, + object: "agent", + model, + name, + instructions: `Instructions for ${name}`, + metadata: { team: "web", fixture: "safe" }, + multi_agent: { enabled: id === "agent_a", max_concurrent_subagents: id === "agent_a" ? 2 : null }, + reasoning: id === "agent_a" ? { effort: "high", summary: "concise" } : {}, + service_tier: id === "agent_a" ? "priority" : "auto", + text: { format: { type: "text" }, verbosity: id === "agent_a" ? "high" : "medium" }, + tools: id === "agent_a" ? [{ type: "tool_search" }] : [], + created_at: baseline - 600, + updated_at: 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)) { + 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 sessionVaultError(agent, vaultIds) { + if (!Array.isArray(vaultIds) || vaultIds.some((id) => typeof id !== "string" || !canonicalUuidPattern.test(id))) { + return "Fixture Session Vault attachments are invalid."; + } + const attached = new Set(vaultIds); + if (attached.size !== vaultIds.length || [...attached].some((id) => !state.vaults.some((vault) => vault.id === id))) { + return "Fixture Session Vault attachments are unavailable."; + } + for (const tool of agent.tools) { + if (tool.type !== "mcp") continue; + const matching = state.credentials.filter((credential) => ( + attached.has(credential.vault_id) && credential.auth.mcp_server_url === tool.transport.server_url + )); + if (typeof tool.credential_id === "string") { + const selected = matching.find((credential) => credential.id === tool.credential_id); + if (!selected || !state.credentialTokens.has(selected.id)) return "Fixture explicit MCP Credential is unavailable."; + } else if (matching.length > 1) { + return "Fixture anonymous MCP selection is ambiguous."; + } + } + return null; +} + +function sessionSnapshot(agent) { + const { object: _object, metadata: _metadata, created_at: _created, updated_at: _updated, ...snapshot } = agent; + return snapshot; +} + +function sessionEffectiveAgent(saved, override) { + if (override === undefined) return saved; + if (!isRecord(override) || !hasOnlyKeys(override, [ + "model", "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools", + ])) return null; + if (Object.hasOwn(override, "model") && typeof override.model !== "string") return null; + const effective = { ...saved }; + if (Object.hasOwn(override, "model")) effective.model = override.model; + if (Object.hasOwn(override, "instructions")) { + if (override.instructions !== null && typeof override.instructions !== "string") return null; + effective.instructions = override.instructions; + } + if (Object.hasOwn(override, "multi_agent")) { + if (override.multi_agent === null) effective.multi_agent = { enabled: false, max_concurrent_subagents: null }; + else if (isRecord(override.multi_agent) && typeof override.multi_agent.enabled === "boolean") { + effective.multi_agent = { + enabled: override.multi_agent.enabled, + max_concurrent_subagents: override.multi_agent.enabled + ? override.multi_agent.max_concurrent_subagents ?? 6 + : null, + }; + } else return null; + } + if (Object.hasOwn(override, "reasoning")) { + if (override.reasoning !== null && !isRecord(override.reasoning)) return null; + effective.reasoning = override.reasoning ?? {}; + } + if (Object.hasOwn(override, "service_tier")) { + if (override.service_tier !== null && typeof override.service_tier !== "string") return null; + effective.service_tier = override.service_tier ?? "auto"; + } + if (Object.hasOwn(override, "text")) { + if (override.text !== null && !isRecord(override.text)) return null; + effective.text = { + format: override.text?.format ?? { type: "text" }, + verbosity: override.text?.verbosity ?? "medium", + }; + } + if (Object.hasOwn(override, "tools")) { + if (override.tools !== null && !Array.isArray(override.tools)) return null; + effective.tools = override.tools ?? []; + } + return effective; +} + +function sessionInlineAgent(input, id) { + if ( + !isRecord(input) + || !hasOnlyKeys(input, ["model", "instructions", "tools"]) + || typeof input.model !== "string" + || /^\p{White_Space}*$/u.test(input.model) + || input.model !== input.model.trim() + || (Object.hasOwn(input, "instructions") + && (typeof input.instructions !== "string" + || /^\p{White_Space}*$/u.test(input.instructions) + || input.instructions !== input.instructions.trim())) + || (Object.hasOwn(input, "tools") && !Array.isArray(input.tools)) + ) return null; + return { + id, + model: input.model, + name: null, + instructions: input.instructions ?? null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: input.tools ?? [], + }; +} + +function sessionInitialInputMessages(input) { + if (typeof input === "string") { + return /^\p{White_Space}*$/u.test(input) + ? null + : [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }]; + } + if (!Array.isArray(input) || input.length === 0) return null; + + const messages = []; + for (const message of input) { + if ( + !isRecord(message) + || !hasOnlyKeys(message, ["type", "role", "content"]) + || (message.type !== undefined && message.type !== "message") + || message.role !== "user" + || !Array.isArray(message.content) + || message.content.length === 0 + ) return null; + const content = []; + for (const part of message.content) { + if ( + !isRecord(part) + || !hasOnlyKeys(part, ["type", "text"]) + || part.type !== "input_text" + || typeof part.text !== "string" + ) return null; + content.push({ type: "input_text", text: part.text }); + } + if (/^\p{White_Space}*$/u.test(content.map((part) => part.text).join(""))) return null; + messages.push({ type: "message", role: "user", content }); + } + return messages; +} + +function sessionEnvironmentResponse(environment) { + if (!isRecord(environment) || typeof environment.type !== "string") return null; + if (environment.type === "none") { + return hasOnlyKeys(environment, ["type"]) ? { type: "none" } : null; + } + if (environment.type === "openai_hosted") { + if (!hasOnlyKeys(environment, ["type", "network", "environment_template_id"])) return null; + let access = "enabled"; + let template = null; + if (environment.environment_template_id !== undefined) { + template = state.environmentTemplates.find((entry) => entry.id === environment.environment_template_id); + // A referenced Template must exist, and an explicit null network is rejected + // instead of guessing inheritance. + if (!template || environment.network === null) return null; + access = template.network.access; + } + if (environment.network !== undefined && environment.network !== null) { + if ( + !isRecord(environment.network) || + !hasOnlyKeys(environment.network, ["access"]) || + (environment.network.access !== "enabled" && environment.network.access !== "disabled") + ) return null; + // A Session keeps or narrows the Template policy and never widens it. + if (template && template.network.access === "disabled" && environment.network.access === "enabled") return null; + access = environment.network.access; + } + return { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access, allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + } + if ( + environment.type !== "self_hosted" + || !hasOnlyKeys(environment, ["type", "workspace_directory", "capability_directories"]) + ) return null; + + const workspace = environment.workspace_directory; + const capabilities = environment.capability_directories; + if ( + typeof workspace !== "string" + || !workspace + || workspace.startsWith("~") + || !workspace.startsWith("/") + || /[\0\r\n\\]/u.test(workspace) + || capabilities !== undefined && capabilities !== null + && (!Array.isArray(capabilities) || capabilities.length !== 0) + ) return null; + + return { + type: "self_hosted", + id: canonicalEnvironmentUuid, + remote_url: "https://executor.example.test", + workspace_directory: workspace, + capability_directories: [], + }; +} + +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, savedOnlyTool], + vaults: [], + credentials: [], + credentialTokens: new Set(), + sessions: [{ + id: "session_snapshot", + object: "agent.session", + agent: sessionSnapshot(first), + environment: { type: "none" }, + status: "idle", + error: null, + metadata: { fixture: "durable-agent-snapshot" }, + required_actions: [], + vault_ids: [], + usage: null, + created_at: baseline - 20, + last_active_at: baseline - 10, + }], + turns: [], + createdSessionItems: new Map(), + requests: [], + sourceFiles: new Map(), + hostedWorkspaceFiles: [], + environmentTemplates: [], + sessionCreateReceipts: new Map(), + controls: { + createAgentResponseVariant: "valid", + sessionCreateDelayMs: 0, + sessionCreateStatus: 201, + sessionCreateResponseLoss: 0, + sessionCreateStreamCloseDelayMs: 120, + sessionListDelayMs: 0, + sessionListStatus: 200, + sessionListPageSize: 100, + retrieveDelayMs: 0, + retrieveStatus: 200, + updateDelayMs: 0, + updateStatus: 200, + deleteDelayMs: 0, + deleteStatus: 200, + sendStatus: 204, + sendResponseLoss: 0, + itemsScenario: 0, + turnsScenario: 0, + turnsRetrieveDelayMs: 0, + turnsRetrieveStatus: 200, + turnsPageSize: 2, + environmentScenario: 0, + environmentRetrieveDelayMs: 0, + environmentRetrieveStatus: 200, + environmentFilesDelayMs: 0, + environmentFilesStatus: 200, + environmentFileCreateStatus: 200, + environmentFileCreateResponseLoss: 0, + environmentTemplateListStatus: 200, + environmentTemplateCreateStatus: 200, + environmentResourceStatus: "pending", + environmentResourceVariant: "valid", + environmentEventStatus: 0, + environmentEventCount: 0, + streamStatus: 200, + streamOpenDelayMs: 0, + streamCloseCount: 0, + streamCloseDelayMs: 30, + sessionRetrieveDelayMs: 0, + sessionRetrieveStatus: 200, + sessionRetrieveVariant: "valid", + sessionUpdateDelayMs: 0, + sessionUpdateStatus: 200, + sessionUpdateResponseLoss: 0, + sessionDeleteDelayMs: 0, + sessionDeleteStatus: 200, + sessionDeleteResponseLoss: 0, + sessionDeleteStreamCloseDelayMs: 0, + itemsRetrieveDelayMs: 0, + itemsRetrieveStatus: 200, + sourceUploadStatus: 200, + sourceUploadResponseLoss: 0, + sourceDeleteStatus: 200, + sourceDeleteResponseLoss: 0, + }, + aborts: { + sessionListReads: 0, + sessionReads: 0, + itemReads: 0, + turnReads: 0, + streams: 0, + environmentFileReads: 0, + }, + sequence: 0, + }; +} + +function applyTurnsScenario(value) { + const session = state.sessions[0]; + if (!session) return; + if (value === 1) { + state.turns = observableTurns(); + session.usage = { + input_tokens: 20, + output_tokens: 6, + total_tokens: 26, + input_tokens_details: { cached_tokens: 8 }, + output_tokens_details: { reasoning_tokens: 4 }, + }; + return; + } + state.turns = []; + session.usage = null; +} + +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 === 10) { + session.environment = { + type: "self_hosted", + id: "environment_fixture", + remote_url: hostileRemote, + workspace_directory: `/workspace//${"long/".repeat(45)}project`, + capability_directories: ["/capabilities/read-only", `/capabilities/${"wide/".repeat(55)}`], + }; + session.status = "requires_action"; + session.required_actions = [ + { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, + ]; + return; + } + if (value === 9) { + session.environment = { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access: "enabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + session.status = "failed"; + session.error = "The environment is no longer available for this input."; + session.required_actions = []; + return; + } + if (value === 8) { + session.environment = { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access: "disabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + session.status = "idle"; + session.required_actions = []; + return; + } + if (value === 7) { + session.environment = { + type: "self_hosted", + id: canonicalEnvironmentUuid, + remote_url: "https://executor.example.test", + workspace_directory: "/executor/workspace", + capability_directories: [], + }; + session.status = "idle"; + session.required_actions = []; + return; + } + if (value === 1 || value === 4 || value === 5 || value === 6) { + session.environment = { + type: "self_hosted", + id: value === 5 ? canonicalEnvironmentUuid.toUpperCase() : "environment_fixture", + remote_url: hostileRemote, + workspace_directory: `/workspace//${"long/".repeat(45)}project`, + capability_directories: ["/capabilities/read-only", `/capabilities/${"wide/".repeat(55)}`], + }; + session.status = value === 1 || value === 6 ? "requires_action" : "idle"; + session.required_actions = value === 1 + ? [{ type: "environment_connection", environment_id: "environment_fixture" }] + : value === 6 + ? [{ type: "environment_connection", environment_id: "environment_fixture" }] + : []; + return; + } + if (value === 2) { + session.environment = { type: "future_remote", remote_url: "javascript:alert(1)", workspace_directory: "/must-not-render" }; + session.status = "idle"; + session.required_actions = []; + return; + } + if (value === 3) { + session.environment = { type: "self_hosted" }; + session.status = "idle"; + session.required_actions = []; + } +} + +let state = initialState(); +const streamResponses = new Map(); + +function emitTurnLifecycle(status) { + const index = state.turns.findIndex((turn) => turn.id === "turn_terminal_refresh"); + const existing = state.turns[index]; + if (!existing || !["completed", "failed", "cancelled"].includes(status)) return false; + const terminal = { + ...existing, + status, + completed_at: baseline + 10, + error: status === "failed" ? { code: "internal_error", message: "The execution could not complete." } : null, + usage: status === "completed" ? { input_tokens: 5, output_tokens: 2, total_tokens: 7, input_tokens_details: { cached_tokens: 1 }, output_tokens_details: { reasoning_tokens: 1 } } : null, + }; + state.turns[index] = terminal; + state.sequence += 1; + const event = `id: turn_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.turn.${status}`, + event_id: `turn_${state.sequence}`, + session_id: "session_snapshot", + turn_id: terminal.id, + turn: terminal, + })}\n\n`; + for (const stream of streamResponses.keys()) stream.write(event); + return true; +} + +function emitSessionLifecycle(status) { + if (!["in_progress", "idle", "failed"].includes(status)) return false; + const session = state.sessions.find((candidate) => candidate.id === "session_snapshot"); + if (!session) return false; + session.status = status; + session.error = status === "failed" ? "The execution could not complete." : null; + session.required_actions = []; + state.sequence += 1; + const event = `id: session_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.${status}`, + event_id: `session_${state.sequence}`, + session_id: session.id, + session, + })}\n\n`; + for (const [stream, sessionId] of streamResponses) { + if (sessionId === session.id) stream.write(event); + } + return true; +} + +function sendJson(response, value, status = 200) { + const body = JSON.stringify(value); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + "cache-control": "no-store", + }); + response.end(body); +} + +function sendError(response, status, message, code = "fixture_failure", type = "fixture_error") { + sendJson(response, { + error: { + code, + type, + message, + }, + }, status); +} + +async function readJson(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (!chunks.length) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function readBuffer(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function readSourceMultipart(request) { + const contentType = request.headers["content-type"] ?? ""; + const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType); + if (!match) return null; + const boundary = match[1] ?? match[2]; + const raw = (await readBuffer(request)).toString("latin1"); + const parts = raw.split(`--${boundary}`).slice(1, -1); + const result = { file: null, filename: null, purpose: null }; + for (const rawPart of parts) { + const part = rawPart.replace(/^\r\n/, "").replace(/\r\n$/, ""); + const separator = part.indexOf("\r\n\r\n"); + if (separator < 0) return null; + const header = part.slice(0, separator); + const body = part.slice(separator + 4); + const name = /\bname="([^"]+)"/i.exec(header)?.[1]; + if (name === "file") { + if (result.file !== null) return null; + result.filename = /\bfilename="([^"]*)"/i.exec(header)?.[1] ?? null; + result.file = Buffer.from(body, "latin1"); + } else if (name === "purpose") { + if (result.purpose !== null) return null; + result.purpose = body; + } else { + return null; + } + } + return result.file !== null && result.filename && result.purpose === "user_data" ? result : null; +} + +function page(data) { + return { + object: "list", + data, + has_more: false, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }; +} + +function queryPage(data, url, maximumPageSize = 100) { + const ordered = url.searchParams.get("order") === "asc" ? [...data].reverse() : [...data]; + const after = url.searchParams.get("after"); + const start = after ? ordered.findIndex((value) => value.id === after) + 1 : 0; + if (after && start === 0) return null; + const requestedLimit = Number(url.searchParams.get("limit") ?? 20); + const size = Math.max(1, Math.min(requestedLimit, maximumPageSize)); + const values = ordered.slice(start, start + size); + return { + object: "list", + data: values, + has_more: start + values.length < ordered.length, + first_id: values[0]?.id ?? null, + last_id: values.at(-1)?.id ?? null, + }; +} + +function recordRequest(request, url, body) { + let safeBody = body; + if ( + request.method === "POST" && + /^\/v1\/vaults\/[^/]+\/credentials(?:\/[^/]+)?$/u.test(url.pathname) && + isRecord(body) && isRecord(body.auth) && Object.hasOwn(body.auth, "token") + ) { + const { token: _token, ...safeAuth } = body.auth; + safeBody = { ...body, auth: { ...safeAuth, token_present: true } }; + } + state.requests.push({ + method: request.method, + path: url.pathname, + query: url.search, + beta: request.headers["openai-beta"] ?? null, + authorizationPresent: Boolean(request.headers.authorization), + idempotencyKeyPresent: Boolean(request.headers["idempotency-key"]), + idempotencyKey: request.headers["idempotency-key"] ?? null, + body: safeBody, + }); +} + +function fixtureUuid(sequence) { + return `10000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; +} + +function consumeControl(prefix, successStatus = 200) { + const delayMs = state.controls[`${prefix}DelayMs`]; + const status = state.controls[`${prefix}Status`]; + state.controls[`${prefix}DelayMs`] = 0; + state.controls[`${prefix}Status`] = successStatus; + return { delayMs, status }; +} + +function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function trackAbort(response, key) { + let finished = false; + response.once("finish", () => { + finished = true; + }); + response.once("close", () => { + if (!finished) state.aborts[key] += 1; + }); +} + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + + if (request.method === "GET" && url.pathname === "/__fixture/health") { + return sendJson(response, { ready: true }); + } + if (request.method === "POST" && url.pathname === "/__fixture/reset") { + for (const stream of streamResponses.keys()) stream.end(); + streamResponses.clear(); + state = initialState(); + return sendJson(response, { reset: true }); + } + if (request.method === "POST" && url.pathname === "/__fixture/control") { + state.controls = { ...state.controls, ...await readJson(request) }; + applyEnvironmentScenario(state.controls.environmentScenario); + applyTurnsScenario(state.controls.turnsScenario); + return sendJson(response, state.controls); + } + if (request.method === "POST" && url.pathname === "/__fixture/emit-turn") { + const input = await readJson(request); + return emitTurnLifecycle(input.status) + ? sendJson(response, { emitted: true }) + : sendError(response, 400, "Fixture terminal Turn is unavailable."); + } + if (request.method === "POST" && url.pathname === "/__fixture/emit-session") { + const input = await readJson(request); + return emitSessionLifecycle(input.status) + ? sendJson(response, { emitted: true }) + : sendError(response, 400, "Fixture Session lifecycle status is unavailable."); + } + if (request.method === "GET" && url.pathname === "/__fixture/requests") { + return sendJson(response, state.requests); + } + if (request.method === "GET" && url.pathname === "/__fixture/state") { + return sendJson(response, { + sessions: state.sessions, + aborts: state.aborts, + openStreams: [...streamResponses.values()], + }); + } + if (request.method === "POST" && url.pathname === "/__fixture/session-metadata") { + const input = await readJson(request); + const target = state.sessions.find((session) => session.id === input.id); + if (!target) return sendError(response, 404, "Fixture Session not found."); + target.metadata = input.metadata; + return sendJson(response, target); + } + if (request.method === "POST" && url.pathname === "/__fixture/remove-session") { + const input = await readJson(request); + const before = state.sessions.length; + state.sessions = state.sessions.filter((session) => session.id !== input.id); + return sendJson(response, { removed: state.sessions.length !== before }); + } + + if (request.method === "POST" && url.pathname === "/v1/files") { + const upload = await readSourceMultipart(request); + recordRequest(request, url, upload ? { + filename: upload.filename, + bytes: upload.file.length, + purpose: upload.purpose, + } : { multipart: "invalid" }); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + if (state.controls.sourceUploadStatus !== 200) { + const status = state.controls.sourceUploadStatus; + state.controls.sourceUploadStatus = 200; + return sendError(response, status, "Fixture Source upload failed."); + } + if (!upload) return sendError(response, 400, "Fixture Source multipart is invalid."); + const id = `file-${sourceFileUuid}`; + const metadata = { + id, + object: "file", + bytes: upload.file.length, + created_at: baseline, + filename: upload.filename, + purpose: "user_data", + status: "processed", + expires_at: null, + status_details: null, + }; + state.sourceFiles.set(id, { metadata, data: upload.file }); + const lose = state.controls.sourceUploadResponseLoss; + state.controls.sourceUploadResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, metadata); + } + + const sourceContentMatch = url.pathname.match(/^\/v1\/files\/([^/]+)\/content$/); + if (request.method === "GET" && sourceContentMatch) { + const id = decodeURIComponent(sourceContentMatch[1]); + recordRequest(request, url, undefined); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + const source = state.sourceFiles.get(id); + if (!source) return sendError(response, 404, "Fixture Source File not found."); + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-disposition": `attachment; filename="${source.metadata.filename}"`, + "content-length": source.data.length, + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + response.end(source.data); + return; + } + + const sourceFileMatch = url.pathname.match(/^\/v1\/files\/([^/]+)$/); + if (sourceFileMatch && (request.method === "GET" || request.method === "DELETE")) { + const id = decodeURIComponent(sourceFileMatch[1]); + recordRequest(request, url, undefined); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + const source = state.sourceFiles.get(id); + if (!source) return sendError(response, 404, "Fixture Source File not found."); + if (request.method === "GET") return sendJson(response, source.metadata); + if (state.controls.sourceDeleteStatus !== 200) { + const status = state.controls.sourceDeleteStatus; + state.controls.sourceDeleteStatus = 200; + return sendError(response, status, "Fixture Source delete failed."); + } + state.sourceFiles.delete(id); + const lose = state.controls.sourceDeleteResponseLoss; + state.controls.sourceDeleteResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, { id, object: "file", deleted: true }); + } + + const body = request.method === "GET" || request.method === "DELETE" ? undefined : await readJson(request); + recordRequest(request, url, body); + + if (url.pathname === "/v1/vaults") { + if (request.method === "GET") return sendJson(response, page(state.vaults)); + if (request.method === "POST") { + if (!isRecord(body) || typeof body.name !== "string" || !body.name.trim() || !isRecord(body.metadata ?? {})) { + return sendError(response, 400, "Fixture Vault fields are invalid."); + } + state.sequence += 1; + const vault = { + id: fixtureUuid(state.sequence), + object: "vault", + created_at: baseline + state.sequence, + name: body.name, + metadata: body.metadata ?? {}, + }; + state.vaults.unshift(vault); + return sendJson(response, vault); + } + } + + const credentialsMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)\/credentials$/u); + if (credentialsMatch) { + const vaultId = decodeURIComponent(credentialsMatch[1]); + if (!state.vaults.some((vault) => vault.id === vaultId)) return sendError(response, 404, "Fixture Vault not found."); + if (request.method === "GET") { + return sendJson(response, page(state.credentials.filter((credential) => credential.vault_id === vaultId))); + } + if (request.method === "POST") { + if ( + !isRecord(body) || typeof body.name !== "string" || !body.name.trim() || + !isRecord(body.auth) || body.auth.type !== "static_bearer" || + typeof body.auth.mcp_server_url !== "string" || !body.auth.mcp_server_url.startsWith("https://") || + typeof body.auth.token !== "string" + ) return sendError(response, 400, "Fixture Credential fields are invalid."); + state.sequence += 1; + const credential = { + id: fixtureUuid(state.sequence), + vault_id: vaultId, + name: body.name, + object: "vault.credential", + auth: { type: "static_bearer", mcp_server_url: body.auth.mcp_server_url }, + created_at: baseline + state.sequence, + updated_at: baseline + state.sequence, + }; + state.credentials.unshift(credential); + state.credentialTokens.add(credential.id); + return sendJson(response, credential); + } + } + + const credentialMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)\/credentials\/([^/]+)$/u); + if (credentialMatch) { + const vaultId = decodeURIComponent(credentialMatch[1]); + const credentialId = decodeURIComponent(credentialMatch[2]); + const credential = state.credentials.find((candidate) => candidate.vault_id === vaultId && candidate.id === credentialId); + if (!credential) return sendError(response, 404, "Fixture Credential not found."); + if (request.method === "GET") return sendJson(response, credential); + if (request.method === "POST") { + if (!isRecord(body) || !isRecord(body.auth) || body.auth.type !== "static_bearer" || typeof body.auth.token !== "string") { + return sendError(response, 400, "Fixture Credential replacement is invalid."); + } + credential.updated_at += 1; + state.credentialTokens.add(credential.id); + return sendJson(response, credential); + } + if (request.method === "DELETE") { + state.credentials = state.credentials.filter((candidate) => candidate.id !== credentialId); + state.credentialTokens.delete(credentialId); + return sendJson(response, { id: credentialId, object: "vault.credential.deleted", deleted: true }); + } + } + + const vaultMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)$/u); + if (vaultMatch) { + const vaultId = decodeURIComponent(vaultMatch[1]); + const vault = state.vaults.find((candidate) => candidate.id === vaultId); + if (!vault) return sendError(response, 404, "Fixture Vault not found."); + if (request.method === "GET") return sendJson(response, vault); + if (request.method === "DELETE") { + const deletedCredentialIds = state.credentials.filter((credential) => credential.vault_id === vaultId).map((credential) => credential.id); + state.vaults = state.vaults.filter((candidate) => candidate.id !== vaultId); + state.credentials = state.credentials.filter((credential) => credential.vault_id !== vaultId); + for (const credentialId of deletedCredentialIds) state.credentialTokens.delete(credentialId); + return sendJson(response, { id: vaultId, object: "vault.deleted", deleted: true }); + } + } + + if (request.method === "GET" && url.pathname === "/v1/agents") { + const listed = state.agents.map((agent, index) => index === 0 + ? { ...agent, name: `${agent.name} · stale list`, updated_at: agent.updated_at - 10 } + : agent); + return sendJson(response, page(listed)); + } + + 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 = { + ...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); + } + + if (request.method === "GET" && url.pathname === "/v1/agents/sessions") { + trackAbort(response, "sessionListReads"); + const control = consumeControl("sessionList"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture Session list failed."); + const agentId = url.searchParams.get("agent_id"); + const filtered = agentId === null + ? state.sessions + : state.sessions.filter((session) => session.agent.id === agentId); + const listed = queryPage(filtered, url, state.controls.sessionListPageSize); + return listed + ? sendJson(response, listed) + : sendError(response, 400, "Fixture Session cursor is outside the selected Agent filter."); + } + + if (request.method === "POST" && url.pathname === "/v1/agents/sessions") { + const idempotencyKey = request.headers["idempotency-key"]; + const { stream: _streamResponseMode, ...creationIntent } = body; + const fingerprint = JSON.stringify(creationIntent); + const receipt = typeof idempotencyKey === "string" + ? state.sessionCreateReceipts.get(idempotencyKey) + : undefined; + if (receipt) { + if (receipt.fingerprint !== fingerprint) { + return sendError(response, 409, "Fixture idempotency key was reused with a different Session request."); + } + if (body.stream === true) { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + response.write(": fixture creation retry observes only future events\n\n"); + setTimeout(() => response.end(), state.controls.sessionCreateStreamCloseDelayMs); + return; + } + return sendJson(response, receipt.session, 200); + } + + const control = consumeControl("sessionCreate", 201); + const responseLoss = state.controls.sessionCreateResponseLoss; + state.controls.sessionCreateResponseLoss = 0; + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 201) return sendError(response, control.status, "Fixture Session create failed."); + const savedAgent = typeof body.agent_id === "string" + ? state.agents.find((candidate) => candidate.id === body.agent_id) + : undefined; + const agent = body.agent_id === undefined + ? sessionInlineAgent(body.agent, `inline_agent_${state.sequence + 1}`) + : savedAgent + ? sessionEffectiveAgent(savedAgent, body.agent) + : null; + if (body.agent_id !== undefined && !savedAgent) { + return sendError(response, 404, "Fixture Agent not found for Session."); + } + if (!agent) return sendError(response, 400, "Fixture Session Agent override is invalid."); + const admissionError = sessionAdmissionError(agent); + if (admissionError) return sendError(response, 400, admissionError); + const vaultError = sessionVaultError(agent, body.vault_ids ?? []); + if (vaultError) return sendError(response, 400, vaultError); + const environment = sessionEnvironmentResponse(body.environment); + if (!environment) return sendError(response, 400, "Fixture Session environment is unsupported."); + if (environment.type === "openai_hosted" && agent.tools.some((tool) => tool.type === "mcp")) { + return sendError(response, 400, "Fixture managed hosted MCP combination is not qualified."); + } + if ( + body.metadata !== undefined && + (!isRecord(body.metadata) || Object.entries(body.metadata).some(([key, value]) => ( + typeof value !== "string" || [...key].length > 64 || [...value].length > 512 + )) || Object.keys(body.metadata).length > 16) + ) return sendError(response, 400, "Fixture Session metadata is invalid."); + const hasInitialInput = body.input !== undefined && body.input !== null; + const initialInputMessages = hasInitialInput ? sessionInitialInputMessages(body.input) : []; + if (hasInitialInput && !initialInputMessages) { + return sendError(response, 400, "Fixture initial Session input is invalid."); + } + state.sequence += 1; + const created = { + id: `session_created_${state.sequence}`, + object: "agent.session", + agent: sessionSnapshot(agent), + environment, + status: "idle", + error: null, + metadata: body.metadata ?? {}, + required_actions: [], + vault_ids: body.vault_ids ?? [], + usage: null, + created_at: baseline + state.sequence, + last_active_at: baseline + state.sequence, + }; + state.sessions.unshift(created); + if (typeof idempotencyKey === "string") { + state.sessionCreateReceipts.set(idempotencyKey, { fingerprint, session: created }); + } + if (responseLoss) { + response.destroy(); + return; + } + if (body.stream === true) { + const createdSnapshot = structuredClone(created); + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + response.write(": connected\n\n"); + state.sequence += 1; + response.write(`event: agent.session.created\nid: create_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.created", + event_id: `create_${state.sequence}`, + session: createdSnapshot, + })}\n\n`); + if (hasInitialInput && initialInputMessages) { + state.sequence += 1; + const turn = { + id: `turn_created_${state.sequence}`, + agent_id: created.agent.id, + session_id: created.id, + object: "agent.session.turn", + status: "queued", + created_at: baseline + state.sequence, + started_at: null, + completed_at: null, + error: null, + usage: null, + }; + const items = initialInputMessages.map((message, index) => ({ + id: `item_created_${state.sequence}_${index + 1}`, + turn_id: turn.id, + type: "message", + status: "completed", + role: "user", + content: message.content, + })); + state.turns.push(turn); + state.createdSessionItems.set(created.id, items); + response.write(`event: agent.session.turn.created\nid: turn_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.turn.created", + event_id: `turn_${state.sequence}`, + session_id: created.id, + turn_id: turn.id, + turn, + })}\n\n`); + created.status = "in_progress"; + created.last_active_at = baseline + state.sequence; + response.write(`event: agent.session.in_progress\nid: progress_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.in_progress", + event_id: `progress_${state.sequence}`, + session_id: created.id, + session: created, + })}\n\n`); + for (const [index, item] of items.entries()) { + response.write(`event: agent.session.turn.item.added\nid: item_${state.sequence}_${index + 1}\ndata: ${JSON.stringify({ + type: "agent.session.turn.item.added", + event_id: `item_${state.sequence}_${index + 1}`, + session_id: created.id, + turn_id: turn.id, + item, + })}\n\n`); + } + } + setTimeout(() => response.end(), state.controls.sessionCreateStreamCloseDelayMs); + return; + } + return sendJson(response, created, 201); + } + + const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/); + if (agentMatch) { + const id = decodeURIComponent(agentMatch[1]); + const agent = state.agents.find((candidate) => candidate.id === id); + if (!agent) return sendError(response, 404, "Fixture Agent not found."); + + if (request.method === "GET") { + const control = consumeControl("retrieve"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture retrieve failed."); + return sendJson(response, agent); + } + + if (request.method === "POST") { + const control = consumeControl("update"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture update failed."); + const updated = { ...agent, ...body, updated_at: agent.updated_at + 100 }; + state.agents = state.agents.map((candidate) => candidate.id === id ? updated : candidate); + return sendJson(response, updated); + } + + if (request.method === "DELETE") { + const control = consumeControl("delete"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture delete failed."); + state.agents = state.agents.filter((candidate) => candidate.id !== id); + return sendJson(response, { id, object: "agent.deleted", deleted: true }); + } + } + + const sessionMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)$/); + if (sessionMatch) { + const id = decodeURIComponent(sessionMatch[1]); + const session = state.sessions.find((candidate) => candidate.id === id); + if (!session) return sendError(response, 404, "Fixture Session not found."); + + if (request.method === "GET") { + trackAbort(response, "sessionReads"); + const delayMs = state.controls.sessionRetrieveDelayMs; + const status = state.controls.sessionRetrieveStatus; + const variant = state.controls.sessionRetrieveVariant; + state.controls.sessionRetrieveDelayMs = 0; + state.controls.sessionRetrieveStatus = 200; + state.controls.sessionRetrieveVariant = "valid"; + const retrievedSession = variant === "wrong_id" + ? { ...session, id: "another_session" } + : variant === "malformed" + ? { id, object: "agent.session", metadata: session.metadata } + : variant === "deep_malformed" + ? { ...session, agent: { model: session.agent.model } } + : session; + if (delayMs && status === 200) { + const payload = JSON.stringify(retrievedSession); + response.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload) + 1, + "cache-control": "no-store", + }); + response.write(" "); + await wait(delayMs); + if (response.destroyed) return; + response.end(payload); + return; + } + if (delayMs) await wait(delayMs); + if (response.destroyed) return; + if (status !== 200) return sendError(response, status, "Fixture Session retrieve failed."); + return sendJson(response, retrievedSession); + } + + if (request.method === "POST") { + const delayMs = state.controls.sessionUpdateDelayMs; + const status = state.controls.sessionUpdateStatus; + const responseLoss = state.controls.sessionUpdateResponseLoss; + state.controls.sessionUpdateDelayMs = 0; + state.controls.sessionUpdateStatus = 200; + state.controls.sessionUpdateResponseLoss = 0; + if (delayMs) await wait(delayMs); + if (status !== 200) return sendError(response, status, "Fixture Session update failed."); + session.metadata = body.metadata ?? session.metadata; + if (responseLoss) { + response.destroy(); + return; + } + return sendJson(response, session); + } + + if (request.method === "DELETE") { + const delayMs = state.controls.sessionDeleteDelayMs; + const status = state.controls.sessionDeleteStatus; + const responseLoss = state.controls.sessionDeleteResponseLoss; + state.controls.sessionDeleteDelayMs = 0; + state.controls.sessionDeleteStatus = 200; + state.controls.sessionDeleteResponseLoss = 0; + if (delayMs) await wait(delayMs); + if (status !== 200) return sendError(response, status, "Fixture Session delete failed."); + if (responseLoss === 2) { + response.destroy(); + return; + } + state.sessions = state.sessions.filter((candidate) => candidate.id !== id); + state.turns = state.turns.filter((turn) => turn.session_id !== id); + const targetStreams = [...streamResponses] + .filter(([, streamSessionId]) => streamSessionId === id) + .map(([stream]) => stream); + const closeStreams = () => { + for (const stream of targetStreams) { + if (!stream.destroyed) stream.end(); + } + }; + if (state.controls.sessionDeleteStreamCloseDelayMs) { + setTimeout(closeStreams, state.controls.sessionDeleteStreamCloseDelayMs); + } else { + closeStreams(); + } + if (responseLoss) { + response.destroy(); + return; + } + return sendJson(response, { id, object: "agent.session.deleted", deleted: true }); + } + } + + // Declared before the single-Environment route so that the reusable + // configuration collection is never read as an Environment identifier. + if (url.pathname === "/v1/agents/environments/templates") { + if (request.headers["openai-beta"] !== "agents=v1") { + return sendError(response, 400, "Fixture Environment Templates require the Agents beta header."); + } + if (request.method === "GET") { + const status = state.controls.environmentTemplateListStatus; + if (status !== 200) { + return sendError( + response, + status, + status === 400 ? "Environment retrieval does not accept query parameters." : "Fixture Environment Template list failed.", + status === 400 ? "unsupported_parameter" : "fixture_failure", + status === 400 ? "invalid_request_error" : "fixture_error", + ); + } + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + if (limit < 1 || limit > 100 || !["asc", "desc"].includes(order)) { + return sendError(response, 400, "Fixture Environment Template query is invalid."); + } + const templates = [...state.environmentTemplates] + .sort((left, right) => left.created_at - right.created_at); + if (order === "desc") templates.reverse(); + const page = templates.slice(0, limit); + return sendJson(response, { + object: "list", + data: page, + has_more: page.length < templates.length, + first_id: page[0]?.id ?? null, + last_id: page.at(-1)?.id ?? null, + }); + } + if (request.method === "POST") { + const status = state.controls.environmentTemplateCreateStatus; + if (status !== 200) { + state.controls.environmentTemplateCreateStatus = 200; + return sendError(response, status, "Fixture Environment Template create failed."); + } + if (!isRecord(body) || !hasOnlyKeys(body, ["name", "network"])) { + return sendError(response, 400, "Fixture Environment Template body is invalid."); + } + let access = "enabled"; + if (body.network !== undefined && body.network !== null) { + if ( + !isRecord(body.network) || !hasOnlyKeys(body.network, ["access"]) || + (body.network.access !== "enabled" && body.network.access !== "disabled") + ) return sendError(response, 400, "Fixture Environment Template network is unsupported."); + access = body.network.access; + } + if (body.name !== undefined && body.name !== null && typeof body.name !== "string") { + return sendError(response, 400, "Fixture Environment Template name is invalid."); + } + const index = state.environmentTemplates.length + 1; + const created = Math.floor(Date.now() / 1000); + const template = { + id: `4${String(index).padStart(7, "0")}-1111-4111-8111-111111111111`, + object: "agent.environment.template", + name: body.name === undefined ? null : body.name, + network: { access, allowed_domains: [] }, + capability_directories: [], + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + created_at: created, + updated_at: created, + }; + state.environmentTemplates.push(template); + return sendJson(response, template); + } + return sendError(response, 405, "This API method is not supported.", "unsupported_operation"); + } + + const environmentFilesMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)\/files$/); + if (request.method === "POST" && environmentFilesMatch) { + const id = decodeURIComponent(environmentFilesMatch[1]); + if (id !== hostedEnvironmentUuid) return sendError(response, 503, "Fixture Environment is not a writable hosted placement."); + if (request.headers["openai-beta"] !== "agents=v1") return sendError(response, 400, "Fixture Environment Files requires the Agents beta header."); + if (state.controls.environmentFileCreateStatus !== 200) { + const status = state.controls.environmentFileCreateStatus; + state.controls.environmentFileCreateStatus = 200; + return sendError(response, status, "Fixture Environment copy failed."); + } + if (!isRecord(body) || typeof body.path !== "string" || !body.path.startsWith("/workspace/")) { + return sendError(response, 400, "Fixture Environment copy body is invalid."); + } + let data; + if (body.type === "file_id" && Object.keys(body).sort().join(",") === "file_id,path,type" && typeof body.file_id === "string") { + const source = state.sourceFiles.get(body.file_id); + if (!source) return sendError(response, 404, "Fixture Source File not found for copy."); + data = source.data; + } else if (body.type === "inline" && Object.keys(body).sort().join(",") === "data,path,type" && typeof body.data === "string") { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(body.data)) { + return sendError(response, 400, "Fixture inline Environment data is invalid."); + } + data = Buffer.from(body.data, "base64"); + if (data.toString("base64") !== body.data) return sendError(response, 400, "Fixture inline Environment data is invalid."); + } else { + return sendError(response, 400, "Fixture Environment copy body is invalid."); + } + if (data.length > 50 * 1024 * 1024) return sendError(response, 413, "Fixture Environment copy is too large."); + const result = { + environment_id: hostedEnvironmentUuid, + object: "agent.environment.file", + path: body.path, + size_bytes: data.length, + }; + state.hostedWorkspaceFiles = [ + ...state.hostedWorkspaceFiles.filter((file) => file.path !== body.path), + result, + ]; + const lose = state.controls.environmentFileCreateResponseLoss; + state.controls.environmentFileCreateResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, result); + } + if (request.method === "GET" && environmentFilesMatch) { + trackAbort(response, "environmentFileReads"); + const control = consumeControl("environmentFiles"); + if (control.delayMs) await wait(control.delayMs); + if (response.destroyed) return; + if (control.status !== 200) { + return sendError( + response, + control.status, + control.status === 404 ? "This API operation is not supported." : "Fixture Environment files read failed.", + control.status === 404 ? "unsupported_operation" : "fixture_failure", + control.status === 404 ? "invalid_request_error" : "fixture_error", + ); + } + + const id = decodeURIComponent(environmentFilesMatch[1]); + if (id === hostedEnvironmentUuid) { + const directory = url.searchParams.get("path") ?? "/workspace"; + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + if (url.searchParams.get("page") !== null || limit < 1 || limit > 100 || !["asc", "desc"].includes(order)) { + return sendError(response, 400, "Fixture hosted Environment files query is invalid."); + } + const files = state.hostedWorkspaceFiles + .filter((file) => file.path.slice(0, file.path.lastIndexOf("/")) === directory) + .sort((left, right) => left.path.localeCompare(right.path)); + if (order === "desc") files.reverse(); + return sendJson(response, { data: files.slice(0, limit), next: null }); + } + const sessionEnvironment = state.sessions[0]?.environment; + const expectedId = sessionEnvironment?.type === "self_hosted" ? sessionEnvironment.id : null; + if (id !== expectedId) return sendError(response, 404, "Fixture Environment not found."); + const directory = url.searchParams.get("path") ?? sessionEnvironment.workspace_directory; + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + const cursor = url.searchParams.get("page"); + if ( + directory !== sessionEnvironment.workspace_directory || + limit !== 20 || + !["asc", "desc"].includes(order) || + (cursor !== null && cursor !== "fixture-page-2") + ) return sendError(response, 400, "Fixture Environment files query is invalid."); + + const allFiles = Array.from({ length: 21 }, (_, index) => ({ + environment_id: canonicalEnvironmentUuid, + object: "agent.environment.file", + path: `${sessionEnvironment.workspace_directory}/file-${String(index + 1).padStart(2, "0")}.txt`, + size_bytes: (index + 1) * 128, + })); + if (order === "desc") allFiles.reverse(); + const start = cursor === "fixture-page-2" ? 20 : 0; + return sendJson(response, { + data: allFiles.slice(start, start + limit), + next: start + limit < allFiles.length ? "fixture-page-2" : null, + }); + } + + const environmentMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)$/); + if (request.method === "GET" && environmentMatch) { + if (state.controls.environmentRetrieveDelayMs) await wait(state.controls.environmentRetrieveDelayMs); + if (state.controls.environmentRetrieveStatus !== 200) { + return sendError(response, state.controls.environmentRetrieveStatus, "Fixture Environment retrieve failed."); + } + const id = decodeURIComponent(environmentMatch[1]); + if (id === hostedEnvironmentUuid) { + const resource = { + id, + object: "agent.environment", + type: "openai_hosted", + status: state.controls.environmentResourceStatus, + files: [], + plugins: [], + skills: [], + }; + if (state.controls.environmentResourceVariant === "missing_skills") delete resource.skills; + if (state.controls.environmentResourceVariant === "wrong_id") resource.id = canonicalEnvironmentUuid; + if (state.controls.environmentResourceVariant === "extra_field") resource.extra = true; + if (state.controls.environmentResourceVariant === "wrong_type") resource.type = "self_hosted"; + if (state.controls.environmentResourceVariant === "populated") resource.files = [{ id: "unsupported-install" }]; + return sendJson(response, resource); + } + const sessionEnvironment = state.sessions[0]?.environment; + const expectedId = sessionEnvironment?.type === "self_hosted" ? sessionEnvironment.id : null; + if (id !== expectedId) return sendError(response, 404, "Fixture Environment not found."); + const canonicalId = id.toLowerCase(); + const resource = { + id: canonicalUuidPattern.test(canonicalId) ? canonicalId : id, + object: "agent.environment", + type: "self_hosted", + status: state.controls.environmentResourceStatus, + files: [], + plugins: [], + skills: [], + }; + if (state.controls.environmentResourceVariant === "missing_skills") delete resource.skills; + if (state.controls.environmentResourceVariant === "wrong_id") resource.id = "another_environment"; + if (state.controls.environmentResourceVariant === "extra_field") resource.extra = true; + if (state.controls.environmentResourceVariant === "wrong_type") resource.type = "openai_hosted"; + return sendJson(response, resource); + } + + const itemsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/items$/); + if (request.method === "GET" && itemsMatch) { + trackAbort(response, "itemReads"); + if (state.controls.itemsRetrieveDelayMs) await wait(state.controls.itemsRetrieveDelayMs); + if (response.destroyed) return; + if (state.controls.itemsRetrieveStatus !== 200) { + return sendError(response, state.controls.itemsRetrieveStatus, "Fixture Items retrieve failed."); + } + const sessionId = decodeURIComponent(itemsMatch[1]); + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for Items."); + } + const items = sessionId !== "session_snapshot" + ? state.createdSessionItems.get(sessionId) ?? [] + : state.controls.itemsScenario === 2 + ? [...observableTurnItems(), ...patchItems()] + : state.controls.itemsScenario + ? patchItems() + : state.controls.turnsScenario + ? observableTurnItems() + : []; + return sendJson(response, page(items)); + } + + const turnsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/turns$/); + if (request.method === "GET" && turnsMatch) { + trackAbort(response, "turnReads"); + if (state.controls.turnsRetrieveDelayMs) await wait(state.controls.turnsRetrieveDelayMs); + if (response.destroyed) return; + if (state.controls.turnsRetrieveStatus !== 200) { + return sendError(response, state.controls.turnsRetrieveStatus, "Fixture Turns retrieve failed."); + } + const sessionId = decodeURIComponent(turnsMatch[1]); + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for Turns."); + } + const sessionTurns = state.turns.filter((turn) => turn.session_id === sessionId); + const after = url.searchParams.get("after"); + const start = after ? sessionTurns.findIndex((turn) => turn.id === after) + 1 : 0; + if (after && start === 0) return sendError(response, 400, "Fixture Turn cursor not found."); + const requestedLimit = Number(url.searchParams.get("limit") ?? 20); + const size = Math.max(1, Math.min(requestedLimit, state.controls.turnsPageSize)); + const data = sessionTurns.slice(start, start + size); + return sendJson(response, { + object: "list", + data, + has_more: start + data.length < sessionTurns.length, + }); + } + + const eventsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/events$/); + if (request.method === "POST" && eventsMatch) { + const status = state.controls.sendStatus; + const responseLoss = state.controls.sendResponseLoss; + state.controls.sendStatus = 204; + state.controls.sendResponseLoss = 0; + if (responseLoss) { + response.destroy(); + return; + } + if (status !== 204) return sendError(response, status, "Fixture send failed."); + response.writeHead(204); + response.end(); + return; + } + if (request.method === "GET" && eventsMatch) { + trackAbort(response, "streams"); + const sessionId = decodeURIComponent(eventsMatch[1]); + if (state.controls.streamOpenDelayMs) await wait(state.controls.streamOpenDelayMs); + if (response.destroyed) return; + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for stream."); + } + if (state.controls.streamStatus !== 200) { + return sendError(response, state.controls.streamStatus, "Fixture stream rejected."); + } + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + streamResponses.set(response, sessionId); + response.write(": fixture stream open\n\n"); + const statuses = [null, "pending", "ready", "connected", "disconnected", "failed", "expired"]; + const environmentStatus = statuses[state.controls.environmentEventStatus] ?? null; + if (environmentStatus && state.controls.environmentEventCount > 0) { + state.controls.environmentEventCount -= 1; + state.sequence += 1; + const streamSession = state.sessions.find((candidate) => candidate.id === sessionId); + const sessionEnvironment = streamSession?.environment; + const supportedEnvironment = sessionEnvironment?.type === "self_hosted" || sessionEnvironment?.type === "openai_hosted" + ? sessionEnvironment + : null; + const rawEnvironmentId = supportedEnvironment?.id ?? "environment_fixture"; + const canonicalEnvironmentId = rawEnvironmentId.toLowerCase(); + const environment = { + id: canonicalUuidPattern.test(canonicalEnvironmentId) ? canonicalEnvironmentId : rawEnvironmentId, + type: supportedEnvironment?.type ?? "self_hosted", + status: environmentStatus, + error: environmentStatus === "failed" ? { + code: "environment_failed", + type: "environment_error", + message: "Safe failure; Authorization: Bearer fixture-secret X-API-Key: fixture-key", + } : null, + }; + response.write(`id: environment_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.environment.${environmentStatus}`, + event_id: `environment_${state.sequence}`, + session_id: sessionId, + environment, + })}\n\n`); + } + if (state.controls.streamCloseCount > 0) { + state.controls.streamCloseCount -= 1; + setTimeout(() => response.end(), state.controls.streamCloseDelayMs); + } + const heartbeat = setInterval(() => response.write(": fixture heartbeat\n\n"), 10_000); + request.on("close", () => { + clearInterval(heartbeat); + streamResponses.delete(response); + }); + return; + } + + return sendError(response, 404, `No fixture route for ${request.method} ${url.pathname}`); + } catch (error) { + return sendError(response, 500, error instanceof Error ? error.message : "Unknown fixture failure."); + } +}); + +server.listen(port, host, () => { + process.stdout.write(`AGENT_FIXTURE_READY http://${host}:${port}\n`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/apps/web/e2e/vault-credentials.spec.ts b/apps/web/e2e/vault-credentials.spec.ts new file mode 100644 index 000000000..59c557187 --- /dev/null +++ b/apps/web/e2e/vault-credentials.spec.ts @@ -0,0 +1,244 @@ +import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test"; + +// Runner traces record HTTP bodies. This file deliberately submits transient +// bearer values, so it must never produce a trace artifact. +test.use({ trace: "off" }); + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; + +interface FixtureRequest { + method: string; + path: string; + body?: Record; +} + +async function resetFixture(request: APIRequestContext): Promise { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function fixtureRequests(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function openAgents(page: Page, request: APIRequestContext): Promise { + await resetFixture(request); + await page.goto("/"); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); +} + +async function openAdvancedSessionSettings(dialog: Locator): Promise { + const toggle = dialog.getByRole("button", { name: /Advanced settings/ }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +} + +async function fillWriteOnlyBearer(dialog: Locator): Promise { + const input = dialog.locator('input[type="password"]'); + await expect(input).toHaveCount(1); + await input.evaluate((element) => { + (element as HTMLInputElement).value = globalThis.crypto.randomUUID().replaceAll("-", ""); + }); +} + +test.describe("Vault capability discovery", () => { + for (const status of [404, 405]) { + test(`hides Vault controls when the connected Core returns ${status}`, async ({ page, request }) => { + await resetFixture(request); + await page.route("**/v1/vaults?*", async (route) => { + if (route.request().method() === "GET" && new URL(route.request().url()).pathname === "/v1/vaults") { + await route.fulfill({ + status, + json: { error: { code: status === 404 ? "not_found" : "method_not_allowed", message: "Unavailable." } }, + }); + return; + } + await route.continue(); + }); + await page.goto("/"); + + 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"); + }); + } +}); + +test("keeps Vault content aligned with the page header without narrow-screen clipping", async ({ page, request }) => { + await resetFixture(request); + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/"); + await page.getByRole("button", { name: "Vaults", exact: true }).click(); + + const desktop = await page.locator(".vaults-page").evaluate((pageElement) => { + const title = pageElement.querySelector(".page-header h1")?.getBoundingClientRect(); + const contentElement = pageElement.querySelector(".vaults-content"); + const note = contentElement?.querySelector(".vault-security-note")?.getBoundingClientRect(); + const page = pageElement.getBoundingClientRect(); + return { + contentOverflowY: contentElement ? getComputedStyle(contentElement).overflowY : null, + pageRight: Math.round(page.right), + titleLeft: Math.round(title?.left ?? -1), + noteLeft: Math.round(note?.left ?? -2), + noteRight: Math.round(note?.right ?? -3), + }; + }); + expect(desktop.contentOverflowY).toBe("auto"); + expect(desktop.noteLeft).toBe(desktop.titleLeft); + expect(desktop.pageRight - desktop.noteRight).toBeGreaterThanOrEqual(23); + expect(desktop.pageRight - desktop.noteRight).toBeLessThanOrEqual(24); + + await page.setViewportSize({ width: 375, height: 720 }); + const narrow = await page.locator(".vaults-page").evaluate((pageElement) => { + const title = pageElement.querySelector(".page-header h1")?.getBoundingClientRect(); + const note = pageElement.querySelector(".vault-security-note")?.getBoundingClientRect(); + const createButton = pageElement.querySelector(".page-actions .button.primary")?.getBoundingClientRect(); + const page = pageElement.getBoundingClientRect(); + return { + pageClientWidth: pageElement.clientWidth, + pageScrollWidth: pageElement.scrollWidth, + pageRight: Math.round(page.right), + titleLeft: Math.round(title?.left ?? -1), + noteLeft: Math.round(note?.left ?? -2), + createButtonRight: Math.round(createButton?.right ?? -3), + }; + }); + expect(narrow.pageScrollWidth).toBeLessThanOrEqual(narrow.pageClientWidth); + expect(narrow.noteLeft).toBe(narrow.titleLeft); + expect(narrow.pageRight - narrow.createButtonRight).toBeGreaterThanOrEqual(11); + expect(narrow.pageRight - narrow.createButtonRight).toBeLessThanOrEqual(12); +}); + +test("shows a fixed 503 storage error, clears the token, and does not retry the write", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: "Vaults", exact: true }).click(); + await page.getByRole("button", { name: "New Vault" }).click(); + const vaultDialog = page.getByRole("dialog", { name: "Create a Vault" }); + await vaultDialog.getByLabel("Name").fill("Unavailable storage"); + await vaultDialog.getByRole("button", { name: "Create Vault" }).click(); + + let credentialWrites = 0; + await page.route("**/v1/vaults/*/credentials", async (route) => { + if (route.request().method() === "POST") { + credentialWrites += 1; + await route.fulfill({ + status: 503, + json: { + error: { + code: "credential_storage_unavailable", + message: "Private fixture detail that the Web must not expose.", + }, + }, + }); + return; + } + await route.continue(); + }); + + const vaultCard = page.locator(".vault-card").filter({ hasText: "Unavailable storage" }); + await vaultCard.getByRole("button", { name: "Credential", exact: true }).click(); + const credentialDialog = page.getByRole("dialog", { name: "Add static bearer Credential" }); + await credentialDialog.getByLabel("Name").fill("Unavailable MCP"); + await credentialDialog.getByLabel("Exact MCP server URL").fill("https://mcp.example/unavailable"); + await fillWriteOnlyBearer(credentialDialog); + await credentialDialog.getByRole("button", { name: "Create Credential" }).click(); + + await expect(credentialDialog.getByRole("alert")).toContainText("Credential encryption is not configured on this Core"); + await expect(credentialDialog).not.toContainText("Private fixture detail"); + await expect(credentialDialog.locator('input[type="password"]')).toHaveValue(""); + expect(credentialWrites).toBe(1); +}); + +test("creates, replaces, uses, and deletes a write-only Vault Credential", async ({ page, request }) => { + const mcpURL = "https://mcp.example/vault-tools"; + await openAgents(page, request); + + const vaultsNavigation = page.getByRole("button", { name: "Vaults" }); + await expect(vaultsNavigation).toBeVisible(); + await vaultsNavigation.click(); + await page.getByRole("button", { name: "New Vault" }).click(); + const vaultDialog = page.getByRole("dialog", { name: "Create a Vault" }); + await vaultDialog.getByLabel("Name").fill("Runtime credentials"); + await vaultDialog.getByRole("button", { name: "Create Vault" }).click(); + + const vaultCard = page.locator(".vault-card").filter({ hasText: "Runtime credentials" }); + await expect(vaultCard).toBeVisible(); + await vaultCard.getByRole("button", { name: "Credential", exact: true }).click(); + const credentialDialog = page.getByRole("dialog", { name: "Add static bearer Credential" }); + await credentialDialog.getByLabel("Name").fill("Private docs MCP"); + await credentialDialog.getByLabel("Exact MCP server URL").fill(mcpURL); + await fillWriteOnlyBearer(credentialDialog); + await credentialDialog.getByRole("button", { name: "Create Credential" }).click(); + await expect(vaultCard).toContainText("Private docs MCP"); + await expect(vaultCard).toContainText("token hidden"); + + let requests = await fixtureRequests(request); + const credentialWrite = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials$/u.test(entry.path)); + expect(credentialWrite?.body?.auth).toMatchObject({ type: "static_bearer", mcp_server_url: mcpURL, token_present: true }); + expect(credentialWrite?.body?.auth).not.toHaveProperty("token"); + + await vaultCard.getByRole("button", { name: "Replace token for Private docs MCP", exact: true }).click(); + const replacementDialog = page.getByRole("dialog", { name: "Replace token · Private docs MCP" }); + await fillWriteOnlyBearer(replacementDialog); + await replacementDialog.getByRole("button", { name: "Replace token", exact: true }).click(); + await expect(replacementDialog).toHaveCount(0); + requests = await fixtureRequests(request); + const replacementWrite = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials\/[^/]+$/u.test(entry.path)); + expect(replacementWrite?.body?.auth).toEqual({ type: "static_bearer", token_present: true }); + expect(replacementWrite?.body?.auth).not.toHaveProperty("token"); + + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /^Create agent/ }).click(); + await page.getByLabel("Name").fill("Credentialed MCP Agent"); + await page.getByLabel("Model").selectOption({ label: "Custom model ID…" }); + await page.getByLabel("Custom model ID").fill("fixture/credential-model"); + await page.getByRole("button", { name: "Add HTTP MCP" }).click(); + const mcpCard = page.locator(".agent-tool-card").filter({ hasText: "HTTP MCP" }).first(); + await mcpCard.getByLabel("Server label").fill("private-docs"); + await mcpCard.getByLabel("Authentication").selectOption({ label: `Private docs MCP · ${mcpURL}` }); + await expect(mcpCard.getByLabel("Server URL")).toHaveValue(mcpURL); + await expect(mcpCard.getByLabel("Server URL")).toHaveAttribute("readonly", ""); + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); + await page.getByRole("button", { name: "Start Session" }).click(); + + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(sessionDialog); + await expect(sessionDialog.getByRole("heading", { name: "Tools & Vaults" })).toBeVisible(); + await expect(sessionDialog).toContainText("Private docs MCP · Runtime credentials"); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); + await expect(page.locator(".toast-region:not(.toast-region-assertive)")).toContainText("Idle Session created"); + + requests = await fixtureRequests(request); + const vaultCreate = requests.find((entry) => entry.method === "POST" && entry.path === "/v1/vaults"); + const vaultId = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials$/u.test(entry.path))?.path.split("/")[3]; + expect(vaultCreate).toBeTruthy(); + expect(vaultId).toMatch(/^[0-9a-f-]{36}$/u); + const agentCreate = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents").at(-1); + const credentialId = (agentCreate?.body?.tools as Array> | undefined)?.[0]?.credential_id; + expect(credentialId).toMatch(/^[0-9a-f-]{36}$/u); + const sessionCreate = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions").at(-1); + expect(sessionCreate?.body?.vault_ids).toEqual([vaultId]); + expect(JSON.stringify(requests)).not.toContain('"token":'); + + await page.getByRole("button", { name: "Vaults" }).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?" }); + await deleteCredentialDialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(currentVaultCard).not.toContainText("Private docs MCP"); + await currentVaultCard.getByRole("button", { name: "Delete Runtime credentials", exact: true }).click(); + const deleteVaultDialog = page.getByRole("dialog", { name: "Delete Vault?" }); + await deleteVaultDialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(page.getByRole("heading", { name: "No Vaults" })).toBeVisible(); + + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && /\/v1\/vaults\/[^/]+\/credentials\/[^/]+$/u.test(entry.path))).toHaveLength(1); + expect(requests.filter((entry) => entry.method === "DELETE" && /^\/v1\/vaults\/[^/]+$/u.test(entry.path))).toHaveLength(1); + expect(JSON.stringify(requests)).not.toContain('"token":'); +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 000000000..4d1421b48 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + Agents Core Web + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..98bf865e2 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@agents-core-web/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 4173", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@agents-core-web/agents-client": "workspace:*", + "lucide-react": "^1.22.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.4", + "typescript": "^5.8.3", + "vite": "^8.1.4", + "vitest": "^4.1.9" + } +} diff --git a/apps/web/public/favicon.png b/apps/web/public/favicon.png new file mode 100644 index 000000000..83ad57715 Binary files /dev/null and b/apps/web/public/favicon.png differ diff --git a/apps/web/public/parsar-mark-dark.png b/apps/web/public/parsar-mark-dark.png new file mode 100644 index 000000000..8a38b4d34 Binary files /dev/null and b/apps/web/public/parsar-mark-dark.png differ diff --git a/apps/web/public/parsar-mark-light.png b/apps/web/public/parsar-mark-light.png new file mode 100644 index 000000000..2d3f2bed9 Binary files /dev/null and b/apps/web/public/parsar-mark-light.png differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 000000000..e4a7d6f85 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,2227 @@ +import { Layers3, Settings2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { AgentCoreError } from "@agents-core-web/agents-client"; +import type { + AgentCore, + AgentSession, + AgentTurn, + CreateAgentInput, + CreateEnvironmentTemplateInput, + FunctionResultInput, + SavedAgent, + SessionEvent, + SessionItem, + UpdateAgentInput, +} 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 { + sessionEnvironmentAdmissionBlocker, +} from "./features/agents/session-admission"; +import { + removeSavedAgent, + replaceSavedAgent, + requestAgentDelete, + requestAgentDetail, + requestAgentUpdate, +} from "./features/agents/agent-actions"; +import { DashboardView } from "./features/dashboard/DashboardView"; +import { + SessionsView, + type SessionDetailState, + type StreamState, +} from "./features/sessions/SessionsView"; +import type { SessionStartInput } from "./features/sessions/create/SessionStartDialog"; +import { sessionCreateRequestPayload } from "./features/sessions/create/session-create-attempt"; +import { normalizeSessionEnvironmentInput } from "./features/sessions/create/session-environment"; +import { validateSessionAgentSubmission } from "./features/sessions/create/session-start-draft"; +import { + removeSession, + reconcileUnknownSessionDelete, + replaceSessionMetadata, + requestSessionDelete, + requestSessionDetail, + requestSessionUpdate, + selectionAfterSessionDelete, + SessionActionError, + SessionMetadataConflictError, +} from "./features/sessions/actions/session-actions"; +import { + environmentObservationFromResource, + environmentIdentitiesMatch, + environmentReadIsCurrent, + mergeDurableEnvironmentObservation, + matchingSessionSnapshot, + reduceEnvironmentObservation, + supportedEnvironmentIdentity, + type EnvironmentIdentity, + type EnvironmentObservation, + type ScopedEnvironmentObservation, + unavailableEnvironmentObservation, + visibleEnvironmentObservation, +} from "./features/sessions/environment/environment-state"; +import { + loadEnvironmentTemplateCatalog, + type EnvironmentTemplateCatalog, +} from "./features/sessions/environment/environment-templates"; +import { + listAllTurns, + matchingTurnSnapshot, + mergeDurableAndLiveTurns, + turnReadIsCurrent, + upsertTurn, +} from "./features/sessions/turns/turn-state"; +import { SystemView } from "./features/system/SystemView"; +import type { SourceFilesOperations } from "./features/system/SourceFilesPanel"; +import { VaultsView, type VaultOperations } from "./features/vaults/VaultsView"; +import { deriveSessionVaultPlan, loadVaultCatalog, type VaultCatalog } from "./features/vaults/vault-catalog"; +import { requestVaultCreate } from "./features/vaults/vault-operations"; +import { + createCore, + loadConnection, + saveConnection, + type CoreConnection, + type CoreConnectionState, +} from "./lib/connection"; +import { settleCollection } from "./lib/collection-load"; +import { listStableCollectionPages } from "./lib/collection-pagination"; +import { BACKEND_NOT_READY_NOTICE, backendFailureStatus } from "./lib/core-readiness"; +import { + beginPendingFunctionResult, + failPendingFunctionResult, + functionResultActionKey, + type FailedPendingFunctionResult, +} from "./lib/pending-function-result"; +import { + beginPendingSend, + failPendingSend, + type FailedPendingSend, +} from "./lib/pending-send"; +import { + appendCommandOutputDelta, + mergeDurableAndLiveItems, + updateLiveSessionItems, + upsertSessionItem, +} from "./lib/session-items"; +import { + createDurableRefreshCoordinator, + createStreamRecoveryBuffer, + type DurableRefreshCoordinator, +} from "./lib/session-recovery"; +import { + beginStreamReconciliation, + requestCurrentStreamRetry, + shouldRetryStreamError, + streamConnectionWasStable, + streamReconnectDelay, + waitForStreamReconnect, +} from "./lib/stream-reconnect"; + +type View = ProductView | "system"; + +function viewFromLocation(): View { + if (typeof window === "undefined") return "dashboard"; + const candidate = window.location.hash.slice(1); + return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" + ? candidate + : "dashboard"; +} + +interface StreamConnection { + sessionId: string | null; + state: StreamState; + error: string | null; +} + +interface SelectedSessionLoad { + sessionId: string | null; + state: SessionDetailState; + error: string | null; +} + +interface SessionCreateRequest { + agentId: string | null; + requestId: number; +} + +interface CreationStreamOwner { + controller: AbortController; + coreGeneration: number; + sessionId: string | null; + streamEpoch: number | null; + startReconciliation?: () => void; +} + +interface LiveSessionEventContext { + sessionId: string; + streamEpoch: number; + isCurrent: () => boolean; + refreshCoordinator: DurableRefreshCoordinator | null; +} + +const COLLECTION_RECONCILIATION_ATTEMPTS = 3; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "The Agent core request failed."; +} + +function isAbort(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +async function listAllItems( + core: AgentCore, + sessionId: string, + signal?: AbortSignal, +): Promise { + const items: SessionItem[] = []; + let after: string | undefined; + + while (true) { + const page = await core.listItems(sessionId, { after, limit: 100, order: "asc", signal }); + items.push(...page.data); + if (!page.has_more) return items; + + const nextAfter = page.last_id ?? page.data[page.data.length - 1]?.id; + if (!nextAfter || nextAfter === after) { + throw new Error("The Agent core returned an invalid Items pagination cursor."); + } + after = nextAfter; + } +} + +function projectTextEvent(current: SessionItem[], event: SessionEvent): SessionItem[] { + const eventType = typeof event.type === "string" ? event.type : ""; + if (!eventType.includes(".output_text.")) return current; + + const partText = event.part?.type === "output_text" ? event.part.text : undefined; + const replacement = event.text ?? partText ?? undefined; + const addition = event.delta; + if (replacement === undefined && addition === undefined) return current; + + const streamId = `stream:${event.turn_id ?? "turn"}:${event.output_index ?? 0}:${event.content_index ?? 0}`; + const existingIndex = current.findIndex((item) => item.id === event.item_id || item.id === streamId); + const existing = existingIndex >= 0 ? current[existingIndex] : undefined; + const previousText = existing?.type === "message" + ? (existing.content ?? []).map((content) => content.text ?? "").join("") + : ""; + const text = replacement ?? `${previousText}${addition ?? ""}`; + const projected: SessionItem = { + id: existing?.id ?? event.item_id ?? streamId, + turn_id: event.turn_id ?? existing?.turn_id ?? "", + type: "message", + status: eventType.endsWith(".done") || eventType.endsWith(".completed") ? "completed" : "in_progress", + role: "assistant", + phase: "final_answer", + content: [{ type: "output_text", text }], + }; + + return upsertSessionItem(current, projected); +} + +export function App() { + const { show: showToast } = useToast(); + const [view, setView] = useState(viewFromLocation); + const [connection, setConnection] = useState(() => loadConnection()); + const [connectionOpen, setConnectionOpen] = useState(false); + + useEffect(() => { + const hash = view === "dashboard" ? "" : `#${view}`; + const next = `${window.location.pathname}${window.location.search}${hash}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (current !== next) window.history.replaceState(window.history.state, "", next); + }, [view]); + + useEffect(() => { + const onHashChange = () => setView(viewFromLocation()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + const [agents, setAgents] = useState([]); + const [vaultCatalog, setVaultCatalog] = useState(null); + const [vaultCollectionState, setVaultCollectionState] = useState("connecting"); + const [vaultCollectionError, setVaultCollectionError] = useState(null); + const [vaultSupported, setVaultSupported] = useState(null); + const [environmentTemplates, setEnvironmentTemplates] = useState(null); + const [sessions, setSessions] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [items, setItems] = useState([]); + const [itemsSessionId, setItemsSessionId] = useState(null); + const [turns, setTurns] = useState([]); + const [turnsSessionId, setTurnsSessionId] = useState(null); + const [turnCollectionLoad, setTurnCollectionLoad] = useState({ + sessionId: null, + state: "idle", + error: null, + }); + const [environmentObservations, setEnvironmentObservations] = useState>( + () => new Map(), + ); + const [agentCollectionState, setAgentCollectionState] = useState("connecting"); + const [agentCollectionError, setAgentCollectionError] = useState(null); + const [agentCollectionHasSnapshot, setAgentCollectionHasSnapshot] = useState(false); + const [sessionCollectionState, setSessionCollectionState] = useState("connecting"); + const [sessionCollectionError, setSessionCollectionError] = useState(null); + const [sessionCollectionHasSnapshot, setSessionCollectionHasSnapshot] = useState(false); + const [sessionAgentFilter, setSessionAgentFilter] = useState(null); + const [filteredSessions, setFilteredSessions] = useState([]); + const [filteredSessionCollectionState, setFilteredSessionCollectionState] = useState("connecting"); + const [filteredSessionCollectionError, setFilteredSessionCollectionError] = useState(null); + const [selectedSessionLoad, setSelectedSessionLoad] = useState({ + sessionId: null, + state: "idle", + error: null, + }); + const [streamConnection, setStreamConnection] = useState({ + sessionId: null, + state: "idle", + error: null, + }); + const [streamRetryRevision, setStreamRetryRevision] = useState(0); + const [sessionSendFailures, setSessionSendFailures] = useState>( + () => new Map(), + ); + const cancelFailureRef = useRef(undefined); + const functionResultFailuresRef = useRef(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 filteredSessionsRef = useRef(filteredSessions); + const itemsSessionIdRef = useRef(itemsSessionId); + const turnsSessionIdRef = useRef(turnsSessionId); + const connectionGenerationRef = useRef(0); + const agentCollectionRequestRef = useRef(0); + const sessionCollectionRequestRef = useRef(0); + const agentCollectionAbortRef = useRef(null); + const sessionCollectionAbortRef = useRef(null); + const filteredSessionCollectionAbortRef = useRef(null); + const filteredSessionCollectionRequestRef = useRef(0); + const sessionAgentFilterRef = useRef(sessionAgentFilter); + const vaultCollectionAbortRef = useRef(null); + const environmentTemplateAbortRef = useRef(null); + const vaultCollectionRequestRef = useRef(0); + const agentCollectionRevisionRef = useRef(0); + const sessionCollectionRevisionRef = useRef(0); + const sessionRequestRef = useRef(new Map()); + const sessionEventRevisionRef = useRef(new Map()); + const itemEventRevisionRef = useRef(new Map()); + const turnEventRevisionRef = useRef(new Map()); + const environmentEventRevisionRef = useRef(new Map()); + const environmentRequestRef = useRef(new Map()); + const sessionEnvironmentIdentityRef = useRef(new Map()); + const operationRequestRef = useRef(0); + const streamEpochRef = useRef(0); + const selectedSessionReadAbortRef = useRef(null); + const selectedStreamAbortRef = useRef(null); + const creationStreamOwnerRef = useRef(null); + selectedIdRef.current = selectedId; + sessionsRef.current = sessions; + filteredSessionsRef.current = filteredSessions; + sessionAgentFilterRef.current = sessionAgentFilter; + + const core = useMemo(() => createCore(connection), [connection]); + const sourceFilesOperations = useMemo(() => ({ + uploadSourceFile: (input, options) => core.uploadSourceFile(input, options), + retrieveSourceFile: (fileId, options) => core.retrieveSourceFile(fileId, options), + downloadSourceFile: (fileId, options) => core.downloadSourceFile(fileId, options), + deleteSourceFile: (fileId, options) => core.deleteSourceFile(fileId, options), + retrieveEnvironment: (environmentId, options) => core.retrieveEnvironment(environmentId, options), + createEnvironmentFile: (environmentId, input, options) => core.createEnvironmentFile(environmentId, input, options), + listEnvironmentFiles: (environmentId, options) => core.listEnvironmentFiles(environmentId, options), + }), [core]); + const coreGeneration = connectionGenerationRef.current; + const coreState: CoreConnectionState = agentCollectionState === "ready" || sessionCollectionState === "ready" + ? "ready" + : agentCollectionState === "failed" && sessionCollectionState === "failed" + ? "failed" + : "connecting"; + const sessionVaultCatalog = vaultCollectionState === "ready" ? vaultCatalog : null; + const sessionBrowserSessions = sessionAgentFilter ? filteredSessions : sessions; + const sessionBrowserState = sessionAgentFilter ? filteredSessionCollectionState : sessionCollectionState; + const sessionBrowserError = sessionAgentFilter ? filteredSessionCollectionError : sessionCollectionError; + const selected = selectedId + ? sessionBrowserSessions.find((session) => session.id === selectedId) ?? null + : null; + const detailState: SessionDetailState = !selectedId + ? "idle" + : selectedSessionLoad.sessionId === selectedId + ? selectedSessionLoad.state + : "loading"; + const detailError = selectedSessionLoad.sessionId === selectedId ? selectedSessionLoad.error : null; + const turnState: SessionDetailState = !selectedId + ? "idle" + : turnCollectionLoad.sessionId === selectedId + ? turnCollectionLoad.state + : "loading"; + const turnError = turnCollectionLoad.sessionId === selectedId ? turnCollectionLoad.error : null; + const streamState: StreamState = !selectedId + ? "idle" + : streamConnection.sessionId !== selectedId + ? "connecting" + : streamConnection.state; + const streamError = selectedId && streamConnection.sessionId === selectedId + ? streamConnection.error + : null; + const sendError = selectedId ? sessionSendFailures.get(selectedId) ?? null : null; + const environmentObservation: EnvironmentObservation | null = visibleEnvironmentObservation( + selectedId ? environmentObservations.get(selectedId) : undefined, + selectedId, + streamConnection.sessionId, + streamEpochRef.current, + ); + const streamReady = streamState === "listening"; + const notify = useCallback((message: string, tone: "success" | "error") => { + showToast(message, { tone, key: `${tone}:${message}` }); + }, [showToast]); + + const refreshAgents = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + agentCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + agentCollectionAbortRef.current = controller; + setAgentCollectionState("connecting"); + setAgentCollectionError(null); + try { + const request = agentCollectionRequestRef.current + 1; + agentCollectionRequestRef.current = request; + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listAgents(options), + () => agentCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== agentCollectionRequestRef.current + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setAgentCollectionState("failed"); + setAgentCollectionError(message); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); + return false; + } + if (result.value === null) { + const message = "Agent data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setAgentCollectionState("failed"); + setAgentCollectionError(message); + notify(message, "error"); + return false; + } + setAgents(result.value); + setAgentCollectionHasSnapshot(true); + setAgentCollectionState("ready"); + return true; + } finally { + if (agentCollectionAbortRef.current === controller) agentCollectionAbortRef.current = null; + } + }, [core, coreGeneration, notify]); + + const refreshSessions = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + sessionCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + sessionCollectionAbortRef.current = controller; + setSessionCollectionState("connecting"); + setSessionCollectionError(null); + try { + const request = sessionCollectionRequestRef.current + 1; + sessionCollectionRequestRef.current = request; + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listSessions(options), + () => sessionCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== sessionCollectionRequestRef.current + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setSessionCollectionState("failed"); + setSessionCollectionError(message); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); + return false; + } + if (result.value === null) { + const message = "Session data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setSessionCollectionState("failed"); + setSessionCollectionError(message); + notify(message, "error"); + return false; + } + const stableSessions = result.value; + + const nextEnvironmentIdentities = new Map( + stableSessions.map((session) => [session.id, supportedEnvironmentIdentity(session.environment)]), + ); + const changedEnvironmentSessions = new Set(); + for (const [sessionId, identity] of nextEnvironmentIdentities) { + if (!environmentIdentitiesMatch(sessionEnvironmentIdentityRef.current.get(sessionId), identity)) { + changedEnvironmentSessions.add(sessionId); + } + } + for (const sessionId of sessionEnvironmentIdentityRef.current.keys()) { + if (!nextEnvironmentIdentities.has(sessionId)) changedEnvironmentSessions.add(sessionId); + } + for (const sessionId of changedEnvironmentSessions) { + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + } + sessionEnvironmentIdentityRef.current = nextEnvironmentIdentities; + if (changedEnvironmentSessions.size) { + setEnvironmentObservations((current) => { + if (![...changedEnvironmentSessions].some((sessionId) => current.has(sessionId))) return current; + const next = new Map(current); + for (const sessionId of changedEnvironmentSessions) next.delete(sessionId); + return next; + }); + } + sessionsRef.current = stableSessions; + setSessions(stableSessions); + setSessionCollectionHasSnapshot(true); + if (!sessionAgentFilterRef.current) { + setSelectedId((current) => { + const next = current && stableSessions.some((session) => session.id === current) + ? current + : stableSessions[0]?.id ?? null; + selectedIdRef.current = next; + return next; + }); + } + setSessionCollectionState("ready"); + return true; + } finally { + if (sessionCollectionAbortRef.current === controller) sessionCollectionAbortRef.current = null; + } + }, [core, coreGeneration, notify]); + + const refreshFilteredSessions = useCallback(async (agentId: string) => { + if ( + coreGeneration !== connectionGenerationRef.current || + sessionAgentFilterRef.current !== agentId + ) return false; + filteredSessionCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + filteredSessionCollectionAbortRef.current = controller; + const request = filteredSessionCollectionRequestRef.current + 1; + filteredSessionCollectionRequestRef.current = request; + setFilteredSessionCollectionState("connecting"); + setFilteredSessionCollectionError(null); + try { + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listSessions({ ...options, agentId }), + () => sessionCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== filteredSessionCollectionRequestRef.current || + sessionAgentFilterRef.current !== agentId + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(backendFailureStatus(result.reason) ? BACKEND_NOT_READY_NOTICE : message, "error"); + return false; + } + if (result.value === null) { + const message = "Filtered Session data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(message, "error"); + return false; + } + const stableFilteredSessions = result.value; + if (stableFilteredSessions.some((session) => session.agent.id !== agentId)) { + const message = "Agent Core returned a Session outside the requested Agent filter."; + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(message, "error"); + return false; + } + filteredSessionsRef.current = stableFilteredSessions; + setFilteredSessions(stableFilteredSessions); + setSelectedId((current) => { + const next = current && stableFilteredSessions.some((session) => session.id === current) + ? current + : stableFilteredSessions[0]?.id ?? null; + selectedIdRef.current = next; + return next; + }); + setFilteredSessionCollectionState("ready"); + return true; + } finally { + if (filteredSessionCollectionAbortRef.current === controller) { + filteredSessionCollectionAbortRef.current = null; + } + } + }, [core, coreGeneration, notify]); + + const refreshVaults = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + vaultCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + vaultCollectionAbortRef.current = controller; + const request = vaultCollectionRequestRef.current + 1; + vaultCollectionRequestRef.current = request; + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + try { + const catalog = await loadVaultCatalog(core, controller.signal); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== vaultCollectionRequestRef.current + ) return false; + setVaultCatalog(catalog); + setVaultSupported(true); + setVaultCollectionState("ready"); + return true; + } catch (error) { + if ( + coreGeneration !== connectionGenerationRef.current || + request !== vaultCollectionRequestRef.current || + isAbort(error) + ) return false; + if (error instanceof AgentCoreError && (error.status === 404 || error.status === 405)) { + setVaultCatalog(null); + setVaultSupported(false); + setVaultCollectionState("ready"); + setVaultCollectionError(null); + return false; + } + setVaultCollectionState("failed"); + setVaultCollectionError(errorMessage(error)); + return false; + } finally { + if (vaultCollectionAbortRef.current === controller) vaultCollectionAbortRef.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. + if (!__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) return; + if (coreGeneration !== connectionGenerationRef.current) return; + environmentTemplateAbortRef.current?.abort(); + const controller = new AbortController(); + environmentTemplateAbortRef.current = controller; + try { + const catalog = await loadEnvironmentTemplateCatalog(core, controller.signal); + if ( + coreGeneration !== connectionGenerationRef.current || + environmentTemplateAbortRef.current !== controller + ) return; + setEnvironmentTemplates(catalog); + } catch (error) { + if ( + coreGeneration !== connectionGenerationRef.current || + environmentTemplateAbortRef.current !== controller || + isAbort(error) + ) return; + setEnvironmentTemplates({ state: "failed", message: errorMessage(error) }); + } finally { + if (environmentTemplateAbortRef.current === controller) environmentTemplateAbortRef.current = null; + } + }, [core, coreGeneration]); + + const refreshSession = useCallback( + async (sessionId: string, signal?: AbortSignal): Promise => { + if (coreGeneration !== connectionGenerationRef.current) return false; + const sessionScopeAgentId = sessionAgentFilterRef.current; + const request = (sessionRequestRef.current.get(sessionId) ?? 0) + 1; + const sessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; + const itemRevision = itemEventRevisionRef.current.get(sessionId) ?? 0; + const turnRevision = turnEventRevisionRef.current.get(sessionId) ?? 0; + const environmentRevision = environmentEventRevisionRef.current.get(sessionId) ?? 0; + sessionRequestRef.current.set(sessionId, request); + const turnRead = { coreGeneration, request, sessionId }; + void listAllTurns(core, sessionId, signal).then((sessionTurns) => { + const currentTurnRead = { + coreGeneration: connectionGenerationRef.current, + request: sessionRequestRef.current.get(sessionId) ?? 0, + sessionId, + selectedSessionId: selectedIdRef.current, + }; + if (!turnReadIsCurrent(turnRead, currentTurnRead)) return; + const liveRevisionChanged = turnRevision !== (turnEventRevisionRef.current.get(sessionId) ?? 0); + const currentTurnsSessionId = turnsSessionIdRef.current; + turnsSessionIdRef.current = sessionId; + setTurns((current) => ( + liveRevisionChanged + ? mergeDurableAndLiveTurns( + sessionTurns, + currentTurnsSessionId === sessionId ? current : [], + ) + : sessionTurns + )); + setTurnsSessionId(sessionId); + setTurnCollectionLoad({ sessionId, state: "ready", error: null }); + }).catch((error: unknown) => { + const currentTurnRead = { + coreGeneration: connectionGenerationRef.current, + request: sessionRequestRef.current.get(sessionId) ?? 0, + sessionId, + selectedSessionId: selectedIdRef.current, + }; + if (!turnReadIsCurrent(turnRead, currentTurnRead) || isAbort(error)) return; + setTurnCollectionLoad({ + sessionId, + state: "failed", + error: errorMessage(error), + }); + }); + try { + const [session, sessionItems] = await Promise.all([ + core.retrieveSession(sessionId, { signal }), + listAllItems(core, sessionId, signal), + ]); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== sessionRequestRef.current.get(sessionId) + ) return false; + if (sessionScopeAgentId && session.agent.id !== sessionScopeAgentId) { + throw new Error("Agent Core returned Session details outside the active Agent filter."); + } + const currentSessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; + const sessionIsCurrent = sessionRevision === currentSessionRevision; + const environmentIdentity = supportedEnvironmentIdentity(session.environment); + if (sessionIsCurrent) { + sessionEnvironmentIdentityRef.current.set(sessionId, environmentIdentity); + sessionCollectionRevisionRef.current += 1; + setSessions((current) => { + const found = current.some((value) => value.id === session.id); + const next = found + ? current.map((value) => (value.id === session.id ? session : value)) + : sessionScopeAgentId ? current : [session, ...current]; + sessionsRef.current = next; + return next; + }); + if ( + sessionScopeAgentId && + sessionAgentFilterRef.current === sessionScopeAgentId + ) { + setFilteredSessions((current) => { + if (!current.some((value) => value.id === session.id)) return current; + const next = current.map((value) => (value.id === session.id ? session : value)); + filteredSessionsRef.current = next; + return next; + }); + } + } + if (selectedIdRef.current === sessionId) { + const liveRevisionChanged = itemRevision !== (itemEventRevisionRef.current.get(sessionId) ?? 0); + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItems((current) => ( + liveRevisionChanged + ? mergeDurableAndLiveItems( + sessionItems, + currentItemsSessionId === sessionId ? current : [], + ) + : sessionItems + )); + setItemsSessionId(sessionId); + } + if (selectedIdRef.current === sessionId) { + setSelectedSessionLoad({ sessionId, state: "ready", error: null }); + } + + if (!sessionIsCurrent) return true; + if (!environmentIdentity) { + if (environmentRevision === (environmentEventRevisionRef.current.get(sessionId) ?? 0)) { + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + } + return true; + } + if (signal?.aborted || selectedIdRef.current !== sessionId) return false; + + const environmentStreamEpoch = streamEpochRef.current; + const environmentRequest = (environmentRequestRef.current.get(sessionId) ?? 0) + 1; + environmentRequestRef.current.set(sessionId, environmentRequest); + const environmentRead = { + coreGeneration, + sessionId, + ...environmentIdentity, + sessionRequest: request, + environmentRequest, + streamEpoch: environmentStreamEpoch, + sessionRevision, + environmentRevision, + }; + let observation: EnvironmentObservation; + try { + const resource = await core.retrieveEnvironment(environmentIdentity.environmentId, { signal }); + observation = environmentObservationFromResource(resource, environmentIdentity) + ?? unavailableEnvironmentObservation(environmentIdentity); + } catch (error) { + if (isAbort(error)) return false; + observation = unavailableEnvironmentObservation(environmentIdentity); + } + const currentEnvironmentIdentity = sessionEnvironmentIdentityRef.current.get(sessionId); + if (signal?.aborted || !environmentReadIsCurrent(environmentRead, { + coreGeneration: connectionGenerationRef.current, + sessionId, + environmentId: currentEnvironmentIdentity?.environmentId ?? "", + environmentType: currentEnvironmentIdentity?.environmentType ?? environmentIdentity.environmentType, + sessionRequest: sessionRequestRef.current.get(sessionId) ?? 0, + environmentRequest: environmentRequestRef.current.get(sessionId) ?? 0, + streamEpoch: streamEpochRef.current, + sessionRevision: sessionEventRevisionRef.current.get(sessionId) ?? 0, + environmentRevision: environmentEventRevisionRef.current.get(sessionId) ?? 0, + selectedSessionId: selectedIdRef.current, + })) return false; + setEnvironmentObservations((current) => { + const next = new Map(current); + const existing = current.get(sessionId); + const merged = existing?.streamEpoch === environmentStreamEpoch + ? mergeDurableEnvironmentObservation(existing.observation, observation) + : observation; + next.set(sessionId, { observation: merged, sessionId, streamEpoch: environmentStreamEpoch }); + return next; + }); + return true; + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + request === sessionRequestRef.current.get(sessionId) && + selectedIdRef.current === sessionId + ) { + if (isAbort(error)) return false; + const message = errorMessage(error); + setSelectedSessionLoad({ sessionId, state: "failed", error: message }); + notify(message, "error"); + } + return false; + } + }, + [core, coreGeneration, notify], + ); + + const refreshSelectedSession = useCallback((sessionId: string): Promise => { + if (selectedIdRef.current !== sessionId) return Promise.resolve(false); + selectedSessionReadAbortRef.current?.abort(); + const controller = new AbortController(); + // Turn pagination can outlive refreshSession's Session/Item result, so retain this + // controller until the next selected read, selection change, deletion, or Core change. + selectedSessionReadAbortRef.current = controller; + return refreshSession(sessionId, controller.signal); + }, [refreshSession]); + + const recoverSessionWorkspace = useCallback(() => { + void (async () => { + const generation = coreGeneration; + void refreshAgents(); + const refreshed = await refreshSessions(); + if (!refreshed || generation !== connectionGenerationRef.current) return; + const filter = sessionAgentFilterRef.current; + if (filter) await refreshFilteredSessions(filter); + if (generation !== connectionGenerationRef.current) return; + const sessionId = selectedIdRef.current; + if (sessionId) await refreshSelectedSession(sessionId); + })(); + }, [coreGeneration, refreshAgents, refreshFilteredSessions, refreshSelectedSession, refreshSessions]); + + const refreshDashboard = useCallback(() => { + void refreshAgents(); + void refreshSessions(); + void refreshVaults(); + void refreshEnvironmentTemplates(); + const filter = sessionAgentFilterRef.current; + if (filter) void refreshFilteredSessions(filter); + }, [refreshAgents, refreshEnvironmentTemplates, refreshFilteredSessions, refreshSessions, refreshVaults]); + + const changeSessionAgentFilter = useCallback((agentId: string | null) => { + if (sessionAgentFilterRef.current === agentId) return; + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + filteredSessionCollectionRequestRef.current += 1; + sessionAgentFilterRef.current = agentId; + filteredSessionsRef.current = []; + setFilteredSessions([]); + setFilteredSessionCollectionError(null); + setFilteredSessionCollectionState(agentId ? "connecting" : "ready"); + const current = selectedIdRef.current; + const nextSelectedId = agentId + ? null + : current && sessionsRef.current.some((session) => session.id === current) + ? current + : sessionsRef.current[0]?.id ?? null; + selectedIdRef.current = nextSelectedId; + setSelectedId(nextSelectedId); + setSessionAgentFilter(agentId); + }, []); + + useEffect(() => { + cancelFailureRef.current = undefined; + functionResultFailuresRef.current.clear(); + }, [coreGeneration, selectedId]); + + useEffect(() => { + setAgents([]); + setVaultCatalog(null); + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + setVaultSupported(null); + setSessions([]); + setAgentCollectionHasSnapshot(false); + setSessionCollectionHasSnapshot(false); + setItems([]); + setTurns([]); + setEnvironmentObservations(new Map()); + itemsSessionIdRef.current = null; + setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setSelectedId(null); + setEnvironmentTemplates(null); + void refreshAgents(); + void refreshSessions(); + void refreshVaults(); + void refreshEnvironmentTemplates(); + }, [refreshAgents, refreshEnvironmentTemplates, refreshSessions, refreshVaults]); + + useEffect(() => { + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + filteredSessionCollectionRequestRef.current += 1; + filteredSessionsRef.current = []; + setFilteredSessions([]); + setFilteredSessionCollectionError(null); + if (!sessionAgentFilter) { + setFilteredSessionCollectionState("ready"); + return; + } + setFilteredSessionCollectionState("connecting"); + void refreshFilteredSessions(sessionAgentFilter); + return () => { + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + }; + }, [refreshFilteredSessions, sessionAgentFilter]); + + useEffect(() => { + if (vaultSupported === false && view === "vaults") setView("dashboard"); + }, [vaultSupported, view]); + + useEffect(() => { + selectedSessionReadAbortRef.current?.abort(); + if (!selectedId) { + selectedSessionReadAbortRef.current = null; + setItems([]); + setTurns([]); + itemsSessionIdRef.current = null; + setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setStreamConnection({ sessionId: null, state: "idle", error: null }); + return; + } + setItems([]); + setTurns([]); + itemsSessionIdRef.current = selectedId; + setItemsSessionId(selectedId); + turnsSessionIdRef.current = selectedId; + setTurnsSessionId(selectedId); + setTurnCollectionLoad({ sessionId: selectedId, state: "loading", error: null }); + setSelectedSessionLoad({ sessionId: selectedId, state: "loading", error: null }); + environmentEventRevisionRef.current.set( + selectedId, + (environmentEventRevisionRef.current.get(selectedId) ?? 0) + 1, + ); + environmentRequestRef.current.set( + selectedId, + (environmentRequestRef.current.get(selectedId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(selectedId)) return current; + const next = new Map(current); + next.delete(selectedId); + return next; + }); + const creationOwner = creationStreamOwnerRef.current; + if ( + creationOwner && + creationOwner.coreGeneration === coreGeneration && + creationOwner.sessionId === selectedId && + !creationOwner.controller.signal.aborted + ) creationOwner.startReconciliation?.(); + else void refreshSelectedSession(selectedId); + return () => { + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + }; + }, [coreGeneration, refreshSelectedSession, selectedId]); + + const applyLiveSessionEvent = useCallback(( + event: SessionEvent, + context: LiveSessionEventContext, + ) => { + const { sessionId, streamEpoch, isCurrent, refreshCoordinator } = context; + if (!isCurrent()) return; + if (typeof event.session_id === "string" && event.session_id && event.session_id !== sessionId) return; + const eventType = typeof event.type === "string" ? event.type : ""; + const eventSession = matchingSessionSnapshot(event, sessionId); + if (eventSession) { + const nextEnvironmentIdentity = supportedEnvironmentIdentity(eventSession.environment); + const previousEnvironmentIdentity = sessionEnvironmentIdentityRef.current.get(sessionId); + if (!environmentIdentitiesMatch(previousEnvironmentIdentity, nextEnvironmentIdentity)) { + sessionEnvironmentIdentityRef.current.set(sessionId, nextEnvironmentIdentity); + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + } + } + const isEnvironmentEvent = eventType.startsWith("agent.session.environment."); + if (isEnvironmentEvent) { + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + const next = new Map(current); + const existing = current.get(sessionId); + const previous = existing?.streamEpoch === streamEpoch ? existing.observation : null; + const reduced = reduceEnvironmentObservation( + previous, + event, + sessionId, + sessionEnvironmentIdentityRef.current.get(sessionId) ?? null, + ); + if (reduced) next.set(sessionId, { observation: reduced, sessionId, streamEpoch }); + else next.delete(sessionId); + return next; + }); + } + if (eventSession) { + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + sessionCollectionRevisionRef.current += 1; + setSessions((current) => { + const next = current.map((session) => (session.id === eventSession.id ? eventSession : session)); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + if (!current.some((session) => session.id === eventSession.id)) return current; + const next = current.map((session) => (session.id === eventSession.id ? eventSession : session)); + filteredSessionsRef.current = next; + return next; + }); + } + const eventTurn = matchingTurnSnapshot(event, sessionId); + if (eventTurn) { + turnEventRevisionRef.current.set( + sessionId, + (turnEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + if (selectedIdRef.current === sessionId) { + const currentTurnsSessionId = turnsSessionIdRef.current; + turnsSessionIdRef.current = sessionId; + setTurnsSessionId(sessionId); + setTurns((current) => upsertTurn( + currentTurnsSessionId === sessionId ? current : [], + eventTurn, + )); + } + } + const isCommandOutputDelta = eventType === "agent.output.command_execution_output.delta"; + if (event.item || eventType.includes(".output_text.") || isCommandOutputDelta) { + itemEventRevisionRef.current.set( + sessionId, + (itemEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + } + if (event.item && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => { + const sessionItems = updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (value) => value, + ); + const withoutTemporary = sessionItems.filter((item) => ( + event.item?.type !== "message" || event.item.role !== "assistant" || !item.id.startsWith(`stream:${event.item.turn_id}:`) + )); + return upsertSessionItem(withoutTemporary, event.item as SessionItem); + }); + } + if (eventType.includes(".output_text.") && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (sessionItems) => projectTextEvent(sessionItems, event), + )); + } + if (isCommandOutputDelta && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (sessionItems) => appendCommandOutputDelta(sessionItems, event), + )); + } + refreshCoordinator?.accept(event); + }, []); + + useEffect(() => { + selectedStreamAbortRef.current?.abort(); + const creationOwner = creationStreamOwnerRef.current; + if (!selectedId) { + creationOwner?.controller.abort(); + if (creationStreamOwnerRef.current === creationOwner) creationStreamOwnerRef.current = null; + return; + } + if ( + creationOwner && + !creationOwner.controller.signal.aborted && + creationOwner.coreGeneration === coreGeneration && + creationOwner.sessionId === selectedId + ) return; + if (creationOwner?.sessionId && creationOwner.sessionId !== selectedId) { + creationOwner.controller.abort(); + if (creationStreamOwnerRef.current === creationOwner) creationStreamOwnerRef.current = null; + } + + const sessionId = selectedId; + const controller = new AbortController(); + selectedStreamAbortRef.current = controller; + const streamEpoch = streamEpochRef.current + 1; + streamEpochRef.current = streamEpoch; + setStreamConnection({ sessionId, state: "connecting", error: null }); + let reconnectAttempt = 0; + const recovery = createStreamRecoveryBuffer(); + let refreshCoordinator: DurableRefreshCoordinator | null = null; + let recoveryPromise: Promise | null = null; + + const isCurrentStream = () => ( + !controller.signal.aborted && + coreGeneration === connectionGenerationRef.current && + streamEpoch === streamEpochRef.current && + selectedIdRef.current === sessionId + ); + + const setCurrentStreamState = (state: StreamState, error: string | null = null) => { + if (!isCurrentStream()) return; + setStreamConnection((current) => ( + current.sessionId === sessionId ? { ...current, state, error } : current + )); + }; + + const applyEvent = (event: SessionEvent) => applyLiveSessionEvent(event, { + sessionId, + streamEpoch, + isCurrent: isCurrentStream, + refreshCoordinator, + }); + + refreshCoordinator = createDurableRefreshCoordinator( + () => refreshSession(sessionId, controller.signal), + ); + + const listen = async () => { + while (isCurrentStream()) { + let openedAt: number | null = null; + let receivedEvent = false; + try { + await core.streamEvents(sessionId, { + signal: controller.signal, + onOpen: () => { + if (!isCurrentStream()) return; + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + openedAt = beginStreamReconciliation( + isCurrentStream, + () => setCurrentStreamState("listening"), + () => { + const token = recovery.begin(); + recoveryPromise = refreshSession(sessionId, controller.signal) + .finally(() => recovery.finish(token, isCurrentStream, applyEvent)); + }, + ); + }, + onEvent: (event) => { + if (!isCurrentStream()) return; + receivedEvent = true; + recovery.accept(event, applyEvent); + }, + }); + } catch (error) { + if (!isCurrentStream() || isAbort(error)) return; + if (!shouldRetryStreamError(error)) { + setCurrentStreamState("failed", errorMessage(error)); + const status = error instanceof AgentCoreError ? ` (${error.status})` : ""; + notify( + `Live event stream was rejected by Agent Core${status}. Check the Core connection settings.`, + "error", + ); + return; + } + } + + if (!isCurrentStream()) return; + await recoveryPromise; + recoveryPromise = null; + if (!isCurrentStream()) return; + if (streamConnectionWasStable(openedAt, receivedEvent)) reconnectAttempt = 0; + setCurrentStreamState("recovering"); + + const delay = streamReconnectDelay(reconnectAttempt); + reconnectAttempt += 1; + if (!(await waitForStreamReconnect(delay, controller.signal))) return; + } + }; + + void listen(); + + return () => { + refreshCoordinator?.dispose(); + recovery.invalidate(); + controller.abort(); + if (selectedStreamAbortRef.current === controller) selectedStreamAbortRef.current = null; + }; + }, [applyLiveSessionEvent, core, coreGeneration, notify, refreshSession, selectedId, streamRetryRevision]); + + const retryCurrentStream = useCallback(() => { + requestCurrentStreamRetry(selectedIdRef.current, (sessionId) => { + setStreamConnection({ sessionId, state: "connecting", error: null }); + setStreamRetryRevision((current) => current + 1); + }); + }, []); + + const run = async (operation: () => Promise, success?: string): Promise => { + if (coreGeneration !== connectionGenerationRef.current) return undefined; + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + try { + const result = await operation(); + if (coreGeneration !== connectionGenerationRef.current) return undefined; + if (success) notify(success, "success"); + return result; + } catch (error) { + if (coreGeneration !== connectionGenerationRef.current) return undefined; + notify(errorMessage(error), "error"); + throw error; + } finally { + if ( + coreGeneration === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); + } + }; + + const createAgent = async (input: CreateAgentInput) => { + const agent = await run(() => core.createAgent(input), "Agent created."); + if (!agent || coreGeneration !== connectionGenerationRef.current) return undefined; + agentCollectionRevisionRef.current += 1; + setAgents((current) => [agent, ...current]); + return agent; + }; + + const retrieveAgent = async (agentId: string) => { + return run(() => requestAgentDetail(core, agentId)); + }; + + const updateAgent = async (agentId: string, input: UpdateAgentInput) => { + const agent = await run(() => requestAgentUpdate(core, agentId, input), "Agent updated."); + if (!agent || coreGeneration !== connectionGenerationRef.current) return undefined; + agentCollectionRevisionRef.current += 1; + setAgents((current) => replaceSavedAgent(current, agent)); + return agent; + }; + + const deleteAgent = async (agentId: string) => { + const deleted = await run(() => requestAgentDelete(core, agentId), "Agent deleted."); + if (!deleted || coreGeneration !== connectionGenerationRef.current) return; + agentCollectionRevisionRef.current += 1; + setAgents((current) => removeSavedAgent(current, agentId)); + }; + + const runVaultMutation = async (operation: () => Promise): Promise => { + if (coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Core connection changed before the Vault operation started."); + } + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + try { + const result = await operation(); + if (coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Core connection changed before the Vault operation was confirmed."); + } + return result; + } finally { + if ( + coreGeneration === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); + } + }; + + const refreshAfterVaultMutation = async () => { + if (coreGeneration === connectionGenerationRef.current) await refreshVaults(); + }; + + const vaultOperations: VaultOperations = { + async createVault(name, metadata) { + try { + await runVaultMutation(() => requestVaultCreate(core, name, metadata)); + notify("Vault created.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async createCredential(vaultId, name, serverURL, token) { + try { + const created = await runVaultMutation(() => core.createVaultCredential(vaultId, { + name, + auth: { type: "static_bearer", mcp_server_url: serverURL, token }, + })); + if (created.vault_id !== vaultId || created.name !== name || created.auth.mcp_server_url !== serverURL) { + throw new Error("Agent Core returned mismatched Credential metadata."); + } + notify("Credential created. Token remains hidden.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async replaceCredential(vaultId, credentialId, token) { + const baseline = vaultCatalog?.credentials.find((credential) => ( + credential.vault_id === vaultId && credential.id === credentialId + )); + if (!baseline || vaultCollectionState !== "ready") { + throw new Error("The latest Credential metadata is unavailable. Refresh before replacing its token."); + } + try { + const updated = await runVaultMutation(() => core.replaceVaultCredentialToken(vaultId, credentialId, { + auth: { type: "static_bearer", token }, + })); + if ( + updated.id !== baseline.id || updated.vault_id !== baseline.vault_id || + updated.name !== baseline.name || updated.auth.mcp_server_url !== baseline.auth.mcp_server_url || + updated.created_at !== baseline.created_at || updated.updated_at < baseline.updated_at + ) throw new Error("Agent Core returned mismatched Credential metadata after replacement."); + notify("Credential token replaced. Running work may still hold the previous token.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async deleteCredential(vaultId, credentialId) { + try { + await runVaultMutation(() => core.deleteVaultCredential(vaultId, credentialId)); + notify("Credential deleted. Provider-side token was not revoked.", "success"); + } catch (error) { + let confirmedDeleted = false; + try { + await core.retrieveVaultCredential(vaultId, credentialId); + } catch (readError) { + confirmedDeleted = readError instanceof AgentCoreError && readError.status === 404; + } + await refreshAfterVaultMutation(); + if (confirmedDeleted) { + notify("Credential deletion reconciled from Core.", "success"); + return; + } + throw error; + } + await refreshAfterVaultMutation(); + }, + async deleteVault(vaultId) { + try { + await runVaultMutation(() => core.deleteVault(vaultId)); + notify("Vault and its Credentials deleted. Provider-side tokens were not revoked.", "success"); + } catch (error) { + let confirmedDeleted = false; + try { + await core.retrieveVault(vaultId); + } catch (readError) { + confirmedDeleted = readError instanceof AgentCoreError && readError.status === 404; + } + await refreshAfterVaultMutation(); + if (confirmedDeleted) { + notify("Vault deletion reconciled from Core.", "success"); + return; + } + throw error; + } + await refreshAfterVaultMutation(); + }, + refresh() { + void refreshVaults(); + }, + }; + + const createEnvironmentTemplate = async (input: CreateEnvironmentTemplateInput) => { + if (!__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) { + throw new Error("Managed Environment configuration is not enabled for this Web build."); + } + const template = await core.createEnvironmentTemplate(input); + // Re-read the durable collection so selection uses Core's own listing. + await refreshEnvironmentTemplates(); + notify("Environment Template saved. No Runtime was allocated.", "success"); + return template; + }; + + const createSession = async (input: SessionStartInput) => { + const submittedAgent = validateSessionAgentSubmission( + input.agentMode, + input.agentId, + input.agent, + agents, + sessionVaultCatalog, + ); + if (submittedAgent.error || !submittedAgent.effectiveAgent || !submittedAgent.requestAgent && input.agentMode === "inline") { + const error = new Error(`Session was not created. ${submittedAgent.error ?? "The Agent request is invalid."}`); + notify(error.message, "error"); + throw error; + } + const effectiveAgent = submittedAgent.effectiveAgent; + const vaultPlan = deriveSessionVaultPlan( + effectiveAgent, + sessionVaultCatalog, + input.manualVaultIds, + ); + const expectedVaultIds = [...input.vaultIds].sort(); + if ( + vaultPlan.blocker || + vaultPlan.vaultIds.length !== expectedVaultIds.length || + vaultPlan.vaultIds.some((vaultId, index) => vaultId !== expectedVaultIds[index]) + ) { + const error = new Error(`Session was not created. ${vaultPlan.blocker ?? "The derived Vault attachments changed before submission."}`); + notify(error.message, "error"); + throw error; + } + if (input.environment.type === "self_hosted" && !__AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS__) { + const error = new Error("Session was not created. Self-hosted Sessions are not enabled for this Web build."); + notify(error.message, "error"); + throw error; + } + if (input.environment.type === "openai_hosted" && !__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) { + const error = new Error("Session was not created. Managed hosted Sessions are not enabled for this Web build."); + notify(error.message, "error"); + throw error; + } + const normalizedEnvironment = normalizeSessionEnvironmentInput(input.environment); + const environmentInput = normalizedEnvironment.input; + if (!environmentInput) { + const error = new Error(`Session was not created. ${normalizedEnvironment.error ?? "The Environment input is invalid."}`); + notify(error.message, "error"); + throw error; + } + const environmentAdmissionBlocker = sessionEnvironmentAdmissionBlocker(effectiveAgent, environmentInput.type); + if (environmentAdmissionBlocker) { + const error = new Error(`Session was not created. ${environmentAdmissionBlocker}`); + notify(error.message, "error"); + throw error; + } + const request = sessionCreateRequestPayload({ + ...(input.agentMode === "saved" ? { agentId: input.agentId } : {}), + ...(submittedAgent.requestAgent ? { agent: submittedAgent.requestAgent } : {}), + environment: environmentInput, + ...(input.input !== undefined ? { input: input.input } : {}), + metadata: input.metadata, + stream: input.stream, + vaultIds: vaultPlan.vaultIds, + }); + + const openSession = (session: AgentSession) => { + sessionCollectionRevisionRef.current += 1; + sessionEnvironmentIdentityRef.current.set(session.id, supportedEnvironmentIdentity(session.environment)); + setSessions((current) => { + const next = [session, ...current.filter((value) => value.id !== session.id)]; + sessionsRef.current = next; + return next; + }); + changeSessionAgentFilter(null); + selectedIdRef.current = session.id; + setSelectedId(session.id); + setItems([]); + itemsSessionIdRef.current = session.id; + setItemsSessionId(session.id); + setTurns([]); + turnsSessionIdRef.current = session.id; + setTurnsSessionId(session.id); + setView("sessions"); + }; + + if (!input.stream) { + const session = await run(() => core.createSession( + { ...request, stream: false }, + input.idempotencyKey, + )); + if (!session || coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Session creation outcome could not be confirmed."); + } + openSession(session); + notify("Idle Session created. Opening live events…", "success"); + return; + } + + const generation = coreGeneration; + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + creationStreamOwnerRef.current?.controller.abort(); + const owner: CreationStreamOwner = { + controller: new AbortController(), + coreGeneration: generation, + sessionId: null, + streamEpoch: null, + }; + creationStreamOwnerRef.current = owner; + const recovery = createStreamRecoveryBuffer(); + let refreshCoordinator: DurableRefreshCoordinator | null = null; + let recoveryPromise: Promise | null = null; + let resolveCreated: (session: AgentSession) => void = () => undefined; + let rejectCreated: (error: unknown) => void = () => undefined; + const created = new Promise((resolve, reject) => { + resolveCreated = resolve; + rejectCreated = reject; + }); + const isCurrentCreationStream = () => ( + creationStreamOwnerRef.current === owner && + !owner.controller.signal.aborted && + generation === connectionGenerationRef.current && + owner.sessionId !== null && + owner.streamEpoch !== null && + streamEpochRef.current === owner.streamEpoch && + selectedIdRef.current === owner.sessionId + ); + const applyCreationEvent = (event: SessionEvent) => { + if (!owner.sessionId || owner.streamEpoch === null) return; + applyLiveSessionEvent(event, { + sessionId: owner.sessionId, + streamEpoch: owner.streamEpoch, + isCurrent: isCurrentCreationStream, + refreshCoordinator, + }); + }; + const handoffToGetStream = async () => { + const sessionId = owner.sessionId; + if (!sessionId) return; + owner.startReconciliation?.(); + await recoveryPromise; + if (!isCurrentCreationStream()) return; + setStreamConnection({ sessionId, state: "recovering", error: null }); + await refreshSession(sessionId, owner.controller.signal); + if (!isCurrentCreationStream()) return; + refreshCoordinator?.dispose(); + recovery.invalidate(); + if (creationStreamOwnerRef.current === owner) creationStreamOwnerRef.current = null; + owner.controller.abort(); + setStreamRetryRevision((current) => current + 1); + }; + + void core.createSessionStream(request, input.idempotencyKey, { + signal: owner.controller.signal, + onSession: (session) => { + if ( + creationStreamOwnerRef.current !== owner || + owner.controller.signal.aborted || + generation !== connectionGenerationRef.current + ) return; + owner.sessionId = session.id; + owner.streamEpoch = streamEpochRef.current + 1; + streamEpochRef.current = owner.streamEpoch; + const token = recovery.begin(); + refreshCoordinator = createDurableRefreshCoordinator( + () => refreshSession(session.id, owner.controller.signal), + ); + owner.startReconciliation = () => { + if (recoveryPromise || !isCurrentCreationStream()) return; + recoveryPromise = refreshSession(session.id, owner.controller.signal) + .finally(() => recovery.finish(token, isCurrentCreationStream, applyCreationEvent)); + }; + openSession(session); + setStreamConnection({ sessionId: session.id, state: "listening", error: null }); + owner.startReconciliation(); + resolveCreated(session); + }, + onOpen: () => undefined, + onEvent: (event) => { + if (event.type === "agent.session.created") return; + recovery.accept(event, applyCreationEvent); + }, + }).then( + () => handoffToGetStream(), + (error: unknown) => { + if (!owner.sessionId) { + recovery.invalidate(); + refreshCoordinator?.dispose(); + if (creationStreamOwnerRef.current === owner) creationStreamOwnerRef.current = null; + owner.controller.abort(); + rejectCreated(error); + return; + } + return handoffToGetStream(); + }, + ).catch((error: unknown) => { + if ( + generation === connectionGenerationRef.current && + owner.sessionId && + selectedIdRef.current === owner.sessionId + ) { + setStreamConnection({ sessionId: owner.sessionId, state: "failed", error: errorMessage(error) }); + } + }); + + try { + await created; + if (generation !== connectionGenerationRef.current) { + throw new Error("The Session creation outcome could not be confirmed after the Core connection changed."); + } + notify( + input.input === undefined + ? input.environment.type === "openai_hosted" + ? "Managed hosted Session created. Live creation events are connected." + : "Idle Session created. Live creation events are connected." + : "Session created with initial input. Live creation events are connected.", + "success", + ); + } catch (error) { + if (generation === connectionGenerationRef.current) notify(errorMessage(error), "error"); + throw error; + } finally { + if ( + generation === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); + } + }; + + const retrieveSessionForAction = useCallback(async (sessionId: string) => { + const generation = coreGeneration; + try { + const session = await requestSessionDetail(core, sessionId); + return generation === connectionGenerationRef.current ? session : undefined; + } catch (error) { + if (generation !== connectionGenerationRef.current) return undefined; + throw error; + } + }, [core, coreGeneration]); + + const updateSessionMetadata = async ( + sessionId: string, + baselineMetadata: Record, + draftMetadata: Record, + ): Promise => { + const generation = coreGeneration; + let updated: AgentSession; + try { + updated = await requestSessionUpdate(core, sessionId, baselineMetadata, draftMetadata); + } catch (error) { + if (generation !== connectionGenerationRef.current) return undefined; + if (error instanceof SessionMetadataConflictError && error.latestSession) { + sessionCollectionRevisionRef.current += 1; + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setSessions((current) => { + const next = replaceSessionMetadata(current, error.latestSession as AgentSession); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = replaceSessionMetadata(current, error.latestSession as AgentSession); + filteredSessionsRef.current = next; + return next; + }); + } + throw error; + } + if (generation !== connectionGenerationRef.current) { + notify("The previous Core returned a Session update after the connection changed. The current Core view was not modified.", "error"); + return undefined; + } + sessionCollectionRevisionRef.current += 1; + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setSessions((current) => { + const next = replaceSessionMetadata(current, updated); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = replaceSessionMetadata(current, updated); + filteredSessionsRef.current = next; + return next; + }); + notify("Session metadata updated.", "success"); + return updated; + }; + + const removeSessionFromWorkspace = (sessionId: string, message: string): boolean => { + const selectedAtCompletion = selectedIdRef.current; + const deletingSelected = selectedAtCompletion === sessionId; + const nextSelectedId = selectionAfterSessionDelete( + sessionAgentFilterRef.current ? filteredSessionsRef.current : sessionsRef.current, + selectedAtCompletion, + sessionId, + ); + const increment = (revisions: Map) => { + revisions.set(sessionId, (revisions.get(sessionId) ?? 0) + 1); + }; + sessionCollectionRevisionRef.current += 1; + increment(sessionRequestRef.current); + increment(sessionEventRevisionRef.current); + increment(itemEventRevisionRef.current); + increment(turnEventRevisionRef.current); + increment(environmentEventRevisionRef.current); + increment(environmentRequestRef.current); + sessionEnvironmentIdentityRef.current.delete(sessionId); + + setSessions((current) => { + const next = removeSession(current, sessionId); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = removeSession(current, sessionId); + filteredSessionsRef.current = next; + return next; + }); + setSessionSendFailures((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + + if (deletingSelected) { + selectedIdRef.current = nextSelectedId; + streamEpochRef.current += 1; + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + selectedStreamAbortRef.current?.abort(); + selectedStreamAbortRef.current = null; + if (creationStreamOwnerRef.current?.sessionId === sessionId) { + creationStreamOwnerRef.current.controller.abort(); + creationStreamOwnerRef.current = null; + } + itemsSessionIdRef.current = null; + turnsSessionIdRef.current = null; + setItems([]); + setItemsSessionId(null); + setTurns([]); + setTurnsSessionId(null); + setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setStreamConnection({ sessionId: null, state: "idle", error: null }); + setSelectedId(nextSelectedId); + } + notify(message, "success"); + return true; + }; + + const deleteSessionFromCore = async (sessionId: string): Promise => { + const generation = coreGeneration; + try { + await requestSessionDelete(core, sessionId); + } catch (error) { + if (generation !== connectionGenerationRef.current) return false; + if (!(error instanceof SessionActionError) || error.kind !== "unknown_write") throw error; + + const reconciliation = await reconcileUnknownSessionDelete(core, sessionId); + if (generation !== connectionGenerationRef.current) return false; + if (reconciliation.state === "missing") { + return removeSessionFromWorkspace( + sessionId, + "Session is absent from Agent Core after reconciling the unknown deletion result.", + ); + } + if (reconciliation.state === "unknown") { + throw new SessionActionError( + `${error.message} The follow-up durable Session refresh also failed, so the result remains unknown.`, + "unknown_write", + { cause: error }, + ); + } + throw new SessionActionError( + `${error.message} A follow-up durable Session refresh confirmed that the Session is still present.`, + "request_failed", + { cause: error }, + ); + } + if (generation !== connectionGenerationRef.current) { + notify("The previous Core confirmed Session deletion after the connection changed. The current Core view was not modified.", "error"); + return false; + } + return removeSessionFromWorkspace(sessionId, "Session deleted from Agent Core."); + }; + + const sendMessage = async (text: string) => { + const sessionId = selectedId; + if (!sessionId) return; + if (!streamReady) { + const message = "Wait for the live event stream to connect before sending."; + notify(message, "error"); + throw new Error(message); + } + const previousFailure = sessionSendFailures.get(sessionId); + const pending = beginPendingSend(sessionId, text, previousFailure); + setSessionSendFailures((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + try { + await run(() => core.sendMessage(sessionId, text, pending.idempotencyKey)); + } catch (error) { + if (coreGeneration === connectionGenerationRef.current) { + setSessionSendFailures((current) => { + const next = new Map(current); + next.set(sessionId, failPendingSend(pending, error, errorMessage(error))); + return next; + }); + } + throw error; + } + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + setSessionSendFailures((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + await refreshSelectedSession(sessionId); + }; + + const cancel = async () => { + const sessionId = selectedId; + if (!sessionId) return; + const pending = beginPendingSend( + sessionId, + "agent.session.input.cancel", + cancelFailureRef.current, + ); + cancelFailureRef.current = undefined; + try { + await run( + () => core.cancelTurn(sessionId, pending.idempotencyKey), + "Cancellation requested.", + ); + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + selectedIdRef.current === sessionId + ) cancelFailureRef.current = failPendingSend(pending, error, errorMessage(error)); + throw error; + } + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + cancelFailureRef.current = undefined; + await refreshSelectedSession(sessionId); + }; + + const submitFunctionResult = async (input: FunctionResultInput) => { + const sessionId = selectedId; + if (!sessionId) return; + const actionKey = functionResultActionKey(sessionId, input); + const pending = beginPendingFunctionResult( + sessionId, + input, + functionResultFailuresRef.current.get(actionKey), + ); + functionResultFailuresRef.current.delete(actionKey); + try { + await run( + () => core.submitFunctionResult(sessionId, input, pending.idempotencyKey), + "Function result submitted.", + ); + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + selectedIdRef.current === sessionId + ) { + functionResultFailuresRef.current.set( + actionKey, + failPendingFunctionResult(pending, error, errorMessage(error)), + ); + } + throw error; + } + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + functionResultFailuresRef.current.delete(actionKey); + await refreshSelectedSession(sessionId); + }; + + const listEnvironmentFiles = useCallback(( + environmentId: string, + options: Parameters[1], + ) => core.listEnvironmentFiles(environmentId, options), [core]); + + const createEnvironmentFile = useCallback(( + environmentId, + input, + options, + ) => core.createEnvironmentFile(environmentId, input, options), [core]); + + const applyConnection = (next: CoreConnection) => { + const normalized = { ...next, baseUrl: next.baseUrl.trim() || "/v1", token: next.token.trim() }; + connectionGenerationRef.current += 1; + agentCollectionRequestRef.current += 1; + sessionCollectionRequestRef.current += 1; + filteredSessionCollectionRequestRef.current += 1; + vaultCollectionRequestRef.current += 1; + agentCollectionRevisionRef.current = 0; + sessionCollectionRevisionRef.current = 0; + sessionRequestRef.current.clear(); + sessionEventRevisionRef.current.clear(); + itemEventRevisionRef.current.clear(); + turnEventRevisionRef.current.clear(); + environmentEventRevisionRef.current.clear(); + environmentRequestRef.current.clear(); + sessionEnvironmentIdentityRef.current.clear(); + operationRequestRef.current += 1; + streamEpochRef.current += 1; + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + selectedStreamAbortRef.current?.abort(); + selectedStreamAbortRef.current = null; + creationStreamOwnerRef.current?.controller.abort(); + creationStreamOwnerRef.current = null; + agentCollectionAbortRef.current?.abort(); + agentCollectionAbortRef.current = null; + sessionCollectionAbortRef.current?.abort(); + sessionCollectionAbortRef.current = null; + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + vaultCollectionAbortRef.current?.abort(); + vaultCollectionAbortRef.current = null; + environmentTemplateAbortRef.current?.abort(); + environmentTemplateAbortRef.current = null; + setEnvironmentTemplates(null); + saveConnection(normalized); + setBusy(false); + setAgentCollectionState("connecting"); + setAgentCollectionError(null); + setAgentCollectionHasSnapshot(false); + setSessionCollectionState("connecting"); + setSessionCollectionError(null); + setSessionCollectionHasSnapshot(false); + sessionAgentFilterRef.current = null; + filteredSessionsRef.current = []; + setSessionAgentFilter(null); + setFilteredSessions([]); + setFilteredSessionCollectionState("connecting"); + setFilteredSessionCollectionError(null); + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + setVaultSupported(null); + setVaultCatalog(null); + setAgents([]); + setSessions([]); + setItems([]); + setSelectedId(null); + setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setStreamConnection({ sessionId: null, state: "idle", error: null }); + setSessionSendFailures(new Map()); + cancelFailureRef.current = undefined; + functionResultFailuresRef.current.clear(); + setEnvironmentObservations(new Map()); + itemsSessionIdRef.current = null; + setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurns([]); + setConnection(normalized); + setConnectionOpen(false); + }; + + const openAgentSetup = () => { + setView("agents"); + agentCreateSequenceRef.current += 1; + setAgentCreateRequest(agentCreateSequenceRef.current); + }; + + const openSessionSetup = (agentId?: string) => { + if (agentId && !agents.some((candidate) => candidate.id === agentId)) { + notify("Session setup could not open because the selected saved Agent is not loaded.", "error"); + return; + } + setView("sessions"); + sessionCreateSequenceRef.current += 1; + setSessionCreateRequest({ agentId: agentId ?? null, requestId: sessionCreateSequenceRef.current }); + }; + + const consumeAgentCreateRequest = useCallback((request: number) => { + setAgentCreateRequest((current) => current === request ? null : current); + }, []); + + const consumeSessionCreateRequest = useCallback((request: number) => { + setSessionCreateRequest((current) => current?.requestId === request ? null : current); + }, []); + + return ( +
+ Skip to main content + + +
+
+ openSessionSetup()} + /> +
+
+ {view === "dashboard" ? ( + openSessionSetup()} + onViewAgents={() => setView("agents")} + onViewSessions={() => setView("sessions")} + onConfigureConnection={() => setConnectionOpen(true)} + onOpenSession={(sessionId) => { + changeSessionAgentFilter(null); + selectedIdRef.current = sessionId; + setSelectedId(sessionId); + setView("sessions"); + }} + /> + ) : null} + {view === "sessions" ? ( + { + if (selectedId) void refreshSelectedSession(selectedId); + }} + onRetryStream={retryCurrentStream} + onRetrieveSession={retrieveSessionForAction} + onSelect={setSelectedId} + onSend={sendMessage} + onUpdateSession={updateSessionMetadata} + /> + ) : null} + {view === "agents" ? ( + void refreshAgents()} + onRetrieve={retrieveAgent} + onStartSession={openSessionSetup} + onUpdate={updateAgent} + /> + ) : null} + {view === "vaults" && vaultSupported === true ? ( + + ) : null} + {view === "system" ? ( + + ) : null} +
+
+ + setConnectionOpen(false)} + onSave={applyConnection} + /> +
+ ); +} diff --git a/apps/web/src/components/ConnectionModal.css b/apps/web/src/components/ConnectionModal.css new file mode 100644 index 000000000..c48b60cf3 --- /dev/null +++ b/apps/web/src/components/ConnectionModal.css @@ -0,0 +1,432 @@ +.connection-modal-modes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + padding: 0; + gap: 8px; + border: 0; +} + +.connection-modal-modes legend { + margin-bottom: 6px; + padding: 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 16px; +} + +.connection-modal-modes label { + display: flex; + min-width: 0; + padding: 9px 10px; + align-items: flex-start; + gap: 8px; + cursor: pointer; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-modes label.selected { + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); +} + +.connection-modal-modes input { + flex: 0 0 auto; + margin: 2px 0 0; + accent-color: var(--accent); +} + +.connection-modal-modes label > span { + display: flex; + min-width: 0; + flex-direction: column; +} + +.connection-modal-modes strong, +.connection-modal-modes small { + overflow-wrap: anywhere; +} + +.connection-modal-modes strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +.connection-modal-modes small { + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-modal-local { + margin-top: 12px; + padding: 12px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-local .connection-guide-header > svg { + color: var(--success); +} + +.connection-modal-local dl { + margin: 10px 0 0; +} + +.connection-modal-local dl > div { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + padding: 5px 0; + gap: 8px; + border-top: 1px solid var(--line); +} + +.connection-modal-local dt { + color: var(--fg-muted); +} + +.connection-modal-local dd { + min-width: 0; + margin: 0; + color: var(--fg); + overflow-wrap: anywhere; +} + +.connection-modal-local code { + padding: 1px 4px; + color: var(--fg); + font-family: var(--font-mono); + font-size: 11px; + background: var(--surface-muted); + border-radius: 4px; +} + +.connection-modal-local-note { + margin: 8px 0 0; +} + +.connection-docker-guide { + margin-top: 10px; + padding: 12px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-docker-guide .connection-guide-header > svg { + color: var(--accent); +} + +.connection-docker-path { + margin-top: 12px; + padding: 10px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-docker-path-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.connection-docker-path-heading strong { + color: var(--fg); + font-size: 12px; + font-weight: 550; +} + +.connection-docker-path-heading span { + color: var(--fg-muted); + font-size: 10px; +} + +.connection-docker-path-heading code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; +} + +.connection-docker-steps { + display: grid; + margin: 8px 0 0; + padding: 0; + gap: 10px; + counter-reset: docker-step; + list-style: none; +} + +.connection-docker-first-use > p { + margin: 8px 0 0; +} + +.connection-docker-first-steps { + display: grid; + margin: 9px 0 0; + padding-left: 20px; + gap: 8px; +} + +.connection-docker-first-steps > li { + padding-left: 2px; +} + +.connection-docker-first-steps > li > span { + display: grid; + gap: 1px; +} + +.connection-docker-first-steps strong { + color: var(--fg); + font-size: 11px; + font-weight: 550; +} + +.connection-docker-first-steps small { + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; +} + +.connection-docker-first-steps .connection-docker-command { + margin-top: 5px; +} + +.connection-docker-first-links { + display: flex; + margin-top: 10px; + align-items: flex-start; + flex-direction: column; + gap: 5px; +} + +.connection-docker-first-links a { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--accent-strong); + text-decoration: none; +} + +.connection-docker-first-links a:hover { + text-decoration: underline; +} + +.connection-docker-steps > li { + display: grid; + min-width: 0; + padding-top: 10px; + gap: 6px; + border-top: 1px solid var(--line); + counter-increment: docker-step; +} + +.connection-docker-steps > li > span { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 7px; +} + +.connection-docker-steps > li > span::before { + display: grid; + width: 18px; + height: 18px; + grid-row: 1 / span 2; + place-items: center; + color: var(--accent); + font-family: var(--font-mono); + font-size: 10px; + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + border-radius: 999px; + content: counter(docker-step); +} + +.connection-docker-steps strong, +.connection-docker-unconfigured strong { + color: var(--fg); + font-size: 12px; + font-weight: 550; +} + +.connection-docker-steps small { + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-docker-command { + display: flex; + min-width: 0; + align-items: center; + padding: 7px 7px 7px 9px; + gap: 8px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; +} + +.connection-docker-command code { + min-width: 0; + flex: 1; + overflow-x: auto; + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; + line-height: 15px; + white-space: nowrap; +} + +.connection-docker-command .button { + min-height: 26px; + flex: 0 0 auto; + padding: 3px 7px; + font-size: 10px; +} + +.connection-docker-unconfigured { + margin-top: 10px; + padding: 9px 10px; + background: var(--surface); + border: 1px dashed var(--line-strong); + border-radius: 5px; +} + +.connection-docker-unconfigured p, +.connection-docker-boundary { + margin: 3px 0 0; +} + +.connection-docker-unconfigured code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 10px; +} + +.connection-docker-boundary { + padding-top: 9px; + border-top: 1px solid var(--line); +} + +.connection-modal-advanced { + margin-top: 12px; +} + +.connection-modal-test { + display: flex; + margin-top: 12px; + padding: 10px 0; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.connection-modal-test > div { + min-width: 0; +} + +.connection-modal-test strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; +} + +.connection-modal-test p { + margin: 2px 0 0; + color: var(--fg-muted); + font-size: 11px; + line-height: 15px; +} + +.connection-modal-test .button { + flex: 0 0 auto; +} + +.connection-modal-probe-result { + margin-top: 10px; + padding: 9px 10px; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 6px; +} + +.connection-modal-probe-result.succeeded { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); +} + +.connection-modal-probe-result.failed { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); +} + +.connection-modal-probe-result strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; +} + +.connection-modal-probe-result.failed strong { + color: var(--danger); +} + +.connection-modal-probe-result p { + margin: 2px 0 0; +} + +.connection-modal-probe-result small { + display: block; + margin-top: 4px; + color: var(--fg-muted); + font-size: 11px; +} + +@media (max-width: 520px) { + .connection-modal-modes { + grid-template-columns: 1fr; + } + + .connection-modal-test { + align-items: stretch; + flex-direction: column; + } + + .connection-modal-test .button { + width: 100%; + } + + .connection-modal-local dl > div { + grid-template-columns: 1fr; + gap: 2px; + } + + .connection-docker-command { + align-items: stretch; + flex-direction: column; + } + + .connection-docker-command .button { + width: 100%; + } + + .connection-docker-path-heading { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } +} diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx new file mode 100644 index 000000000..1e4d0cba6 --- /dev/null +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -0,0 +1,243 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { + ConnectionModal, + ConnectionProbeStatus, + type ConnectionProbeState, +} from "./ConnectionModal"; +import type { LocalDockerBackendGuideProfile } from "../lib/docker-guide-config"; + +const callbacks = { + onClose: () => undefined, + onSave: () => undefined, +}; + +function renderModal( + baseUrl: string, + proxyAuthEnabled: boolean, + token = "", + dockerBackendGuide: LocalDockerBackendGuideProfile | null = null, +) { + return renderToStaticMarkup( + , + ); +} + +describe("Agent Core connection modes", () => { + it("makes the fixed local Parsar path a zero-input default", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Local Parsar Core"); + expect(markup).toContain("Default · zero input"); + expect(markup).toMatch(/type="radio"[^>]*checked[^>]*value="local"/); + expect(markup).toContain("Local `/v1` proxy"); + expect(markup).toContain("No URL is required"); + expect(markup).not.toContain("Compatible Core base URL"); + expect(markup).not.toContain('type="password"'); + }); + + it("does not request or expose a browser bearer when proxy auth is active", () => { + const markup = renderModal("/v1", true, "stale-browser-token"); + + expect(markup).toContain("Server-managed key detected"); + expect(markup).toContain("No bearer credential is exposed to browser JavaScript"); + expect(markup).not.toContain("stale-browser-token"); + expect(markup).not.toContain("Bearer token"); + expect(markup).not.toContain("sessionStorage"); + }); + + it("keeps local mode token-free when server-managed auth is not detected", () => { + const markup = renderModal("/v1", false, "stale-browser-token"); + + expect(markup).toContain("Server-managed key not detected"); + expect(markup).toContain("Configure the local proxy token file and restart Web"); + expect(markup).toContain("Local mode will not request a browser token"); + expect(markup).not.toContain("stale-browser-token"); + expect(markup).not.toContain('type="password"'); + }); + + it("puts direct URLs, CORS guidance, and current-tab tokens behind Advanced", () => { + const markup = renderModal("http://127.0.0.1:8091/v1", true); + + expect(markup).toMatch(/type="radio"[^>]*checked[^>]*value="advanced"/); + expect(markup).toContain("Compatible Core base URL"); + expect(markup).toContain("through CORS"); + expect(markup).toContain('type="password"'); + expect(markup).toContain("Current tab only"); + expect(markup).toContain("sessionStorage, never localStorage"); + expect(markup).not.toContain("Server-managed key detected"); + }); + + it("rejects credentials and query data embedded in an Advanced URL", () => { + const markup = renderModal("https://core.example/v1?token=unsafe", true); + + expect(markup).toContain("an HTTP loopback URL, without credentials, query parameters, or fragments"); + expect(markup).toContain('aria-invalid="true"'); + expect(markup.match(/disabled=""/g)).toHaveLength(2); + }); + + it("rejects remote HTTP while explaining the HTTPS and loopback boundary", () => { + const markup = renderModal("http://core.example/v1", true, "must-not-leave-browser"); + + expect(markup).toContain("Remote Core access requires HTTPS"); + expect(markup).toContain("Plain HTTP is allowed only for an explicit loopback host"); + expect(markup).toContain('aria-invalid="true"'); + expect(markup.match(/disabled=""/g)).toHaveLength(2); + }); + + it("keeps credential tutorials in current monorepo operator links", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Operator-owned setup"); + expect(markup).toContain("Web connection guide · current repository"); + expect(markup).toContain("Connection troubleshooting · current repository"); + expect(markup).toContain("Parsar Core setup"); + expect(markup).toContain("parsar-core/blob/main/docs/web/core-connection.md"); + expect(markup).toContain("parsar-core/blob/main/services/agents-api/README.md"); + expect(markup).not.toContain("MiniMax-AI-Dev/parsar/blob"); + expect(markup).not.toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); + expect(markup).not.toContain("f7cdf591396529880d80f8211fc7a0f4768fdf46"); + expect(markup).not.toContain("8cc2898ca42b272cb3771234ee6a0ad0d2e932ba"); + expect(markup).not.toContain("7409e00ca25311805a9f8f0d03614f820e407642"); + expect(markup).not.toContain("6345391"); + expect(markup).not.toContain("bac551c"); + expect(markup).not.toContain("openssl rand"); + expect(markup).not.toContain("AGENTS_API_KEYS_FILE"); + expect(markup).not.toContain("AGENTS_API_DAEMON_WS_URL"); + }); + + it("renders a copyable, non-executing Docker backend recovery guide", () => { + const markup = renderModal("/v1", true, "", { + databaseContainer: "parsar-agents-api-web-smoke-db", + apiContainer: "agents-core-web-api", + daemonContainer: "agents-core-web-daemon", + corePort: 8091, + }); + + expect(markup).toContain("Start a local Docker backend"); + expect(markup).toContain("docker start parsar-agents-api-web-smoke-db"); + expect(markup).toContain("docker start agents-core-web-api agents-core-web-daemon"); + expect(markup).toContain("http://127.0.0.1:8091/healthz"); + expect(markup).toContain("Already set up on this computer"); + expect(markup).toContain("First time on this computer"); + expect(markup).toContain("make docker-build-agents-api"); + expect(markup).toContain("does not publish a safe zero-input bootstrap"); + expect(markup).toContain("Core container setup"); + expect(markup).toContain("Daemon provisioning"); + expect(markup).toContain(`/parsar-core/blob/main/services/agents-api/CONTAINER.md`); + expect(markup).toContain("Web only displays the reviewed commands"); + expect(markup).toContain("never receives Docker socket access or executes them"); + expect(markup).toContain("A self-hosted executor is Session-specific"); + expect(markup).not.toContain("docker run"); + expect(markup).not.toContain("docker compose down"); + }); + + it("fails closed to configuration help when container names are unavailable", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Docker startup guide is not configured for this Web build"); + expect(markup).toContain("AGENTS_CORE_WEB_DOCKER_BACKEND_*"); + expect(markup).not.toContain("docker start"); + }); + + it("states the GET-only probe boundary without disabling the current chat contract", () => { + const markup = renderModal("/v1", true); + + expect(markup).toContain("Test connection"); + expect(markup).toContain("with one GET"); + expect(markup).toContain("never creates an Agent, Session, Turn, or Item"); + expect(markup).toContain("Chat uses the current Core events contract"); + expect(markup).toContain("a real request can still fail"); + }); +}); + +describe("Connection probe status", () => { + it("announces the loading state", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="status"'); + expect(markup).toContain('aria-live="polite"'); + expect(markup).toContain("Testing Core connection…"); + expect(markup).toContain("one read-only Agents API GET request"); + }); + + it.each<[ConnectionProbeState, string]>([ + [ + { + status: "complete", + result: { kind: "authenticated", executionReadiness: "unknown", httpStatus: 200 }, + }, + "Core API authenticated", + ], + [ + { + status: "complete", + result: { kind: "invalid_configuration", executionReadiness: "unknown" }, + }, + "Core URL blocked", + ], + [ + { + status: "complete", + result: { kind: "unauthorized", executionReadiness: "unknown", httpStatus: 401 }, + }, + "401 invalid_api_key", + ], + [ + { + status: "complete", + result: { kind: "protocol_mismatch", executionReadiness: "unknown", httpStatus: 400 }, + }, + "Agents API protocol mismatch", + ], + [ + { + status: "complete", + result: { kind: "unreachable", executionReadiness: "unknown" }, + }, + "Core unreachable", + ], + [ + { + status: "complete", + result: { kind: "http_error", executionReadiness: "unknown", httpStatus: 503 }, + }, + "Core returned HTTP 503", + ], + ])("renders a safe, distinct terminal result", (state, expected) => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain(expected); + expect(markup).toContain("Chat uses the current Agents API contract"); + expect(markup).toContain("does not start a Turn or verify its runtime dependencies"); + }); + + it("uses an alert for failures and a polite status for authenticated access", () => { + const failed = renderToStaticMarkup( + , + ); + const succeeded = renderToStaticMarkup( + , + ); + + expect(failed).toContain('role="alert"'); + expect(succeeded).toContain('role="status"'); + }); +}); diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx new file mode 100644 index 000000000..a5a41f8c5 --- /dev/null +++ b/apps/web/src/components/ConnectionModal.tsx @@ -0,0 +1,464 @@ +import { Check, Container, Copy, ExternalLink, Info, KeyRound } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; + +import { + isLocalProxyBaseUrl, + isValidDirectCoreBaseUrl, + type CoreConnection, +} from "../lib/connection"; +import { + probeCore, + type CoreProbeResult, +} from "../lib/core-probe"; +import type { LocalDockerBackendGuideProfile } from "../lib/docker-guide-config"; +import { Modal } from "./Modal"; +import "./ConnectionModal.css"; + +interface ConnectionModalProps { + connection: CoreConnection; + open: boolean; + proxyAuthEnabled?: boolean; + dockerBackendGuide?: LocalDockerBackendGuideProfile | null; + onClose: () => void; + onSave: (connection: CoreConnection) => void; +} + +type ConnectionMode = "local" | "advanced"; + +export type ConnectionProbeState = + | { status: "idle" } + | { status: "loading" } + | { status: "complete"; result: CoreProbeResult }; + +const operatorGuideUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/docs/web/core-connection.md"; +const troubleshootingUrl = `${operatorGuideUrl}#troubleshooting`; +const parsarCoreSetupUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/services/agents-api/README.md#standalone-http-service"; +const parsarContainerSetupUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/services/agents-api/CONTAINER.md"; +const parsarDaemonSetupUrl = "https://github.com/MiniMax-AI/parsar-core/blob/main/services/agents-api/README.md#internal-execution-device-connection"; + +function DockerCommand({ label, command }: { label: string; command: string }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + if (!navigator.clipboard) return; + try { + await navigator.clipboard.writeText(command); + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + } catch { + setCopied(false); + } + }; + + return ( +
+ {command} + +
+ ); +} + +function DockerBackendGuide({ profile }: { profile: LocalDockerBackendGuideProfile | null }) { + return ( +
+
+
+ {profile ? ( + <> +
+
+ Already set up on this computer + Start the existing containers +
+
    +
  1. + Start the dedicated databaseWait until its Docker health status is healthy. + +
  2. +
  3. + Start Core API and daemonThe daemon reconnects to Core independently. + +
  4. +
  5. + Verify Core process healthA successful health response is liveness only; use Test connection next. + +
  6. +
+
+
+
+ First time on this computer + Create Core before trying docker start +
+

+ Docker is assumed to be installed. Parsar Core still requires a dedicated PostgreSQL database, a private + caller principal, migrations, the Core API container, and a provisioned daemon profile. Core does not + publish a safe zero-input bootstrap, so Web will not invent credentials or + create containers with guessed settings. +

+
    +
  1. + Build the Core imageRun from the reviewed parsar-core checkout. + +
  2. +
  3. Create the private Core stackPrepare the dedicated database, caller key files, migrations, and API container using the in-repository container guide.
  4. +
  5. Provision and connect the daemonIssue a separate device profile; caller keys and daemon credentials are not interchangeable.
  6. +
  7. Return here and testConfigure Web's server-side caller-key file, restart Web, then use Test connection.
  8. +
+ +
+ + ) : ( +
+ Docker startup guide is not configured for this Web build. +

+ Set the non-secret AGENTS_CORE_WEB_DOCKER_BACKEND_* values from .env.example, + then restart Web. Container names are operator configuration and are never guessed in the browser. +

+
+ )} +

+ Existing-stack commands only start saved database/Core/daemon containers. First-time setup remains an + operator action because it creates durable state and credentials. A self-hosted executor is Session-specific + and must be connected from that Session's Environment instructions. +

+
+ ); +} + +function initialMode(connection: CoreConnection): ConnectionMode { + return isLocalProxyBaseUrl(connection.baseUrl) ? "local" : "advanced"; +} + +function initialAdvancedDraft(connection: CoreConnection): CoreConnection { + const direct = !isLocalProxyBaseUrl(connection.baseUrl); + return { + baseUrl: direct ? connection.baseUrl : "", + token: direct ? connection.token : "", + }; +} + +function resultCopy(result: CoreProbeResult): { title: string; detail: string } { + switch (result.kind) { + case "authenticated": + return { + title: "Core API authenticated", + detail: "The read-only Agents API request returned a valid collection.", + }; + case "invalid_configuration": + return { + title: "Core URL blocked", + detail: "Remote Core URLs must use HTTPS. Plain HTTP is allowed only for an explicit loopback host.", + }; + case "unauthorized": + return { + title: "Authentication failed", + detail: "Core returned 401 invalid_api_key. Check the server-managed caller key or current-tab token.", + }; + case "protocol_mismatch": + return { + title: "Agents API protocol mismatch", + detail: "The endpoint did not accept the tested /v1/agents GET contract and OpenAI-Beta: agents=v1 header.", + }; + case "http_error": + return { + title: `Core returned HTTP ${result.httpStatus ?? "error"}`, + detail: "The basic Agents API read did not succeed. No Core resource was changed.", + }; + case "unreachable": + return { + title: "Core unreachable", + detail: "The browser could not reach Core. Check the local proxy target, network, or direct-mode CORS policy.", + }; + } +} + +export function ConnectionProbeStatus({ state }: { state: ConnectionProbeState }) { + if (state.status === "idle") return null; + if (state.status === "loading") { + return ( +
+ Testing Core connection… +

Sending one read-only Agents API GET request.

+
+ ); + } + + const copy = resultCopy(state.result); + const failed = state.result.kind !== "authenticated"; + return ( +
+ {copy.title} +

{copy.detail}

+ Chat uses the current Agents API contract. This read-only probe does not start a Turn or verify its runtime dependencies. +
+ ); +} + +export function ConnectionModal({ + connection, + open, + proxyAuthEnabled = import.meta.env.DEV && __AGENTS_CORE_WEB_DEV_PROXY_AUTH__, + dockerBackendGuide = __AGENTS_CORE_WEB_DOCKER_BACKEND_GUIDE__, + onClose, + onSave, +}: ConnectionModalProps) { + const modeName = useId(); + const [mode, setMode] = useState(() => initialMode(connection)); + const [advancedDraft, setAdvancedDraft] = useState(() => initialAdvancedDraft(connection)); + const [probeState, setProbeState] = useState({ status: "idle" }); + const probeControllerRef = useRef(undefined); + const probeRevisionRef = useRef(0); + const advancedUrlValid = isValidDirectCoreBaseUrl(advancedDraft.baseUrl); + const draft = mode === "local" + ? { baseUrl: "/v1", token: "" } + : { baseUrl: advancedDraft.baseUrl.trim(), token: advancedDraft.token.trim() }; + + useEffect(() => { + setMode(initialMode(connection)); + setAdvancedDraft(initialAdvancedDraft(connection)); + }, [connection, open]); + + useEffect(() => { + probeRevisionRef.current += 1; + probeControllerRef.current?.abort(); + probeControllerRef.current = undefined; + setProbeState({ status: "idle" }); + }, [advancedDraft.baseUrl, advancedDraft.token, mode, open, proxyAuthEnabled]); + + useEffect(() => () => probeControllerRef.current?.abort(), []); + + const testConnection = async () => { + const controller = new AbortController(); + probeControllerRef.current?.abort(); + probeControllerRef.current = controller; + const revision = ++probeRevisionRef.current; + setProbeState({ status: "loading" }); + + try { + const result = await probeCore({ + baseUrl: draft.baseUrl, + token: mode === "advanced" ? draft.token : undefined, + signal: controller.signal, + }); + if (probeRevisionRef.current !== revision || controller.signal.aborted) return; + probeControllerRef.current = undefined; + setProbeState({ status: "complete", result }); + } catch { + if (probeRevisionRef.current !== revision || controller.signal.aborted) return; + probeControllerRef.current = undefined; + setProbeState({ + status: "complete", + result: { kind: "unreachable", executionReadiness: "unknown" }, + }); + } + }; + + const testDisabled = probeState.status === "loading" || (mode === "advanced" && !advancedUrlValid); + const applyDisabled = mode === "advanced" && !advancedUrlValid; + + return ( + + + + + } + > +
+ Connection mode + + +
+ + {mode === "local" ? ( + <> +
+
+
+
+
+
API base
+
/v1
+
+
+
Authentication
+
{proxyAuthEnabled ? "Server-managed key detected" : "Server-managed key not detected"}
+
+
+

+ {proxyAuthEnabled + ? "The Vite proxy supplies its key server-side. No bearer credential is exposed to browser JavaScript." + : "Configure the local proxy token file and restart Web. Local mode will not request a browser token."} +

+
+ + + ) : ( +
+ + +
+ )} + +
+
+ Connection test +

Checks authenticated Agents API access with one GET. It never creates an Agent, Session, Turn, or Item.

+
+ +
+ + +
+
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/CreateMenu.test.tsx b/apps/web/src/components/CreateMenu.test.tsx new file mode 100644 index 000000000..13b7c0b13 --- /dev/null +++ b/apps/web/src/components/CreateMenu.test.tsx @@ -0,0 +1,25 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { CreateMenuContent } from "./CreateMenu"; + +describe("Create menu", () => { + it("shows only actions supported by the current Core contract", () => { + const html = renderToStaticMarkup( + undefined} + onStartSession={() => undefined} + />, + ); + + expect(html).toContain('role="menu"'); + expect(html).toContain("Agent"); + expect(html).toContain("Start Session"); + expect(html).not.toContain("Environment template"); + expect(html).not.toContain("Environment key"); + expect(html).not.toContain('aria-disabled="true"'); + expect(html).not.toContain("executor_token"); + }); +}); diff --git a/apps/web/src/components/CreateMenu.tsx b/apps/web/src/components/CreateMenu.tsx new file mode 100644 index 000000000..ee6774568 --- /dev/null +++ b/apps/web/src/components/CreateMenu.tsx @@ -0,0 +1,122 @@ +import { + Bot, + ChevronDown, + MessageSquare, + Plus, +} from "lucide-react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; + +interface CreateMenuActions { + canCreateAgent: boolean; + canStartSession: boolean; + onCreateAgent: () => void; + onStartSession: () => void; +} + +export function CreateMenuContent({ + canCreateAgent, + canStartSession, + onCreateAgent, + onStartSession, +}: CreateMenuActions) { + return ( +
+ + +
+ ); +} + +export function CreateMenu(actions: CreateMenuActions) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const closeOnOutsidePointer = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", closeOnOutsidePointer); + return () => document.removeEventListener("mousedown", closeOnOutsidePointer); + }, [open]); + + const focusMenuItem = (edge: "first" | "last") => { + window.requestAnimationFrame(() => { + const items = rootRef.current?.querySelectorAll('button[role="menuitem"]:not([disabled])'); + const item = edge === "first" ? items?.[0] : items?.[items.length - 1]; + item?.focus(); + }); + }; + + const onMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + return; + } + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + const items = Array.from( + rootRef.current?.querySelectorAll('button[role="menuitem"]:not([disabled])') ?? [], + ); + if (!items.length) return; + event.preventDefault(); + if (event.key === "Home") return void items[0]?.focus(); + if (event.key === "End") return void items[items.length - 1]?.focus(); + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const delta = event.key === "ArrowDown" ? 1 : -1; + items[(current + delta + items.length) % items.length]?.focus(); + }; + + return ( +
+ + {open ? ( + { + setOpen(false); + triggerRef.current?.focus(); + actions.onCreateAgent(); + }} + onStartSession={() => { + setOpen(false); + triggerRef.current?.focus(); + actions.onStartSession(); + }} + /> + ) : null} +
+ ); +} diff --git a/apps/web/src/components/ErrorState.tsx b/apps/web/src/components/ErrorState.tsx new file mode 100644 index 000000000..d52166e91 --- /dev/null +++ b/apps/web/src/components/ErrorState.tsx @@ -0,0 +1,48 @@ +import { AlertTriangle, RefreshCw } from "lucide-react"; +import type { ReactNode } from "react"; + +interface ErrorStateProps { + title: string; + description?: string; + detail?: string; + hint?: string; + onRetry?: () => void; + action?: ReactNode; + className?: string; +} + +/** Parsar's flat, durable error state for failures that must not fade away. */ +export function ErrorState({ + title, + description, + detail, + hint, + onRetry, + action, + className, +}: ErrorStateProps) { + return ( +
+
+
+ {onRetry || action ? ( +
+ {onRetry ? ( + + ) : null} + {action} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/MessageMarkdown.tsx b/apps/web/src/components/MessageMarkdown.tsx new file mode 100644 index 000000000..72e85d15b --- /dev/null +++ b/apps/web/src/components/MessageMarkdown.tsx @@ -0,0 +1,11 @@ +import ReactMarkdown from "react-markdown"; + +export function MessageMarkdown({ content }: { content: string }) { + return ( +
+

{children}

}}> + {content} +
+
+ ); +} diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx new file mode 100644 index 000000000..8bb76ea64 --- /dev/null +++ b/apps/web/src/components/Modal.tsx @@ -0,0 +1,127 @@ +import { X } from "lucide-react"; +import { useEffect, useId, useRef, useState, type PropsWithChildren, type ReactNode } from "react"; + +interface ModalProps extends PropsWithChildren { + open: boolean; + title: string; + footer?: ReactNode; + onClose: () => void; +} + +export function Modal({ open, title, footer, onClose, children }: ModalProps) { + const dialogRef = useRef(null); + const onCloseRef = useRef(onClose); + const previousFocusRef = useRef(null); + const wasOpenRef = useRef(false); + const titleId = useId(); + const [mounted, setMounted] = useState(open); + const [closing, setClosing] = useState(false); + onCloseRef.current = onClose; + + useEffect(() => { + if (open) { + if (!wasOpenRef.current) { + previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + wasOpenRef.current = true; + setMounted(true); + setClosing(false); + return; + } + wasOpenRef.current = false; + if (!mounted) return; + + setClosing(true); + const timer = window.setTimeout(() => { + setMounted(false); + setClosing(false); + }, 200); + return () => window.clearTimeout(timer); + }, [mounted, open]); + + useEffect(() => { + if (!open || !mounted) return; + + const dialog = dialogRef.current; + const focusableSelector = [ + "button:not([disabled])", + "input:not([disabled])", + "textarea:not([disabled])", + "select:not([disabled])", + "a[href]", + "[tabindex]:not([tabindex='-1'])", + ].join(","); + const frame = window.requestAnimationFrame(() => { + const preferred = dialog?.querySelector("input:not([disabled]), textarea:not([disabled]), select:not([disabled])"); + const first = preferred ?? dialog?.querySelector(focusableSelector) ?? dialog; + first?.focus(); + }); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab" || !dialog) return; + + const focusable = Array.from(dialog.querySelectorAll(focusableSelector)); + if (!focusable.length) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!dialog.contains(document.activeElement)) { + event.preventDefault(); + (event.shiftKey ? last : first)?.focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + window.cancelAnimationFrame(frame); + document.removeEventListener("keydown", handleKeyDown); + previousFocusRef.current?.focus(); + }; + }, [mounted, open]); + + if (!mounted) return null; + + return ( +
{ + if (!closing) onClose(); + }} + > +
event.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+ {footer ?
{footer}
: null} +
+
+ ); +} diff --git a/apps/web/src/components/ProductNavigation.test.tsx b/apps/web/src/components/ProductNavigation.test.tsx new file mode 100644 index 000000000..75029bc9d --- /dev/null +++ b/apps/web/src/components/ProductNavigation.test.tsx @@ -0,0 +1,31 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ProductNavigation } from "./ProductNavigation"; + +describe("Product navigation", () => { + it("exposes only the Core-backed product destinations and current page", () => { + const html = renderToStaticMarkup( + undefined} />, + ); + + expect(html).toContain('aria-label="Agents product"'); + expect(html).toContain('class="main-nav product-navigation"'); + expect(html).toContain("Workspace"); + expect(html).toContain("Dashboard"); + expect(html).toContain("Agents"); + expect(html).toContain("Sessions"); + expect(html).not.toContain("Vaults"); + expect(html).not.toContain("Environments"); + expect(html).toContain('aria-current="page"'); + }); + + it("shows Vaults only after Core capability discovery succeeds", () => { + const html = renderToStaticMarkup( + undefined} />, + ); + + expect(html).toContain("Vaults"); + expect(html).toContain('aria-current="page"'); + }); +}); diff --git a/apps/web/src/components/ProductNavigation.tsx b/apps/web/src/components/ProductNavigation.tsx new file mode 100644 index 000000000..75b672ee5 --- /dev/null +++ b/apps/web/src/components/ProductNavigation.tsx @@ -0,0 +1,42 @@ +import { Bot, LayoutDashboard, MessageSquare, Vault, type LucideIcon } from "lucide-react"; + +export type ProductView = "dashboard" | "agents" | "sessions" | "vaults"; + +const productViews: Array<{ id: ProductView; label: string; icon: LucideIcon }> = [ + { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, + { id: "agents", label: "Agents", icon: Bot }, + { id: "sessions", label: "Sessions", icon: MessageSquare }, + { id: "vaults", label: "Vaults", icon: Vault }, +]; + +export function ProductNavigation({ + active, + onSelect, + showVaults = false, +}: { + active: ProductView | null; + onSelect: (view: ProductView) => void; + showVaults?: boolean; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/Skeleton.tsx b/apps/web/src/components/Skeleton.tsx new file mode 100644 index 000000000..74376f9d0 --- /dev/null +++ b/apps/web/src/components/Skeleton.tsx @@ -0,0 +1,5 @@ +import type { HTMLAttributes } from "react"; + +export function Skeleton({ className, ...props }: HTMLAttributes) { + return
; +} diff --git a/apps/web/src/components/StatusIcon.tsx b/apps/web/src/components/StatusIcon.tsx new file mode 100644 index 000000000..930fab42f --- /dev/null +++ b/apps/web/src/components/StatusIcon.tsx @@ -0,0 +1,69 @@ +export type StatusKind = "queued" | "running" | "completed" | "failed" | "cancelled" | "interrupted"; + +interface StatusIconProps { + status: StatusKind; + title?: string; +} + +/** Parsar's 14px status signature. Status colour never leaves this glyph. */ +export function StatusIcon({ status, title }: StatusIconProps) { + const common = { + className: `status-icon status-${status}`, + viewBox: "0 0 14 14", + "aria-hidden": title ? undefined : true, + role: title ? "img" : undefined, + } as const; + const label = title ? {title} : null; + + switch (status) { + case "queued": + return ( + + {label} + + + ); + case "running": + return ( + + {label} + + + + + + ); + case "completed": + return ( + + {label} + + + + ); + case "failed": + return ( + + {label} + + + + ); + case "cancelled": + return ( + + {label} + + + + ); + case "interrupted": + return ( + + {label} + + + + ); + } +} diff --git a/apps/web/src/components/ThemeMenu.tsx b/apps/web/src/components/ThemeMenu.tsx new file mode 100644 index 000000000..cef499725 --- /dev/null +++ b/apps/web/src/components/ThemeMenu.tsx @@ -0,0 +1,32 @@ +import { Moon, Sun } from "lucide-react"; + +import { useTheme, type ResolvedTheme } from "../lib/theme"; + +export function ThemeMenu() { + const { resolvedTheme, setPreference } = useTheme(); + const segments: Array<{ value: ResolvedTheme; Icon: typeof Sun; label: string }> = [ + { value: "light", Icon: Sun, label: "Light theme" }, + { value: "dark", Icon: Moon, label: "Dark theme" }, + ]; + + return ( +
+ {segments.map(({ value, Icon, label }) => { + const pressed = resolvedTheme === value; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/Toast.test.ts b/apps/web/src/components/Toast.test.ts new file mode 100644 index 000000000..e0c11d0d7 --- /dev/null +++ b/apps/web/src/components/Toast.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { enqueueToast, type ToastItem } from "./Toast"; + +describe("Parsar toast queue", () => { + it("refreshes an identical keyed toast and advances its timer revision", () => { + const first = enqueueToast([], 1, "Agent created.", { + tone: "success", + key: "success:Agent created.", + }); + const refreshed = enqueueToast(first, 2, "Agent created.", { + tone: "success", + key: "success:Agent created.", + }); + + expect(refreshed).toHaveLength(1); + expect(refreshed[0]).toMatchObject({ + id: 1, + revision: 2, + leaving: false, + message: "Agent created.", + }); + }); + + it("keeps at most three transient toasts live while the oldest exits", () => { + let queue: ToastItem[] = []; + for (let id = 1; id <= 4; id += 1) { + queue = enqueueToast(queue, id, `Notice ${id}`); + } + + expect(queue.filter((toast) => !toast.leaving)).toHaveLength(3); + expect(queue.find((toast) => toast.id === 1)?.leaving).toBe(true); + }); + + it("revives a keyed toast that was already leaving", () => { + const leaving = enqueueToast([], 1, "Connection failed.", { + tone: "error", + key: "error:Connection failed.", + }).map((toast) => ({ ...toast, leaving: true })); + + const revived = enqueueToast(leaving, 2, "Connection failed.", { + tone: "error", + key: "error:Connection failed.", + }); + + expect(revived).toHaveLength(1); + expect(revived[0]).toMatchObject({ id: 1, revision: 2, leaving: false }); + }); +}); diff --git a/apps/web/src/components/Toast.tsx b/apps/web/src/components/Toast.tsx new file mode 100644 index 000000000..f2466938a --- /dev/null +++ b/apps/web/src/components/Toast.tsx @@ -0,0 +1,237 @@ +/* eslint-disable react-refresh/only-export-components */ +import { AlertTriangle, Info, X } from "lucide-react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { StatusIcon } from "./StatusIcon"; + +export type ToastTone = "info" | "success" | "warning" | "error"; + +export interface ToastOptions { + tone?: ToastTone; + detail?: string; + action?: ReactNode; + persist?: boolean; + /** The same key updates and revives the existing toast. */ + key?: string; +} + +export interface ToastItem extends ToastOptions { + id: number; + message: ReactNode; + durationMs: number; + leaving: boolean; + /** Changes on every keyed update, even when the message text is identical. */ + revision: number; +} + +export type ShowToast = (message: ReactNode, options?: ToastOptions) => void; + +interface ToastContextValue { + show: ShowToast; + dismiss: (key: string) => void; +} + +const ToastContext = createContext(null); +const NO_PROVIDER: ToastContextValue = { show: () => undefined, dismiss: () => undefined }; + +const DEFAULT_MS = 4_000; +const ERROR_MS = 7_000; +const MAX_VISIBLE = 3; +const EXIT_FALLBACK_MS = 250; + +export function useToast(): ToastContextValue { + return useContext(ToastContext) ?? NO_PROVIDER; +} + +/** @internal Exported for the focused queue regression tests. */ +export function enqueueToast( + list: ToastItem[], + id: number, + message: ReactNode, + options: ToastOptions = {}, +): ToastItem[] { + const tone = options.tone ?? "success"; + const fields = { + message, + tone, + detail: options.detail, + action: options.action, + persist: options.persist, + key: options.key, + durationMs: tone === "error" ? ERROR_MS : DEFAULT_MS, + leaving: false, + }; + const sameKey = options.key ? list.find((toast) => toast.key === options.key) : undefined; + + if (sameKey) { + return list.map((toast) => ( + toast.id === sameKey.id + ? { ...toast, ...fields, revision: toast.revision + 1 } + : toast + )); + } + + // Parsar lets the oldest transient toast finish its exit rather than + // removing it abruptly. Persistent prompts are never selected for eviction. + const live = list.filter((toast) => !toast.leaving && !toast.persist); + const excess = live.slice(0, Math.max(0, live.length - (MAX_VISIBLE - 1))); + const next = list.map((toast) => ( + excess.some((candidate) => candidate.id === toast.id) + ? { ...toast, leaving: true } + : toast + )); + + return [...next, { id, ...fields, revision: 1 }]; +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(1); + + const drop = useCallback((id: number) => { + setToasts((list) => list.filter((toast) => toast.id !== id)); + }, []); + + const startLeaving = useCallback((id: number) => { + setToasts((list) => list.map((toast) => ( + toast.id === id ? { ...toast, leaving: true } : toast + ))); + }, []); + + const show = useCallback((message, options = {}) => { + const id = nextId.current; + nextId.current += 1; + setToasts((list) => enqueueToast(list, id, message, options)); + }, []); + + const dismiss = useCallback((key: string) => { + setToasts((list) => list.map((toast) => ( + toast.key === key ? { ...toast, leaving: true } : toast + ))); + }, []); + + const value = useMemo(() => ({ show, dismiss }), [dismiss, show]); + const assertive = toasts.filter((toast) => toast.tone === "error"); + const polite = toasts.filter((toast) => toast.tone !== "error"); + + return ( + + {children} + {typeof document !== "undefined" + ? createPortal( +
+
+ {assertive.map((toast) => ( + + ))} +
+
+ {polite.map((toast) => ( + + ))} +
+
, + document.body, + ) + : null} +
+ ); +} + +function ToastStrip({ + toast, + onLeave, + onDrop, +}: { + toast: ToastItem; + onLeave: (id: number) => void; + onDrop: (id: number) => void; +}) { + const [focused, setFocused] = useState(false); + const remaining = useRef(toast.durationMs); + const startedAt = useRef(0); + + // A keyed toast may be shown again with exactly the same text. Revision is + // therefore the signal that this reader-visible countdown must restart. + useEffect(() => { + remaining.current = toast.durationMs; + }, [toast.durationMs, toast.revision]); + + useEffect(() => { + if (toast.persist || toast.leaving || focused) return; + startedAt.current = Date.now(); + const timer = window.setTimeout(() => onLeave(toast.id), remaining.current); + return () => { + window.clearTimeout(timer); + remaining.current = Math.max(0, remaining.current - (Date.now() - startedAt.current)); + }; + }, [focused, onLeave, toast.durationMs, toast.id, toast.leaving, toast.persist, toast.revision]); + + useEffect(() => { + if (!toast.leaving) return; + const timer = window.setTimeout(() => onDrop(toast.id), EXIT_FALLBACK_MS); + return () => window.clearTimeout(timer); + }, [onDrop, toast.id, toast.leaving]); + + const tone = toast.tone ?? "success"; + + return ( +
setFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); + }} + onAnimationEnd={(event) => { + if (toast.leaving && event.target === event.currentTarget) onDrop(toast.id); + }} + > +
+
+ {tone === "success" ? ( + + ) : tone === "error" ? ( +
+ {toast.action} + {!toast.persist ? ( + + ) : null} +
+ {toast.detail ?
{toast.detail}
: null} +
+ ); +} diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx new file mode 100644 index 000000000..e6821aed7 --- /dev/null +++ b/apps/web/src/features/CoreCollectionStates.test.tsx @@ -0,0 +1,489 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { AgentSession, SavedAgent, SessionItem } from "@agents-core-web/agents-client"; + +import { AgentsView } from "./agents/AgentsView"; +import { restoreDraftAfterFailedSend, SessionsView } from "./sessions/SessionsView"; + +const agentsCallbacks = { + onCreate: async () => undefined, + onRefresh: () => undefined, + onStartSession: async () => undefined, +}; + +const sessionsCallbacks = { + onCancel: async () => undefined, + onCreateSession: async () => undefined, + onDeleteSession: async () => true, + onFunctionResult: async () => undefined, + onRefresh: () => undefined, + onRetrySession: () => undefined, + onRetryStream: () => undefined, + onRetrieveSession: async () => undefined, + onSelect: () => undefined, + onSend: async () => undefined, + onUpdateSession: async () => undefined, +}; + +const selectedSession: AgentSession = { + id: "session_1", + object: "agent.session", + agent: { + id: "agent_1", + model: "gpt-5", + name: "Researcher", + instructions: null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + }, + environment: { type: "none" }, + status: "idle", + error: null, + metadata: {}, + required_actions: [], + vault_ids: [], + usage: null, + created_at: 1, + last_active_at: 1, +}; + +const durableItems: SessionItem[] = [{ + id: "item_1", + turn_id: "turn_1", + type: "message", + status: "completed", + role: "user", + content: [{ type: "input_text", text: "Existing durable item" }], +}]; + +const savedAgent: SavedAgent = { + ...selectedSession.agent, + object: "agent", + metadata: {}, + created_at: 1, + updated_at: 1, +}; + +describe("Agent Core collection states", () => { + it("restores a failed message only when it cannot overwrite a newer draft", () => { + expect(restoreDraftAfterFailedSend("", "failed message")).toBe("failed message"); + expect(restoreDraftAfterFailedSend("new draft", "failed message")).toBe("new draft"); + }); + + it("keeps an Agents load failure distinct from a ready empty collection", () => { + const failed = renderToStaticMarkup( + , + ); + const empty = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Couldn’t load Agents"); + expect(failed).toContain("connection refused"); + expect(failed).not.toContain("No saved Agents"); + expect(empty).toContain("No saved Agents"); + }); + + it("keeps a stale Agent ledger visible when its collection refresh fails", () => { + const failed = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Couldn’t refresh Agents"); + expect(failed).toContain("agents refresh failed"); + expect(failed).toContain('aria-label="Agents"'); + expect(failed).toContain("Researcher"); + }); + + it("shows composer activity only for a Core-reported in-progress Session", () => { + const renderStatus = (status: AgentSession["status"], busy = false) => renderToStaticMarkup( + , + ); + + const active = renderStatus("in_progress"); + expect(active).toContain('class="conversation-activity"'); + expect(active).toContain('role="status" aria-live="polite" aria-atomic="true"'); + expect(active).toContain("Researcher is working…"); + expect(renderStatus("idle", true)).not.toContain("conversation-activity"); + expect(renderStatus("requires_action")).not.toContain("conversation-activity"); + expect(renderStatus("failed")).not.toContain("conversation-activity"); + }); + + it("keeps Sessions loading and failure distinct from ready empty copy", () => { + const common = { + agents: [], + sessions: [], + selected: null, + items: [], + busy: false, + detailError: null, + detailState: "idle" as const, + streamError: null, + streamState: "idle" as const, + ...sessionsCallbacks, + }; + const loading = renderToStaticMarkup( + , + ); + const failed = renderToStaticMarkup( + , + ); + const empty = renderToStaticMarkup( + , + ); + + expect(loading).toContain("Loading Sessions"); + expect(loading).not.toContain("No Sessions yet"); + expect(failed).toContain("Couldn’t load Sessions"); + expect(failed).not.toContain("Select or create a Session"); + expect(empty).toContain("No Sessions yet"); + expect(empty).toContain("Select or create a Session"); + }); + + it("keeps stale Sessions and a healthy selected workspace usable after collection refresh fails", () => { + const failed = renderToStaticMarkup( + , + ); + const composer = failed.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0] ?? ""; + + expect(failed).toContain("Couldn’t refresh Sessions"); + expect(failed).toContain("sessions refresh failed"); + expect(failed).toContain("Existing durable item"); + expect(failed).toContain("Live events"); + expect(composer).not.toContain("disabled"); + }); + + it("does not claim an empty timeline before the selected Session load succeeds", () => { + const common = { + agents: [], + sessions: [selectedSession], + selected: selectedSession, + items: [], + busy: false, + coreError: null, + coreState: "ready" as const, + streamError: null, + streamState: "connecting" as const, + ...sessionsCallbacks, + }; + const loading = renderToStaticMarkup( + , + ); + const failed = renderToStaticMarkup( + , + ); + const ready = renderToStaticMarkup( + , + ); + + expect(loading).toContain("Loading Session timeline"); + expect(loading).not.toContain("Session is ready"); + expect(failed).toContain("Couldn’t load this Session"); + expect(failed).toContain("items request failed"); + expect(failed).toContain("Retry"); + expect(failed).not.toContain("Session is ready"); + expect(ready).toContain("Session is ready"); + expect(ready).toContain("Message execution also requires a Core worker and executor."); + }); + + it("keeps durable Items visible beside refresh and terminal Session failures", () => { + const refreshFailed = renderToStaticMarkup( + , + ); + const sessionFailed = renderToStaticMarkup( + , + ); + + expect(refreshFailed).toContain("Existing durable item"); + expect(refreshFailed).toContain("refresh failed"); + expect(refreshFailed).toContain("last loaded Items remain visible"); + expect(sessionFailed).toContain("Latest attempt failed"); + expect(sessionFailed).toContain("You can send another message to start a new Turn in this Session."); + expect(sessionFailed).toContain("model execution failed"); + expect(sessionFailed).not.toContain("Session is ready"); + expect(sessionFailed.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0]).not.toContain("disabled"); + expect(sessionFailed).not.toContain("This Session has failed"); + }); + + it("blocks new messages only when the selected Session has a proven terminal Environment", () => { + const environmentId = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Session cannot continue"); + expect(html).toContain("Start a new Session to continue"); + expect(html.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0]).toContain("disabled"); + }); + + it.each([ + ["401", "A valid key is required."], + ["404", "The Session event stream was not found."], + ])("keeps an events %s rejection visible with an explicit stream retry", (_status, streamError) => { + const failed = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Couldn’t open live events"); + expect(failed).toContain(streamError); + expect(failed).toContain("Retry"); + expect(failed).not.toContain("Session is ready"); + expect(failed).not.toContain("Opening the event stream"); + }); + + it("keeps execution-unavailable message failures visible without claiming a retry", () => { + const failed = renderToStaticMarkup( + , + ); + + expect(failed).toContain("Core execution is unavailable"); + expect(failed).toContain("Execution is not enabled on this service."); + expect(failed).toContain("Your draft was restored and was not retried."); + expect(failed).toContain("operator runtime configuration"); + expect(failed).toContain("Core runtime setup"); + expect(failed).toContain("/parsar-core/blob/main/services/agents-api/README.md#public-text-execution"); + expect(failed).not.toContain("AGENTS_API_DAEMON_WS_URL"); + }); + + it("renders Environment connection actions separately from simultaneous function results", () => { + const waiting = renderToStaticMarkup( + , + ); + + expect(waiting).toContain('aria-label="Environment connection required"'); + expect(waiting).toContain('aria-haspopup="dialog"'); + expect(waiting).toContain('aria-label="Connect environment"'); + expect(waiting).toContain("Live events"); + expect(waiting).not.toContain('aria-label="Environment and Workspace status"'); + expect(waiting).toContain("environment_01"); + expect(waiting).toContain("must be connected by the Core operator"); + expect(waiting).toContain('aria-label="Function result required"'); + expect(waiting).toContain('aria-label="Function result or error"'); + expect(waiting.match(/aria-label="Function result or error"/g)).toHaveLength(1); + expect(waiting).toContain("Structured result"); + expect(waiting).toContain("1 / 2"); + expect(waiting).toContain('title="lookup"'); + expect(waiting).not.toContain('title="summarize"'); + expect(waiting).toContain("This Web cannot connect, complete, or approve it."); + }); + + it("blocks the composer for unknown, malformed, or missing required actions", () => { + for (const requiredActions of [ + [{ type: "future_approval", credential: "must-not-render" }], + [{ type: "function_call", name: "missing identity" }], + [ + { type: "future_approval" }, + { type: "function_call", call_id: "call", turn_id: "turn", name: "valid_but_blocked", arguments: {} }, + ], + [], + null, + ]) { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-label="Unsupported required action"'); + expect(html).toContain("will not infer a form or continue the Session"); + expect(html).not.toContain('aria-label="Message the Agent"'); + expect(html).not.toContain('aria-label="Function result or error"'); + expect(html).toContain('aria-label="Cancel active Turn"'); + expect(html).not.toContain("must-not-render"); + } + }); +}); diff --git a/apps/web/src/features/agents/AgentCatalog.css b/apps/web/src/features/agents/AgentCatalog.css new file mode 100644 index 000000000..b42590015 --- /dev/null +++ b/apps/web/src/features/agents/AgentCatalog.css @@ -0,0 +1,263 @@ +.agents-catalog-scroll { + min-width: 0; + min-height: 0; + flex: 1; + padding: 24px; + overflow: auto; +} + +.agent-catalog-section { + display: grid; + gap: 14px; +} + +.agent-catalog-section + .agent-catalog-section { + margin-top: 34px; +} + +.agent-catalog-section > h2, +.agent-catalog-section > header h2 { + margin: 0; + color: var(--fg); + font-size: 18px; + font-weight: 600; + line-height: 24px; +} + +.agent-catalog-section > header p { + margin: 3px 0 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; +} + +.agent-card-grid, +.agent-template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(245px, 100%), 1fr)); + gap: 12px; +} + +.agent-catalog-card { + min-width: 0; + min-height: 190px; + color: var(--fg); + text-align: left; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: var(--shadow-control); + transition: background-color 150ms var(--ease-settle), border-color 150ms var(--ease-settle), transform 150ms var(--ease-settle); +} + +.agent-catalog-slot, +.agent-catalog-slot > .agent-catalog-card { + height: 100%; +} + +button.agent-catalog-card, +.agent-card-main { + cursor: pointer; +} + +.agent-catalog-card:hover:not(:disabled), +.agent-catalog-card:focus-within { + background: var(--hover); + border-color: var(--line-strong); +} + +.agent-catalog-card:focus-visible, +.agent-card-main:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; +} + +.agent-catalog-card:disabled, +.agent-card-main:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.agent-card-main, +button.agent-catalog-card { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + align-items: flex-start; + padding: 18px; + color: inherit; + text-align: left; +} + +.agent-card-main { + background: transparent; + border: 0; +} + +.agent-saved-card { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.agent-saved-card .agent-card-main { + flex: 1; +} + +.agent-card-icon { + display: inline-flex; + width: 34px; + height: 34px; + flex: 0 0 34px; + align-items: center; + justify-content: center; + margin-bottom: 18px; + color: var(--fg); + background: var(--tile); + border: 1px solid var(--line); + border-radius: 9px; +} + +.agent-catalog-card > strong, +.agent-card-main > strong { + display: block; + max-width: 100%; + overflow: hidden; + font-size: 15px; + font-weight: 550; + line-height: 21px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-card-description { + display: -webkit-box; + min-height: 38px; + margin-top: 5px; + overflow: hidden; + color: var(--fg-muted); + font-size: 12px; + line-height: 18px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.agent-card-meta { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + margin-top: auto; + padding-top: 16px; + gap: 8px; + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; +} + +.agent-card-meta code { + min-width: 0; + flex: 1; + overflow: hidden; + color: inherit; + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-card-opening { + margin-top: 8px; + color: var(--accent); + font-size: 11px; +} + +.agent-saved-card > footer { + display: flex; + min-height: 44px; + align-items: center; + justify-content: flex-end; + padding: 8px 12px; + border-top: 1px solid var(--line); +} + +.agent-create-card, +.agent-template-card { + border-style: dashed; + box-shadow: none; +} + +.agent-create-card .agent-card-icon { + color: var(--surface); + background: var(--accent); + border-color: transparent; +} + +.agent-template-card .agent-card-icon { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + border-color: color-mix(in srgb, var(--accent) 24%, var(--line)); +} + +.agent-catalog-empty { + display: flex; + align-items: center; + padding: 12px 14px; + gap: 8px; + color: var(--fg-muted); + font-size: 12px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 7px; +} + +.agent-catalog-empty strong { + color: var(--fg); + font-weight: 500; +} + +.agent-catalog-empty .button { + margin-left: auto; +} + +.agent-catalog-more { + display: flex; + min-height: 36px; + align-items: center; + justify-content: flex-end; + gap: 10px; + color: var(--fg-muted); + font-size: 11px; +} + +.agent-catalog-more .button { + gap: 5px; +} + +@media (max-width: 640px) { + .agents-catalog-scroll { + padding: 16px 12px 28px; + } + + .agent-card-grid, + .agent-template-grid { + grid-template-columns: minmax(0, 1fr); + } + + .agent-catalog-section + .agent-catalog-section { + margin-top: 28px; + } + + .agent-catalog-empty { + align-items: flex-start; + flex-direction: column; + } + + .agent-catalog-empty .button { + margin-left: 0; + } + + .agent-catalog-more { + justify-content: space-between; + } +} diff --git a/apps/web/src/features/agents/AgentCatalog.test.tsx b/apps/web/src/features/agents/AgentCatalog.test.tsx new file mode 100644 index 000000000..b8379e7dd --- /dev/null +++ b/apps/web/src/features/agents/AgentCatalog.test.tsx @@ -0,0 +1,72 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { SavedAgent } from "@agents-core-web/agents-client"; + +import { AgentCatalog, twoRowSavedAgentCapacity } from "./AgentCatalog"; + +function savedAgent(index: number): SavedAgent { + return { + id: `agent_${index}`, + object: "agent", + model: "provider/model", + name: `Agent ${index}`, + instructions: `Instructions ${index}`, + metadata: {}, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + created_at: 1_700_000_000 + index, + updated_at: 1_700_000_100 + index, + }; +} + +function renderCatalog(expanded = false, isFiltering = false): string { + return renderToStaticMarkup( + savedAgent(index))} + busy={false} + coreReady + expanded={expanded} + hasSavedAgents + isFiltering={isFiltering} + openingAgentId={null} + vaultCatalog={null} + onClearSearch={() => undefined} + onCreate={() => undefined} + onEdit={() => undefined} + onExpandedChange={() => undefined} + onStartSession={() => undefined} + onUseTemplate={() => undefined} + />, + ); +} + +describe("Agent catalog", () => { + it("reserves the first card for Create and bounds saved Agents to two responsive rows", () => { + expect(twoRowSavedAgentCapacity(1)).toBe(1); + expect(twoRowSavedAgentCapacity(2)).toBe(3); + expect(twoRowSavedAgentCapacity(3)).toBe(5); + expect(twoRowSavedAgentCapacity(4)).toBe(7); + + const html = renderCatalog(); + expect(html.indexOf("Create agent")).toBeLessThan(html.indexOf("Edit Agent 0")); + expect(html).toContain("Edit Agent 0 (agent_0)"); + expect(html).not.toContain("Edit Agent 1 (agent_1)"); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("Show 3 more"); + }); + + it("shows every saved Agent after expansion or while filtering", () => { + const expanded = renderCatalog(true); + expect(expanded).toContain("Edit Agent 3 (agent_3)"); + expect(expanded).toContain('aria-expanded="true"'); + expect(expanded).toContain("Show less"); + + const filtered = renderCatalog(false, true); + expect(filtered).toContain("Edit Agent 3 (agent_3)"); + expect(filtered).not.toContain("Show 3 more"); + }); +}); diff --git a/apps/web/src/features/agents/AgentCatalog.tsx b/apps/web/src/features/agents/AgentCatalog.tsx new file mode 100644 index 000000000..515cbecd9 --- /dev/null +++ b/apps/web/src/features/agents/AgentCatalog.tsx @@ -0,0 +1,227 @@ +import { Bot, ChevronDown, ChevronUp, MessageSquare, PanelsTopLeft, Plus } from "lucide-react"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; + +import type { SavedAgent } from "@agents-core-web/agents-client"; + +import type { VaultCatalog } from "../vaults/vault-catalog"; +import { sessionAdmissionBlocker } from "./session-admission"; +import { AGENT_TEMPLATES, type AgentTemplate } from "./agent-templates"; + +import "./AgentCatalog.css"; + +const useIsomorphicLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect; + +export function twoRowSavedAgentCapacity(columnCount: number): number { + return Math.max(1, Math.floor(columnCount) * 2 - 1); +} + +function resolvedGridColumnCount(grid: HTMLElement): number { + const template = window.getComputedStyle(grid).gridTemplateColumns.trim(); + if (!template || template === "none") return 1; + return Math.max(1, template.split(/\s+/u).length); +} + +function formatShortDate(seconds: number): string { + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(new Date(seconds * 1000)); +} + +function AgentSessionStartAction({ + agent, + busy, + onStart, + vaultCatalog, +}: { + agent: SavedAgent; + busy: boolean; + onStart: (agentId: string) => void; + vaultCatalog: VaultCatalog | null; +}) { + const blocker = sessionAdmissionBlocker(agent, vaultCatalog); + const descriptionId = useId(); + return ( + + + {blocker ? ( + + Session unavailable: {blocker} + + ) : null} + + ); +} + +export function AgentCatalog({ + agents, + busy, + coreReady, + hasSavedAgents, + openingAgentId, + vaultCatalog, + onClearSearch, + onCreate, + onEdit, + onExpandedChange, + onStartSession, + onUseTemplate, + expanded, + isFiltering, +}: { + agents: SavedAgent[]; + busy: boolean; + coreReady: boolean; + expanded: boolean; + hasSavedAgents: boolean; + isFiltering: boolean; + openingAgentId: string | null; + vaultCatalog: VaultCatalog | null; + onClearSearch: () => void; + onCreate: (returnFocus: HTMLButtonElement) => void; + onEdit: (agent: SavedAgent, returnFocus: HTMLButtonElement) => void; + onExpandedChange: (expanded: boolean) => void; + onStartSession: (agentId: string) => void; + onUseTemplate: (template: AgentTemplate, returnFocus: HTMLButtonElement) => void; +}) { + const interactionDisabled = busy; + const gridId = useId(); + const gridRef = useRef(null); + const [columnCount, setColumnCount] = useState(1); + const savedCapacity = twoRowSavedAgentCapacity(columnCount); + const canExpand = !isFiltering && agents.length > savedCapacity; + const visibleAgents = isFiltering || expanded ? agents : agents.slice(0, savedCapacity); + const hiddenAgentCount = Math.max(0, agents.length - savedCapacity); + + useIsomorphicLayoutEffect(() => { + const grid = gridRef.current; + if (!grid) return; + + const updateColumnCount = () => { + const next = resolvedGridColumnCount(grid); + setColumnCount((current) => current === next ? current : next); + }; + updateColumnCount(); + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateColumnCount); + return () => window.removeEventListener("resize", updateColumnCount); + } + + const observer = new ResizeObserver(updateColumnCount); + observer.observe(grid); + return () => observer.disconnect(); + }, []); + + return ( +
+
+

Agents

+
+
+ +
+ {visibleAgents.map((agent) => ( +
+ +
+ +
+
+ ))} +
+ {canExpand ? ( +
+ {expanded ? `Showing all ${agents.length} saved Agents` : `${hiddenAgentCount} more saved ${hiddenAgentCount === 1 ? "Agent" : "Agents"}`} + +
+ ) : null} + {!agents.length ? ( +
+ {hasSavedAgents ? "No matching Agents" : "No saved Agents"} + {hasSavedAgents ? "Try another name, model, or ID." : "Start from a blank Agent or choose a starter template below."} + {hasSavedAgents ? : null} +
+ ) : null} +
+ +
+
+
+

Starter templates

+

Prompt starters only. Add and verify any tools separately.

+
+
+
+ {AGENT_TEMPLATES.map((template) => ( +
+ +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/features/agents/AgentDialog.tsx b/apps/web/src/features/agents/AgentDialog.tsx new file mode 100644 index 000000000..21a69ecca --- /dev/null +++ b/apps/web/src/features/agents/AgentDialog.tsx @@ -0,0 +1,9 @@ +import { createPortal } from "react-dom"; +import type { ComponentProps } from "react"; + +import { Modal } from "../../components/Modal"; + +export function AgentDialog(props: ComponentProps) { + const dialog =
; + return typeof document === "undefined" ? dialog : createPortal(dialog, document.body); +} diff --git a/apps/web/src/features/agents/AgentForm.tsx b/apps/web/src/features/agents/AgentForm.tsx new file mode 100644 index 000000000..2d5005796 --- /dev/null +++ b/apps/web/src/features/agents/AgentForm.tsx @@ -0,0 +1,355 @@ +import { Info } from "lucide-react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; + +import type { SavedAgent } from "@agents-core-web/agents-client"; + +import { + buildModelOptionGroups, + CUSTOM_MODEL_OPTION, + modelIdFromOption, + modelOptionValue, +} from "../../lib/model-options"; +import type { VaultCatalog } from "../vaults/vault-catalog"; +import { vaultName } from "../vaults/vault-catalog"; +import { type AgentFormSubmitInput, type AgentFormValues, type AgentToolDraft, validateAgentForm, valuesFromAgent } from "./agent-form"; + +interface AgentFormProps { + agent?: SavedAgent; + disabled?: boolean; + formId: string; + initialValues?: AgentFormValues; + knownModels: string[]; + vaultCatalog?: VaultCatalog | null; + onDraftChange?: (values: AgentFormValues) => void; + onSubmit: (input: AgentFormSubmitInput) => Promise; +} + +export function AgentForm({ agent, disabled = false, formId, initialValues, knownModels, vaultCatalog = null, onDraftChange, onSubmit }: AgentFormProps) { + const nameRef = useRef(null); + const initial = agent ? valuesFromAgent(agent, vaultCatalog) : initialValues ?? valuesFromAgent(undefined, vaultCatalog); + const options = buildModelOptionGroups( + knownModels, + import.meta.env.VITE_AGENT_MODEL_PRESETS, + initial.model || import.meta.env.VITE_AGENT_DEFAULT_MODEL, + ); + const initialIsSuggested = [...options.configured, ...options.previouslyUsed].includes(initial.model); + const [name, setName] = useState(initial.name); + const [modelChoice, setModelChoice] = useState( + initial.model && !initialIsSuggested ? CUSTOM_MODEL_OPTION : modelOptionValue(initial.model || options.defaultModel), + ); + const [customModel, setCustomModel] = useState(initialIsSuggested ? "" : initial.model); + const [instructions, setInstructions] = useState(initial.instructions); + const [metadata, setMetadata] = useState(initial.metadata); + const [reasoningEffort, setReasoningEffort] = useState(initial.reasoningEffort); + const [reasoningSummary, setReasoningSummary] = useState(initial.reasoningSummary); + const [serviceTier, setServiceTier] = useState(initial.serviceTier); + const [textFormat] = useState(initial.textFormat); + const [textVerbosity, setTextVerbosity] = useState(initial.textVerbosity); + const [tools, setTools] = useState(initial.tools); + const [toolsModified, setToolsModified] = useState(initial.toolsModified); + const [configurationError, setConfigurationError] = useState(null); + const [modelError, setModelError] = useState(null); + const [metadataError, setMetadataError] = useState(null); + const [nameError, setNameError] = useState(null); + const [toolsError, setToolsError] = useState(null); + const model = modelChoice === CUSTOM_MODEL_OPTION ? customModel : modelIdFromOption(modelChoice) ?? ""; + + const updateTools = (update: (current: AgentToolDraft[]) => AgentToolDraft[]) => { + setTools((current) => update(current)); + setToolsModified(true); + if (toolsError) setToolsError(null); + }; + + useEffect(() => { + const frame = window.requestAnimationFrame(() => nameRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, []); + + useEffect(() => { + onDraftChange?.({ + name, + model, + instructions, + metadata, + reasoningEffort, + reasoningSummary, + serviceTier, + textFormat, + textVerbosity, + tools, + toolsModified, + }); + }, [instructions, metadata, model, name, onDraftChange, reasoningEffort, reasoningSummary, serviceTier, textFormat, textVerbosity, tools, toolsModified]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const result = validateAgentForm({ + name, + model, + instructions, + metadata, + reasoningEffort, + reasoningSummary, + serviceTier, + textFormat, + textVerbosity, + tools, + toolsModified, + }, agent ? "update" : "create", vaultCatalog); + setConfigurationError(result.configurationError ?? null); + setModelError(result.modelError ?? null); + setMetadataError(result.metadataError ?? null); + setNameError(result.nameError ?? null); + setToolsError(result.toolsError ?? null); + if (!result.input) return; + await onSubmit(result.input); + }; + + return ( +
void submit(event)} noValidate> +
+ +