From 260d7d4e0bc46671278190a8047b83f14807cc1f Mon Sep 17 00:00:00 2001 From: sam2tom Date: Wed, 16 Sep 2026 18:27:15 +0800 Subject: [PATCH 1/2] feat(web): gate execution writes on proven compatibility --- apps/web/e2e/agents-lifecycle.spec.ts | 132 ++++++++++++------ apps/web/e2e/core-connection.spec.ts | 6 +- apps/web/e2e/fixture-core.mjs | 22 ++- apps/web/src/App.tsx | 86 +++++++----- .../src/components/ConnectionModal.test.tsx | 8 +- apps/web/src/components/ConnectionModal.tsx | 4 +- .../features/CoreCollectionStates.test.tsx | 77 +++++++++- apps/web/src/features/agents/AgentForm.tsx | 2 +- .../src/features/agents/AgentSetupView.tsx | 4 +- .../src/features/sessions/SessionsView.tsx | 107 ++++++++++++-- .../src/lib/execution-compatibility.test.ts | 49 +++++++ apps/web/src/lib/execution-compatibility.ts | 81 +++++++++++ apps/web/src/style.css | 24 ++++ docs/protocol-coverage.md | 55 ++++++-- 14 files changed, 535 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/lib/execution-compatibility.test.ts create mode 100644 apps/web/src/lib/execution-compatibility.ts diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index a157cc0..b565857 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -817,7 +817,7 @@ test("keeps a stale Session row and surfaces each explicit repeated 404 deletion } }); -test("deletes an inactive Session without disturbing the active composer or listening stream", async ({ page, request }) => { +test("deletes an inactive Session without disturbing the active read-only workspace or listening stream", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); @@ -826,7 +826,8 @@ test("deletes an inactive Session without disturbing the active composer or list await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); const composer = page.getByLabel("Message the Agent"); - await composer.fill("active draft must survive"); + await expect(composer).toBeDisabled(); + await expect(composer).toHaveAttribute("placeholder", "Execution compatibility is not publicly proven"); const before = await fixtureState(request); const activeId = before.sessions.find((session) => session.id !== "session_snapshot")?.id; expect(activeId).toBeTruthy(); @@ -850,7 +851,8 @@ test("deletes an inactive Session without disturbing the active composer or list await deleteClick; await expect(dialog).toHaveCount(0); await expect(inactiveRow).toHaveCount(0); - await expect(composer).toHaveValue("active draft must survive"); + await expect(composer).toBeDisabled(); + await expect(page.getByRole("note", { name: "Execution writes unavailable" })).toBeVisible(); await expect(page.getByText("listening", { exact: true })).toBeVisible(); await expect(page.locator(".conversation-session-action")).toBeFocused(); @@ -1043,7 +1045,11 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect(panel.getByRole("link", { name: "Launcher setup" })).toBeVisible(); await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); await expect(page.getByRole("region", { name: "Function result required" })).toBeVisible(); - await expect(page.getByLabel("Function result or error")).toBeVisible(); + await expect(page.getByRole("note", { name: "Execution writes unavailable" })).toContainText("not publicly proven"); + await expect(page.getByLabel("Function result or error")).toBeDisabled(); + await expect(page.getByRole("button", { name: "Return error" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Submit result" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); await expect(page.locator("body")).not.toContainText("launcher:private"); await expect(page.locator("body")).not.toContainText("executor_token=secret"); await expect(page.locator('a[href^="file:"]')).toHaveCount(0); @@ -1357,50 +1363,98 @@ test("renders Parsar patches as accessible read-only diffs in desktop and narrow 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) => { +test("keeps unproven message and function-result writes blocked at desktop and narrow widths", async ({ page, request }, testInfo) => { await resetFixture(request); await page.goto("/"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); + const notice = page.getByRole("note", { name: "Execution writes unavailable" }); const composer = page.getByLabel("Message the Agent"); + const send = page.getByRole("button", { name: "Send message" }); - 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( + await expect(notice).toContainText("Execution compatibility is not publicly proven by the connected Core"); + await expect(notice).toContainText("keeps the Session read-only"); + await expect(composer).toBeDisabled(); + await expect(send).toBeDisabled(); + const writesBefore = (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( + ).length; + + await composer.evaluate((element) => element.removeAttribute("disabled")); + await composer.fill("must remain local"); + await send.evaluate((element) => element.removeAttribute("disabled")); + await send.click(); + await page.waitForTimeout(250); + await expect(composer).toHaveValue("must remain local"); + expect((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); + )).toHaveLength(writesBefore); + await attachScreenshot(page, testInfo, "desktop-execution-read-only"); - 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(); + await controlFixture(request, { environmentScenario: 7 }); + await page.reload(); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + const steering = page.getByLabel("Message the Agent"); + await expect(steering).toBeDisabled(); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); + await steering.evaluate((element) => element.removeAttribute("disabled")); + await steering.fill("must not steer"); + await steering.evaluate((element) => element.removeAttribute("disabled")); + await steering.press("Enter"); + await page.waitForTimeout(250); + await expect(steering).toHaveValue("must not steer"); + expect((await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + )).toHaveLength(writesBefore); - sends = (await fixtureRequests(request)).filter( + await controlFixture(request, { environmentScenario: 6 }); + await page.reload(); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); + const cancel = page.getByRole("button", { name: "Cancel active Turn" }); + await expect(cancel).toBeEnabled(); + await cancel.click(); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(writesBefore + 1); + const writesAfterCancel = (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"); + expect(writesAfterCancel).toHaveLength(writesBefore + 1); + expect(writesAfterCancel.at(-1)?.body).toEqual({ + events: [{ type: "agent.session.input.cancel" }], + }); + + await controlFixture(request, { environmentScenario: 1 }); + await page.reload(); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Function result required" })).toBeVisible(); + const returnError = page.getByRole("button", { name: "Return error" }); + await expect(page.getByLabel("Function result or error")).toBeDisabled(); + await expect(returnError).toBeDisabled(); + await expect(page.getByRole("button", { name: "Submit result" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); + + await returnError.evaluate((element) => element.removeAttribute("disabled")); + await returnError.click(); + await page.waitForTimeout(250); + expect((await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + )).toHaveLength(writesBefore + 1); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "Dark theme" }).click(); + const bounds = await page.getByRole("note", { name: "Execution writes unavailable" }).evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(bounds.document).toBeLessThanOrEqual(bounds.viewport); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(bounds.viewport); + await attachScreenshot(page, testInfo, "narrow-dark-execution-read-only"); }); diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts index c7b86c4..13d9974 100644 --- a/apps/web/e2e/core-connection.spec.ts +++ b/apps/web/e2e/core-connection.spec.ts @@ -333,7 +333,8 @@ test("announces loading, authenticated access, and each safe failure state from await expect(loading).toContainText("Testing Core connection…"); const terminal = dialog.getByRole(expected.role); await expect(terminal).toContainText(expected.text); - await expect(terminal).toContainText("Execution readiness: Unknown / not verified"); + await expect(terminal).toContainText("Execution compatibility: Unknown / not publicly proven"); + await expect(terminal).toContainText("Turn-driving writes remain disabled"); if (expected.absentText) await expect(terminal).not.toContainText(expected.absentText); } @@ -373,7 +374,8 @@ test("turns a stalled probe into one bounded unreachable result", async ({ page, (window as ProbeInstrumentationWindow).__stalledProbeCallCount ?? 0 ))).toBe(1); await expect(dialog.getByRole("alert")).toContainText("Core unreachable", { timeout: 7_500 }); - await expect(dialog.getByRole("alert")).toContainText("Execution readiness: Unknown / not verified"); + await expect(dialog.getByRole("alert")).toContainText("Execution compatibility: Unknown / not publicly proven"); + await expect(dialog.getByRole("alert")).toContainText("Turn-driving writes remain disabled"); expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__stalledProbeCallCount)).toBe(1); }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index e6dfee1..230d036 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -253,7 +253,13 @@ function applyEnvironmentScenario(value) { const session = state.sessions[0]; if (!session) return; const hostileRemote = "https://launcher:private@executor.example.test/connect?executor_token=secret#credential"; - if (value === 1 || value === 4 || value === 5) { + if (value === 7) { + session.environment = { type: "none" }; + session.status = "in_progress"; + 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", @@ -261,11 +267,15 @@ function applyEnvironmentScenario(value) { workspace_directory: `/workspace//${"long/".repeat(45)}project`, capability_directories: ["/capabilities/read-only", `/capabilities/${"wide/".repeat(55)}`], }; - session.status = value === 1 ? "requires_action" : "idle"; - session.required_actions = value === 1 ? [ - { type: "environment_connection", environment_id: "environment_fixture" }, - { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, - ] : []; + session.status = value === 1 || value === 6 ? "requires_action" : "idle"; + session.required_actions = value === 1 + ? [ + { type: "environment_connection", environment_id: "environment_fixture" }, + { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, + ] + : value === 6 + ? [{ type: "environment_connection", environment_id: "environment_fixture" }] + : []; return; } if (value === 2) { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bccdca3..df15b8b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -74,6 +74,10 @@ import { type CoreConnectionState, } from "./lib/connection"; import { settleCollection } from "./lib/collection-load"; +import { + DEFAULT_EXECUTION_COMPATIBILITY, + guardedExecutionWrite, +} from "./lib/execution-compatibility"; import { beginPendingSend, failPendingSend, @@ -100,6 +104,8 @@ import { type View = ProductView | "system"; +const executionCompatibility = DEFAULT_EXECUTION_COMPATIBILITY; + interface StreamConnection { sessionId: string | null; state: StreamState; @@ -1036,39 +1042,44 @@ export function App() { 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; - }); + await guardedExecutionWrite(executionCompatibility, { + connectionGeneration: connectionGenerationRef.current, + sessionId: selectedIdRef.current ?? "", + }, async () => { + if (!streamReady) { + const message = "Wait for the live event stream to connect before sending."; + notify(message, "error"); + throw new Error(message); } - 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; + 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); }); - await refreshSelectedSession(sessionId); }; const cancel = async () => { @@ -1082,9 +1093,14 @@ export function App() { const submitFunctionResult = async (input: FunctionResultInput) => { const sessionId = selectedId; if (!sessionId) return; - await run(() => core.submitFunctionResult(sessionId, input), "Function result submitted."); - if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; - await refreshSelectedSession(sessionId); + await guardedExecutionWrite(executionCompatibility, { + connectionGeneration: connectionGenerationRef.current, + sessionId: selectedIdRef.current ?? "", + }, async () => { + await run(() => core.submitFunctionResult(sessionId, input), "Function result submitted."); + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + await refreshSelectedSession(sessionId); + }); }; const applyConnection = (next: CoreConnection) => { @@ -1225,6 +1241,8 @@ export function App() { onCreateRequestConsumed={consumeSessionCreateRequest} detailError={detailError} detailState={detailState} + executionCompatibility={executionCompatibility} + executionConnectionGeneration={coreGeneration} turnError={turnError} turnState={turnState} environmentObservation={environmentObservation} diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx index b4536fd..68e6c74 100644 --- a/apps/web/src/components/ConnectionModal.test.tsx +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -110,7 +110,8 @@ describe("Agent Core connection modes", () => { 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("Execution readiness remains Unknown / not verified"); + expect(markup).toContain("Execution compatibility remains Unknown / not publicly proven"); + expect(markup).toContain("Turn-driving writes stay disabled"); expect(markup).toContain("does not prove a daemon, model, or provider is ready"); }); }); @@ -172,8 +173,9 @@ describe("Connection probe status", () => { const markup = renderToStaticMarkup(); expect(markup).toContain(expected); - expect(markup).toContain("Execution readiness: Unknown / not verified"); - expect(markup).not.toContain("Execution readiness: Ready"); + expect(markup).toContain("Execution compatibility: Unknown / not publicly proven"); + expect(markup).toContain("Turn-driving writes remain disabled"); + expect(markup).not.toContain("Execution compatibility: Ready"); }); it("uses an alert for failures and a polite status for authenticated access", () => { diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx index 63ccc76..925b901 100644 --- a/apps/web/src/components/ConnectionModal.tsx +++ b/apps/web/src/components/ConnectionModal.tsx @@ -102,7 +102,7 @@ export function ConnectionProbeStatus({ state }: { state: ConnectionProbeState } > {copy.title}

{copy.detail}

- Execution readiness: Unknown / not verified. + Execution compatibility: Unknown / not publicly proven. Turn-driving writes remain disabled. ); } @@ -331,7 +331,7 @@ export function ConnectionModal({
); diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx index 047615a..c498f71 100644 --- a/apps/web/src/features/CoreCollectionStates.test.tsx +++ b/apps/web/src/features/CoreCollectionStates.test.tsx @@ -13,6 +13,8 @@ const agentsCallbacks = { }; const sessionsCallbacks = { + executionCompatibility: { state: "unknown" as const }, + executionConnectionGeneration: 0, onCancel: async () => undefined, onCreateSession: async () => undefined, onDeleteSession: async () => true, @@ -148,7 +150,7 @@ describe("Agent Core collection states", () => { expect(empty).toContain("Select or create a Session"); }); - it("keeps stale Sessions and a healthy selected workspace usable after collection refresh fails", () => { + it("keeps stale Sessions and a healthy selected workspace inspectable after collection refresh fails", () => { const failed = renderToStaticMarkup( { expect(failed).toContain("sessions refresh failed"); expect(failed).toContain("Existing durable item"); expect(failed).toContain("listening"); - expect(composer).not.toContain("disabled"); + expect(failed).toContain("Session is read-only"); + expect(composer).toContain("disabled"); + }); + + it("keeps message and function-result execution writes read-only until compatibility is proven", () => { + const messageView = renderToStaticMarkup( + , + ); + const composer = messageView.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0] ?? ""; + const send = messageView.match(/]*aria-label="Send message"[^>]*>/)?.[0] ?? ""; + + expect(messageView).toContain('aria-label="Execution writes unavailable"'); + expect(messageView).toContain("Execution compatibility is not publicly proven by the connected Core"); + expect(messageView).toContain("This Web keeps the Session read-only"); + expect(messageView).not.toContain("does not currently have an execution worker"); + expect(composer).toContain("disabled"); + expect(send).toContain("disabled"); + + const functionView = renderToStaticMarkup( + , + ); + const functionInput = functionView.match(/]*aria-label="Function result or error"[^>]*>/)?.[0] ?? ""; + const cancel = functionView.match(/]*aria-label="Cancel active Turn"[^>]*>/)?.[0] ?? ""; + + expect(functionView).toContain("reports this execution profile as unsupported"); + expect(functionInput).toContain("disabled"); + expect(functionView).toMatch(/]*disabled=""[^>]*>Return error<\/button>/); + expect(functionView).toMatch(/]*disabled=""[^>]*>Submit result<\/button>/); + expect(cancel).not.toContain("disabled"); }); it("does not claim an empty timeline before the selected Session load succeeds", () => { @@ -203,8 +271,8 @@ describe("Agent Core collection states", () => { 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."); + expect(ready).toContain("Session is read-only"); + expect(ready).toContain("You can inspect durable state and live events"); }); it("keeps durable Items visible beside refresh and terminal Session failures", () => { @@ -392,6 +460,7 @@ describe("Agent Core collection states", () => { 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/AgentForm.tsx b/apps/web/src/features/agents/AgentForm.tsx index 691775f..4cb6417 100644 --- a/apps/web/src/features/agents/AgentForm.tsx +++ b/apps/web/src/features/agents/AgentForm.tsx @@ -242,7 +242,7 @@ export function AgentForm({ agent, disabled = false, formId, knownModels, onDraf

- New Agents use the current cross-engine Session profile: implicit reasoning, medium verbosity, service tier auto, and text format. Existing saved-only values remain visible; reasoning, verbosity, and tier stay editable, while known incompatible settings block Session start. Model and provider compatibility still require a real Turn. + New Agents use the current cross-engine Session profile: implicit reasoning, medium verbosity, service tier auto, and text format. Existing saved-only values remain visible; reasoning, verbosity, and tier stay editable, while known incompatible settings block Session start. Saved configuration is not execution proof, and Turn-driving writes remain disabled until Core exposes a versioned compatibility contract.

{configurationError ?

{configurationError}

: null} diff --git a/apps/web/src/features/agents/AgentSetupView.tsx b/apps/web/src/features/agents/AgentSetupView.tsx index c3fe818..64a3510 100644 --- a/apps/web/src/features/agents/AgentSetupView.tsx +++ b/apps/web/src/features/agents/AgentSetupView.tsx @@ -41,8 +41,8 @@ function SetupGuide({ saved }: { saved: boolean }) { const steps = [ ["Define an Agent", "Choose a model and instructions; the Web keeps generation settings on the current Session-safe profile.", true], ["Save the definition", "Core becomes the durable source of truth for the saved Agent.", saved], - ["Start a Session", "Create an idle environment:none Session and subscribe before sending input.", false], - ["Exchange events", "A real Turn still requires a compatible worker, executor, model, and provider.", false], + ["Start a Session", "Create an idle environment:none Session for durable, read-only inspection.", false], + ["Enable execution", "Turn-driving writes stay disabled until Core publishes versioned compatibility proof.", false], ] as const; return (
diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 2969e90..c668c26 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -11,7 +11,7 @@ import { RefreshCw, Square, } from "lucide-react"; -import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; +import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; import type { AgentSession, @@ -30,6 +30,10 @@ import { Modal } from "../../components/Modal"; import { Skeleton } from "../../components/Skeleton"; import { StatusIcon, type StatusKind } from "../../components/StatusIcon"; import type { CoreConnectionState } from "../../lib/connection"; +import { + executionWriteBlocker, + type ExecutionCompatibility, +} from "../../lib/execution-compatibility"; import { useThreadScroll } from "../../lib/use-thread-scroll"; import { knownSessionAdmissionBlocker } from "../agents/session-admission"; import { @@ -57,6 +61,8 @@ interface SessionsViewProps { onCreateRequestConsumed?: (request: number) => void; detailError: string | null; detailState: SessionDetailState; + executionCompatibility: ExecutionCompatibility; + executionConnectionGeneration: number; turnError?: string | null; turnState?: TurnTimelineLoadState; environmentObservation?: EnvironmentObservation | null; @@ -192,11 +198,54 @@ function UnsupportedActionNotice() { ); } +function ExecutionCompatibilityNotice({ id, blocker }: { id: string; blocker: string }) { + return ( +
+
+ + Session is read-only +
+

{blocker}

+
+ ); +} + +function CancelActiveTurnButton({ busy, onCancel }: { busy: boolean; onCancel: () => void }) { + return ( + + ); +} + +function CancelOnlyBar({ busy, onCancel }: { busy: boolean; onCancel: () => void }) { + return ( +
+

Turn continuation is unavailable, but cancellation remains available.

+ +
+ ); +} + function FunctionActionBar({ actions, agentName, autoFocus, busy, + executionBlocker, + executionDescriptionId, onCancel, onSubmit, }: { @@ -204,6 +253,8 @@ function FunctionActionBar({ agentName: string; autoFocus: boolean; busy: boolean; + executionBlocker: string | null; + executionDescriptionId?: string; onCancel: () => void; onSubmit: (input: FunctionResultInput) => Promise; }) { @@ -241,14 +292,16 @@ function FunctionActionBar({ onChange={(event) => setResult(event.target.value)} placeholder="Return a result or describe the error…" aria-label="Function result or error" + aria-describedby={executionDescriptionId} rows={3} - disabled={busy} + disabled={busy || Boolean(executionBlocker)} />
- +
); @@ -282,6 +334,8 @@ export function SessionsView({ onCreateRequestConsumed, detailError, detailState, + executionCompatibility, + executionConnectionGeneration, turnError = null, turnState = "idle", environmentObservation = null, @@ -312,6 +366,7 @@ export function SessionsView({ const [actionSession, setActionSession] = useState(null); const [viewport, setViewport] = useState(null); const [threadContent, setThreadContent] = useState(null); + const executionNoticeId = useId(); const sendingRef = useRef(false); const pageRef = useRef(null); const newSessionActionRef = useRef(null); @@ -329,6 +384,10 @@ export function SessionsView({ const selectedAgent = agents.find((agent) => agent.id === agentId); const selectedAgentBlocker = selectedAgent ? knownSessionAdmissionBlocker(selectedAgent) : null; + const executionBlocker = executionWriteBlocker(executionCompatibility, { + connectionGeneration: executionConnectionGeneration, + sessionId: selected?.id ?? "", + }); useEffect(() => { if (selectedAgent && !selectedAgentBlocker) return; @@ -434,6 +493,10 @@ export function SessionsView({ const unsupportedActionCount = requiredActions.length - environmentConnections.length - functionActions.length + ( !requiredActionsAreValid || selected?.status === "requires_action" && !requiredActions.length ? 1 : 0 ); + const showCancelOnly = Boolean( + (selected?.status === "in_progress" || selected?.status === "requires_action") && + (unsupportedActionCount > 0 || environmentConnections.length > 0 && functionActions.length === 0), + ); return (
@@ -650,9 +713,11 @@ export function SessionsView({ {detailState === "ready" && streamState !== "failed" && selected.status !== "failed" && !items.length ? (
-

Session is ready

+

{executionBlocker ? "Session is read-only" : "Session is ready"}

- {streamState === "listening" + {executionBlocker + ? "You can inspect durable state and live events, but this Web will not submit execution writes." + : streamState === "listening" ? "Live events are connected. Message execution also requires a Core worker and executor." : "Opening the event stream before enabling the composer."}

@@ -669,16 +734,24 @@ export function SessionsView({
+ {executionBlocker ? ( + + ) : null} {environmentConnections.map((action, index) => ( ))} {unsupportedActionCount ? : null} + {showCancelOnly ? ( + + ) : null} {!unsupportedActionCount && functionActions.length ? ( @@ -695,10 +768,15 @@ export function SessionsView({ element.style.height = `${Math.min(element.scrollHeight, 200)}px`; }} onKeyDown={onComposerKeyDown} - placeholder={selected.status === "failed" ? "This Session has failed" : `Message ${selected.agent.name || "the Agent"}…`} + placeholder={selected.status === "failed" + ? "This Session has failed" + : executionBlocker + ? "Execution compatibility is not publicly proven" + : `Message ${selected.agent.name || "the Agent"}…`} aria-label="Message the Agent" + aria-describedby={executionBlocker ? executionNoticeId : undefined} rows={1} - disabled={detailState !== "ready" || selected.status === "failed"} + disabled={Boolean(executionBlocker) || detailState !== "ready" || selected.status === "failed"} />
@@ -706,16 +784,15 @@ export function SessionsView({ {selected.agent.name || "Untitled Agent"} {selected.status === "in_progress" || selected.status === "requires_action" ? ( - + ) : ( diff --git a/apps/web/src/lib/execution-compatibility.test.ts b/apps/web/src/lib/execution-compatibility.test.ts new file mode 100644 index 0000000..ce2c37f --- /dev/null +++ b/apps/web/src/lib/execution-compatibility.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ExecutionCompatibilityError, + executionWriteBlocker, + guardedExecutionWrite, + normalizeExecutionCompatibility, +} from "./execution-compatibility"; + +describe("execution compatibility", () => { + const scope = { connectionGeneration: 7, sessionId: "session-1" }; + const forgedSupported = { + state: "supported" as const, + proof: { ...scope, contractVersion: "agents-execution/v1" }, + }; + + it.each([ + undefined, + null, + "", + "ready", + { state: "supported" }, + { state: "supported", proof: { ...scope, contractVersion: "" } }, + { state: "supported", proof: { ...scope, connectionGeneration: 6, contractVersion: "agents-execution/v1" } }, + { state: "supported", proof: { ...scope, sessionId: "session-2", contractVersion: "agents-execution/v1" } }, + forgedSupported, + ])("normalizes malformed or unproven input to unknown", (value) => { + expect(normalizeExecutionCompatibility(value, scope)).toEqual({ state: "unknown" }); + expect(executionWriteBlocker(value, scope)).toContain("not publicly proven"); + }); + + it("keeps unsupported distinct without enabling writes", () => { + expect(normalizeExecutionCompatibility({ state: "unsupported" }, scope)).toEqual({ state: "unsupported" }); + expect(executionWriteBlocker({ state: "unsupported" }, scope)).toContain("reports this execution profile as unsupported"); + }); + + it.each([{ state: "unknown" }, { state: "unsupported" }, { state: "supported" }, forgedSupported])( + "blocks the final write callback for %j", + async (compatibility) => { + const write = vi.fn(async () => "written"); + + await expect(guardedExecutionWrite(compatibility, scope, write)).rejects.toBeInstanceOf( + ExecutionCompatibilityError, + ); + expect(write).not.toHaveBeenCalled(); + }, + ); + +}); diff --git a/apps/web/src/lib/execution-compatibility.ts b/apps/web/src/lib/execution-compatibility.ts new file mode 100644 index 0000000..843412b --- /dev/null +++ b/apps/web/src/lib/execution-compatibility.ts @@ -0,0 +1,81 @@ +export interface ExecutionWriteScope { + connectionGeneration: number; + sessionId: string; +} + +const verifiedExecutionCompatibility = Symbol("verified execution compatibility"); + +interface VerifiedSupportedExecutionCompatibility { + state: "supported"; + proof: ExecutionWriteScope & { contractVersion: string }; + [verifiedExecutionCompatibility]: true; +} + +export type ExecutionCompatibility = + | { state: "unknown" } + | { state: "unsupported" } + | VerifiedSupportedExecutionCompatibility; + +export const DEFAULT_EXECUTION_COMPATIBILITY: ExecutionCompatibility = Object.freeze({ + state: "unknown", +}); + +const UNKNOWN_EXECUTION_COPY = + "Execution compatibility is not publicly proven by the connected Core. This Web keeps the Session read-only and will not submit execution writes."; + +const UNSUPPORTED_EXECUTION_COPY = + "The connected Core reports this execution profile as unsupported. This Web keeps the Session read-only and will not submit execution writes."; + +export class ExecutionCompatibilityError extends Error { + readonly compatibility: "unknown" | "unsupported"; + + constructor( + compatibility: "unknown" | "unsupported", + message: string, + ) { + super(message); + this.name = "ExecutionCompatibilityError"; + this.compatibility = compatibility; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function scopeMatches(value: unknown, scope: ExecutionWriteScope): value is ExecutionCompatibility & { state: "supported" } { + if (!isRecord(value) || value.state !== "supported" || !isRecord(value.proof)) return false; + const proof = value.proof; + return ( + (value as Record)[verifiedExecutionCompatibility] === true && + typeof proof.contractVersion === "string" && Boolean(proof.contractVersion.trim()) && + Number.isSafeInteger(proof.connectionGeneration) && proof.connectionGeneration === scope.connectionGeneration && + typeof proof.sessionId === "string" && proof.sessionId === scope.sessionId && Boolean(proof.sessionId) + ); +} + +export function normalizeExecutionCompatibility( + value: unknown, + scope: ExecutionWriteScope, +): ExecutionCompatibility { + if (scopeMatches(value, scope)) return value; + if (isRecord(value) && value.state === "unsupported") return { state: "unsupported" }; + return DEFAULT_EXECUTION_COMPATIBILITY; +} + +export function executionWriteBlocker(value: unknown, scope: ExecutionWriteScope): string | null { + const compatibility = normalizeExecutionCompatibility(value, scope); + if (compatibility.state === "supported") return null; + return compatibility.state === "unsupported" ? UNSUPPORTED_EXECUTION_COPY : UNKNOWN_EXECUTION_COPY; +} + +export async function guardedExecutionWrite( + compatibility: unknown, + scope: ExecutionWriteScope, + write: () => Promise, +): Promise { + const normalized = normalizeExecutionCompatibility(compatibility, scope); + if (normalized.state === "supported") return write(); + const blocker = executionWriteBlocker(normalized, scope) ?? UNKNOWN_EXECUTION_COPY; + throw new ExecutionCompatibilityError(normalized.state, blocker); +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 5580827..df5e1d4 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -2792,6 +2792,12 @@ button.trace-step-row:hover { color: var(--fg-muted); } +.approval-input:disabled, +.composer textarea:disabled { + cursor: not-allowed; + opacity: 0.62; +} + .approval-actions { display: flex; align-items: center; @@ -2800,6 +2806,24 @@ button.trace-step-row:hover { gap: 8px; } +.active-turn-cancel-bar { + display: flex; + width: 100%; + max-width: 736px; + min-height: 40px; + align-items: center; + justify-content: space-between; + margin: 0 auto; + gap: 12px; +} + +.active-turn-cancel-bar > p { + margin: 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 17px; +} + .composer { display: flex; width: 100%; diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index c4f357c..3d03b29 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -12,6 +12,14 @@ OpenAI-hosted service compatibility. That revision still exposes only Environment retrieve plus Session-bound `self_hosted` creation; it adds no public Environment list, template, file, or browser-facing key management route. +- The execution-compatibility boundary was re-audited against Parsar + [`d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/commit/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee). + Its public Agents API exposes no versioned execution-readiness resource or + Core/daemon/native-harness fingerprint. The default sandbox still installs + Codex `0.141.0` while the pinned native Environment reference is `0.153.4`, + and `environment:none` preparation still calls `environment/status`. Web + therefore treats current execution compatibility as unknown; it does not + sniff those versions or use a failed Turn as discovery. - Upstream resource source: `openai-python` 3.13.0 beta Agents resources at [`d7c41efe`](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents) - Required beta header: `OpenAI-Beta: agents=v1` @@ -37,13 +45,13 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. | Sessions create/list/retrieve | Yes | Yes | UI creates idle `environment:none` Sessions; client types also cover the pinned `self_hosted` request and safe response projection | | Sessions update/delete | Yes | Yes | Title/string metadata editing and one-Session confirmed deletion; no bulk or Workspace deletion | | Session live events | Yes | Yes | Authenticated `fetch` stream, not `EventSource` | -| Input message | Yes | Yes | Opens SSE before submission; uncertain failures retain the in-memory payload/key for an explicit unchanged manual retry only | +| Input message / steering | Yes | Read-only until proven | Unknown or unsupported execution compatibility blocks the control and final App write boundary; no input event is submitted | | Active Turn cancel | Yes | Yes | Submitted as a Session event, not a Turn-create endpoint | | Turn list | Yes | Yes, read-only | Selected Sessions load every page in ascending creation order; no Turn mutation UI | | Turn retrieve | Yes | No | Reusable client diagnostic method; timeline recovery uses the all-pages list | | Item list/recovery | Yes | Yes | Authoritative recovery after stream loss | | Parsar `apply_patch` Item presentation | Existing function Item fields | Yes, read-only | Parsar extension recognized only for the pinned `changes[].{path,kind,diff}` shape; not an OpenAI standard Item type | -| Function result/error | Yes | Yes | Initial UI supports text result/error handoff only for `function_call` actions | +| Function result/error | Yes | Read-only until proven | The form remains inspectable for `function_call` actions, but unknown or unsupported compatibility disables result/error submission | | Initial-input creation stream | Later | No | Idle-create flow avoids the early-event race | | Artifacts/files | Later | No | Required Core resources are not implemented | | Environment connection action | Yes | Render-only | `environment_connection` is distinct from a function call; Web shows an operator-owned, non-actionable state and sends no result | @@ -59,6 +67,24 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. ## Runtime boundary - The Web currently creates only `environment: {"type":"none"}` Sessions. +- Execution compatibility has one Web-owned normalized state: + `supported`, `unsupported`, or `unknown`, defaulting to `unknown`. The current + Core surface cannot promote it to `supported`; the supported variant is sealed + inside the future contract adapter, so structurally similar untrusted data stays + `unknown`. While it is `unknown` or + `unsupported`, message, active-Turn steering, and function-result/error writes + are blocked both in Session controls and at the final App operation boundary. + Agent CRUD, idle Session management, Session/Item/Turn/Usage/Environment/SSE + reads, recovery, metadata/deletion flows, and cancellation of already-active + work remain available. +- A future `supported` state requires an authenticated, public, versioned, + tenant- and Session-scoped execution profile. Its proof must bind the exact + engine and Environment mode, derive readiness from native method probing, and + include freshness/scope semantics. The normalized Web proof is additionally + bound to the current connection generation and exact Session ID before every + write. Missing, malformed, stale, wrong-connection, wrong-Session, or unknown + data remains `unknown`. A revision string or private daemon heartbeat is + insufficient. - Product navigation and the global Create menu do not widen the protocol. Agent and idle Session creation call the existing client methods. Environment template and Environment key entries are non-actionable unavailable states. @@ -159,9 +185,10 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. - Core has no standard model-catalog or capability-discovery route in this surface. Web model presets are editable suggestions. Known reasoning, tier, format, multi-agent, executable-tool-shape, and unattached-credential incompatibilities - are authoritative at Session creation; a real Turn remains necessary to prove - model/provider execution and conditional Codex `low`/`high` verbosity support. - Claude SDK accepts medium verbosity only. + are authoritative at Session creation. A prior successful or failed Turn is + historical observation, not a versioned readiness contract, and cannot promote + the Web state to `supported`; this Web does not send a paid Turn as a capability + probe. Claude SDK accepts medium verbosity only. Environment creation and management beyond the narrow read, provider selection, Files, Plugins, Skills, Artifacts, Vault, hosted runtimes, and Workspace lifecycle @@ -297,18 +324,18 @@ read keeps the existing conversation usable. observability. They are not per-Item timing, monetary cost, provider attribution, or a complete OpenAI Trace waterfall. -The client never retries a write automatically. For an input message that fails with -a network/response-loss error, HTTP 5xx, or transient 408/409/425/429, the Web keeps -the original payload and idempotency key in memory. Only a later user-initiated Send -of the byte-for-byte unchanged payload reuses that key. Editing the payload, changing -Session/Core, a successful response, or a permanent 4xx starts a new operation with a -new key. This state is intentionally not stored in browser persistence, and the UI -cannot prove whether an uncertain request was accepted until durable Core state -reconciles. +The client never retries a write automatically. If a future proven compatibility +contract enables input, a message that fails with a network/response-loss error, +HTTP 5xx, or transient 408/409/425/429 keeps the original payload and idempotency key +in memory. Only a later user-initiated Send of the byte-for-byte unchanged payload +reuses that key. Editing the payload, changing Session/Core, a successful response, +or a permanent 4xx starts a new operation with a new key. This state is intentionally +not stored in browser persistence, and the UI cannot prove whether an uncertain +request was accepted until durable Core state reconciles. HTTP acceptance, `/healthz`, an open SSE connection, and successful Agent creation do not prove that a daemon, native harness, model ID, or provider credential can -complete a Turn. +complete a Turn and never upgrade execution compatibility. ## Terminology From f9f84e001ea566beb72c8c34ed54b9d9a711ca89 Mon Sep 17 00:00:00 2001 From: sam2tom Date: Wed, 16 Sep 2026 21:54:33 +0800 Subject: [PATCH 2/2] fix(web): align chat with current Core contract Restore documented Session event writes with exact HTTP 204 admission, remove unsupported top-level Environment surfaces, and refresh the pinned Parsar compatibility coverage. --- README.md | 21 +- README.zh-CN.md | 16 +- apps/web/e2e/agents-lifecycle.spec.ts | 137 ++++----- apps/web/e2e/core-connection.spec.ts | 10 +- apps/web/e2e/fixture-core.mjs | 6 - apps/web/src/App.tsx | 97 +++---- .../src/components/ConnectionModal.test.tsx | 19 +- apps/web/src/components/ConnectionModal.tsx | 10 +- apps/web/src/components/CreateMenu.test.tsx | 11 +- apps/web/src/components/CreateMenu.tsx | 10 - .../src/components/ProductNavigation.test.tsx | 6 +- apps/web/src/components/ProductNavigation.tsx | 5 +- .../features/CoreCollectionStates.test.tsx | 84 +----- apps/web/src/features/agents/AgentForm.tsx | 2 +- .../src/features/agents/AgentSetupView.tsx | 4 +- .../environments/EnvironmentsView.test.tsx | 112 -------- .../environments/EnvironmentsView.tsx | 171 ------------ .../src/features/sessions/SessionsView.tsx | 80 +----- .../sessions/environment/EnvironmentPanel.tsx | 2 +- .../src/lib/execution-compatibility.test.ts | 49 ---- apps/web/src/lib/execution-compatibility.ts | 81 ------ apps/web/src/style.css | 262 +----------------- docs/architecture.md | 34 ++- docs/protocol-coverage.md | 100 +++---- packages/agents-client/src/client.test.ts | 32 +++ packages/agents-client/src/client.ts | 14 +- 26 files changed, 272 insertions(+), 1103 deletions(-) delete mode 100644 apps/web/src/features/environments/EnvironmentsView.test.tsx delete mode 100644 apps/web/src/features/environments/EnvironmentsView.tsx delete mode 100644 apps/web/src/lib/execution-compatibility.test.ts delete mode 100644 apps/web/src/lib/execution-compatibility.ts diff --git a/README.md b/README.md index ddaccce..898e97a 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,12 @@ Restart `pnpm dev` after changing them. Keep credentials server-side. A direct Core URL in the connection dialog is only for a compatible Core that explicitly allows the Web origin, methods, and headers through CORS. -Do not have a Core running yet? Follow [Connecting Agent Core](docs/core-connection.md). -That guide contains the complete PostgreSQL, caller key, device, daemon, native -harness, `CODEX_HOME`, security, verification, and shutdown procedures. +Do not have a Core running yet? Use the immutable +[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service). +The repository's [legacy Web connection runbook](docs/core-connection.md) is pinned +to the older revision stated at its top; revalidate its PostgreSQL, caller-key, +device, daemon, native-harness, `CODEX_HOME`, verification, and shutdown steps before +applying them to a newer Core. ## First use @@ -110,16 +113,18 @@ See [Architecture](docs/architecture.md) for the full component and trust bounda | --- | --- | | Web cannot reach Core | Confirm the Core address and `AGENTS_API_PROXY_TARGET`, then restart Vite | | `401 invalid_api_key` | The plaintext caller bearer must match the current Core key binding | -| `503 execution_unavailable` / `Execution is not enabled` | Core is reachable but has no enabled execution path; connect its configured executor/daemon | +| `503 execution_unavailable` / `Execution is not enabled` | Core rejected execution; inspect its safe error plus runtime and ownership state. A worker, executor, or daemon may be unconfigured or disconnected, or an execution lease may have been lost | | Agent saves but its model fails | Use a model ID and provider credential supported by the connected runtime | -`/healthz` proves HTTP liveness only, not chat readiness. See the -[full troubleshooting guide](docs/core-connection.md#troubleshooting) before retrying -an uncertain request. +`/healthz` proves HTTP liveness only, not chat readiness. Check durable Core state and +the current pinned Parsar guide before retrying an uncertain request; the +[legacy 043 troubleshooting snapshot](docs/core-connection.md#troubleshooting) is +historical context only. ## Documentation -- [Connect Agent Core](docs/core-connection.md) — full local and deployment setup +- [Current Parsar Core setup](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) — immutable current upstream guide +- [Legacy Web connection runbook](docs/core-connection.md) — historical `0438880` snapshot; revalidate before use - [Protocol coverage](docs/protocol-coverage.md) — exact supported API surface - [Architecture](docs/architecture.md) — ownership, runtime, and trust boundaries - [Roadmap](docs/roadmap.md) — planned Web and Core integrations diff --git a/README.zh-CN.md b/README.zh-CN.md index a186461..badac83 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,9 +70,11 @@ AGENTS_API_PROXY_TOKEN_FILE=/absolute/private/path/to/web-token 修改后重启 `pnpm dev`。凭据应保留在服务端。只有兼容 Core 通过 CORS 明确允许 Web 的源、方法和请求头时,才能在连接对话框中使用 Core 直连 URL。 -还没有运行中的 Core?请参阅[连接 Agent Core](docs/core-connection.md)。 -该文档包含完整的 PostgreSQL、调用方凭据、执行设备、daemon、原生执行适配层(harness)、 -`CODEX_HOME`、安全、验证和停止流程。 +还没有运行中的 Core?请使用不可变的 +[当前 Parsar 配置指南](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service)。 +仓库内的[旧版 Web 连接手册](docs/core-connection.md)固定在文首标注的旧 revision; +将其中 PostgreSQL、调用方凭据、执行设备、daemon、原生执行适配层(harness)、 +`CODEX_HOME`、验证和停止流程用于更新版 Core 前必须重新核对。 ## 第一次使用 @@ -108,15 +110,17 @@ WebSocket 当作 API URL。 | --- | --- | | Web 无法访问 Core | 确认 Core 地址和 `AGENTS_API_PROXY_TARGET`,然后重启 Vite | | `401 invalid_api_key` | 明文调用方 Bearer 凭据必须与 Core 当前的密钥绑定匹配 | -| `503 execution_unavailable` / `Execution is not enabled` | Core 可以访问,但没有已启用的执行链路;请连接其配置的 executor/daemon | +| `503 execution_unavailable` / `Execution is not enabled` | Core 拒绝执行;请检查其安全错误、运行时和 ownership 状态。worker、executor 或 daemon 可能未配置或已断连,也可能丢失了执行 lease | | Agent 保存成功但模型运行失败 | 使用已连接运行时支持的 model ID 和提供商凭据 | `/healthz` 只能证明 HTTP 存活,不能证明聊天已就绪。重试结果不确定的请求前, -请先查看[完整故障排查](docs/core-connection.md#troubleshooting)。 +请先核对 Core 持久状态和当前固定版本的 Parsar 指南; +[旧版 043 故障排查快照](docs/core-connection.md#troubleshooting)仅供历史参考。 ## 文档入口 -- [连接 Agent Core](docs/core-connection.md) — 完整本地和部署配置 +- [当前 Parsar Core 配置](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) — 不可变的当前上游指南 +- [旧版 Web 连接手册](docs/core-connection.md) — 历史 `0438880` 快照,使用前必须重新核对 - [协议覆盖范围](docs/protocol-coverage.md) — 准确的已支持 API 范围 - [架构说明](docs/architecture.md) — 所有权、运行时和信任边界 - [路线图](docs/roadmap.md) — 计划中的 Web 和 Core 集成 diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index b565857..29ac6c2 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -503,8 +503,7 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode expect(metrics.ledger?.right).toBeLessThanOrEqual(390); await attachScreenshot(page, testInfo, "narrow-light-agent-ledger"); - await page.getByRole("button", { name: "Environments", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Environments Observed" })).toBeVisible(); + 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"); @@ -515,6 +514,9 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode 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); @@ -817,7 +819,7 @@ test("keeps a stale Session row and surfaces each explicit repeated 404 deletion } }); -test("deletes an inactive Session without disturbing the active read-only workspace or listening stream", async ({ page, request }) => { +test("deletes an inactive Session without disturbing the active composer or listening stream", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); @@ -826,8 +828,7 @@ test("deletes an inactive Session without disturbing the active read-only worksp await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); const composer = page.getByLabel("Message the Agent"); - await expect(composer).toBeDisabled(); - await expect(composer).toHaveAttribute("placeholder", "Execution compatibility is not publicly proven"); + 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(); @@ -851,8 +852,7 @@ test("deletes an inactive Session without disturbing the active read-only worksp await deleteClick; await expect(dialog).toHaveCount(0); await expect(inactiveRow).toHaveCount(0); - await expect(composer).toBeDisabled(); - await expect(page.getByRole("note", { name: "Execution writes unavailable" })).toBeVisible(); + await expect(composer).toHaveValue("active draft must survive"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); await expect(page.locator(".conversation-session-action")).toBeFocused(); @@ -1045,9 +1045,8 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect(panel.getByRole("link", { name: "Launcher setup" })).toBeVisible(); await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); await expect(page.getByRole("region", { name: "Function result required" })).toBeVisible(); - await expect(page.getByRole("note", { name: "Execution writes unavailable" })).toContainText("not publicly proven"); - await expect(page.getByLabel("Function result or error")).toBeDisabled(); - await expect(page.getByRole("button", { name: "Return error" })).toBeDisabled(); + await expect(page.getByLabel("Function result or error")).toBeEnabled(); + await expect(page.getByRole("button", { name: "Return error" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Submit result" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); await expect(page.locator("body")).not.toContainText("launcher:private"); @@ -1363,98 +1362,72 @@ test("renders Parsar patches as accessible read-only diffs in desktop and narrow await attachScreenshot(page, testInfo, "narrow-dark-parsar-diff"); }); -test("keeps unproven message and function-result writes blocked at desktop and narrow widths", async ({ page, request }, testInfo) => { +test("manually retries uncertain sends with the original key only while the payload is unchanged", async ({ page, request }, testInfo) => { await resetFixture(request); await page.goto("/"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); - const notice = page.getByRole("note", { name: "Execution writes unavailable" }); const composer = page.getByLabel("Message the Agent"); - const send = page.getByRole("button", { name: "Send message" }); - await expect(notice).toContainText("Execution compatibility is not publicly proven by the connected Core"); - await expect(notice).toContainText("keeps the Session read-only"); - await expect(composer).toBeDisabled(); - await expect(send).toBeDisabled(); - const writesBefore = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ).length; + 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(); - await composer.evaluate((element) => element.removeAttribute("disabled")); - await composer.fill("must remain local"); - await send.evaluate((element) => element.removeAttribute("disabled")); - await send.click(); - await page.waitForTimeout(250); - await expect(composer).toHaveValue("must remain local"); - expect((await fixtureRequests(request)).filter( + 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"), - )).toHaveLength(writesBefore); - await attachScreenshot(page, testInfo, "desktop-execution-read-only"); + ); + expect(sends).toHaveLength(4); + expect(sends[3]?.idempotencyKey).not.toBe(sends[2]?.idempotencyKey); - await controlFixture(request, { environmentScenario: 7 }); - await page.reload(); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); - const steering = page.getByLabel("Message the Agent"); - await expect(steering).toBeDisabled(); - await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); - await steering.evaluate((element) => element.removeAttribute("disabled")); - await steering.fill("must not steer"); - await steering.evaluate((element) => element.removeAttribute("disabled")); - await steering.press("Enter"); - await page.waitForTimeout(250); - await expect(steering).toHaveValue("must not steer"); - expect((await fixtureRequests(request)).filter( + 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"), - )).toHaveLength(writesBefore); + ); + expect(sends).toHaveLength(6); + expect(sends[5]?.idempotencyKey).not.toBe(sends[4]?.idempotencyKey); + await attachScreenshot(page, testInfo, "desktop-send-recovery"); +}); +test("keeps cancellation available for an Environment-only required action", async ({ page, request }) => { + await resetFixture(request); await controlFixture(request, { environmentScenario: 6 }); - await page.reload(); + await page.goto("/"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); + const writesBefore = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; const cancel = page.getByRole("button", { name: "Cancel active Turn" }); await expect(cancel).toBeEnabled(); await cancel.click(); await expect.poll(async () => (await fixtureRequests(request)).filter( (entry) => entry.method === "POST" && entry.path.endsWith("/events"), ).length).toBe(writesBefore + 1); - const writesAfterCancel = (await fixtureRequests(request)).filter( + const writes = (await fixtureRequests(request)).filter( (entry) => entry.method === "POST" && entry.path.endsWith("/events"), ); - expect(writesAfterCancel).toHaveLength(writesBefore + 1); - expect(writesAfterCancel.at(-1)?.body).toEqual({ - events: [{ type: "agent.session.input.cancel" }], - }); - - await controlFixture(request, { environmentScenario: 1 }); - await page.reload(); - await expect(page.getByText("listening", { exact: true })).toBeVisible(); - await expect(page.getByRole("region", { name: "Function result required" })).toBeVisible(); - const returnError = page.getByRole("button", { name: "Return error" }); - await expect(page.getByLabel("Function result or error")).toBeDisabled(); - await expect(returnError).toBeDisabled(); - await expect(page.getByRole("button", { name: "Submit result" })).toBeDisabled(); - await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); - - await returnError.evaluate((element) => element.removeAttribute("disabled")); - await returnError.click(); - await page.waitForTimeout(250); - expect((await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - )).toHaveLength(writesBefore + 1); - - await page.setViewportSize({ width: 390, height: 844 }); - await page.getByRole("button", { name: "Dark theme" }).click(); - const bounds = await page.getByRole("note", { name: "Execution writes unavailable" }).evaluate((element) => { - const box = element.getBoundingClientRect(); - return { - viewport: innerWidth, - document: document.documentElement.scrollWidth, - left: box.left, - right: box.right, - }; - }); - expect(bounds.document).toBeLessThanOrEqual(bounds.viewport); - expect(bounds.left).toBeGreaterThanOrEqual(0); - expect(bounds.right).toBeLessThanOrEqual(bounds.viewport); - await attachScreenshot(page, testInfo, "narrow-dark-execution-read-only"); + expect(writes.at(-1)?.body).toEqual({ events: [{ type: "agent.session.input.cancel" }] }); }); diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts index 13d9974..e357bcd 100644 --- a/apps/web/e2e/core-connection.spec.ts +++ b/apps/web/e2e/core-connection.spec.ts @@ -303,7 +303,7 @@ test("announces loading, authenticated access, and each safe failure state from methods.push(route.request().method()); const reply = replies.shift(); if (!reply) return route.abort("failed"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 150)); if ("abort" in reply) return route.abort("failed"); return route.fulfill({ status: reply.status, @@ -333,8 +333,8 @@ test("announces loading, authenticated access, and each safe failure state from await expect(loading).toContainText("Testing Core connection…"); const terminal = dialog.getByRole(expected.role); await expect(terminal).toContainText(expected.text); - await expect(terminal).toContainText("Execution compatibility: Unknown / not publicly proven"); - await expect(terminal).toContainText("Turn-driving writes remain disabled"); + 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); } @@ -374,8 +374,8 @@ test("turns a stalled probe into one bounded unreachable result", async ({ page, (window as ProbeInstrumentationWindow).__stalledProbeCallCount ?? 0 ))).toBe(1); await expect(dialog.getByRole("alert")).toContainText("Core unreachable", { timeout: 7_500 }); - await expect(dialog.getByRole("alert")).toContainText("Execution compatibility: Unknown / not publicly proven"); - await expect(dialog.getByRole("alert")).toContainText("Turn-driving writes remain disabled"); + await expect(dialog.getByRole("alert")).toContainText("Chat uses the current Agents API contract"); + await expect(dialog.getByRole("alert")).toContainText("does not start a Turn or verify its runtime dependencies"); expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__stalledProbeCallCount)).toBe(1); }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 230d036..577736b 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -253,12 +253,6 @@ 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 === 7) { - session.environment = { type: "none" }; - session.status = "in_progress"; - session.required_actions = []; - return; - } if (value === 1 || value === 4 || value === 5 || value === 6) { session.environment = { type: "self_hosted", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index df15b8b..c9fb58e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -29,7 +29,6 @@ import { requestAgentDetail, requestAgentUpdate, } from "./features/agents/agent-actions"; -import { EnvironmentsView } from "./features/environments/EnvironmentsView"; import { SessionsView, type SessionDetailState, @@ -74,10 +73,6 @@ import { type CoreConnectionState, } from "./lib/connection"; import { settleCollection } from "./lib/collection-load"; -import { - DEFAULT_EXECUTION_COMPATIBILITY, - guardedExecutionWrite, -} from "./lib/execution-compatibility"; import { beginPendingSend, failPendingSend, @@ -104,8 +99,6 @@ import { type View = ProductView | "system"; -const executionCompatibility = DEFAULT_EXECUTION_COMPATIBILITY; - interface StreamConnection { sessionId: string | null; state: StreamState; @@ -1042,44 +1035,39 @@ export function App() { const sendMessage = async (text: string) => { const sessionId = selectedId; if (!sessionId) return; - await guardedExecutionWrite(executionCompatibility, { - connectionGeneration: connectionGenerationRef.current, - sessionId: selectedIdRef.current ?? "", - }, async () => { - 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 (!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; + }); } - 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); + 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 () => { @@ -1093,14 +1081,9 @@ export function App() { const submitFunctionResult = async (input: FunctionResultInput) => { const sessionId = selectedId; if (!sessionId) return; - await guardedExecutionWrite(executionCompatibility, { - connectionGeneration: connectionGenerationRef.current, - sessionId: selectedIdRef.current ?? "", - }, async () => { - await run(() => core.submitFunctionResult(sessionId, input), "Function result submitted."); - if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; - await refreshSelectedSession(sessionId); - }); + await run(() => core.submitFunctionResult(sessionId, input), "Function result submitted."); + if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + await refreshSelectedSession(sessionId); }; const applyConnection = (next: CoreConnection) => { @@ -1241,8 +1224,6 @@ export function App() { onCreateRequestConsumed={consumeSessionCreateRequest} detailError={detailError} detailState={detailState} - executionCompatibility={executionCompatibility} - executionConnectionGeneration={coreGeneration} turnError={turnError} turnState={turnState} environmentObservation={environmentObservation} @@ -1282,16 +1263,6 @@ export function App() { onUpdate={updateAgent} /> ) : null} - {view === "environments" ? ( - { - setSelectedId(sessionId); - setView("sessions"); - }} - /> - ) : null} {view === "system" ? : null}
diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx index 68e6c74..7c07ea6 100644 --- a/apps/web/src/components/ConnectionModal.test.tsx +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -89,11 +89,12 @@ describe("Agent Core connection modes", () => { const markup = renderModal("/v1", true); expect(markup).toContain("Operator-owned setup"); - expect(markup).toContain("Connection guide"); - expect(markup).toContain("Troubleshooting"); + expect(markup).toContain("Legacy Web guide · 043 snapshot"); + expect(markup).toContain("Legacy troubleshooting · 043 snapshot"); expect(markup).toContain("Parsar Core setup"); expect(markup).toContain("98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"); - expect(markup).toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); + expect(markup).toContain("d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee"); + expect(markup).not.toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); expect(markup).not.toContain("f7cdf591396529880d80f8211fc7a0f4768fdf46"); expect(markup).not.toContain("8cc2898ca42b272cb3771234ee6a0ad0d2e932ba"); expect(markup).not.toContain("7409e00ca25311805a9f8f0d03614f820e407642"); @@ -104,15 +105,14 @@ describe("Agent Core connection modes", () => { expect(markup).not.toContain("AGENTS_API_DAEMON_WS_URL"); }); - it("states the GET-only probe boundary and unknown execution readiness", () => { + 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("Execution compatibility remains Unknown / not publicly proven"); - expect(markup).toContain("Turn-driving writes stay disabled"); - expect(markup).toContain("does not prove a daemon, model, or provider is ready"); + expect(markup).toContain("Chat uses the current Core events contract"); + expect(markup).toContain("a real request can still fail"); }); }); @@ -173,9 +173,8 @@ describe("Connection probe status", () => { const markup = renderToStaticMarkup(); expect(markup).toContain(expected); - expect(markup).toContain("Execution compatibility: Unknown / not publicly proven"); - expect(markup).toContain("Turn-driving writes remain disabled"); - expect(markup).not.toContain("Execution compatibility: Ready"); + 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", () => { diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx index 925b901..a63ac08 100644 --- a/apps/web/src/components/ConnectionModal.tsx +++ b/apps/web/src/components/ConnectionModal.tsx @@ -29,7 +29,7 @@ export type ConnectionProbeState = | { status: "complete"; result: CoreProbeResult }; const webBaseline = "98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"; -const parsarBaseline = "0438880ab21aa16d05cb91a4c7f91cc0abc12358"; +const parsarBaseline = "d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee"; const operatorGuideUrl = `https://github.com/MiniMax-AI-Dev/agents-core-web/blob/${webBaseline}/docs/core-connection.md`; const troubleshootingUrl = `${operatorGuideUrl}#troubleshooting`; const parsarCoreSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#standalone-http-service`; @@ -102,7 +102,7 @@ export function ConnectionProbeStatus({ state }: { state: ConnectionProbeState } > {copy.title}

{copy.detail}

- Execution compatibility: Unknown / not publicly proven. Turn-driving writes remain disabled. + Chat uses the current Agents API contract. This read-only probe does not start a Turn or verify its runtime dependencies. ); } @@ -316,11 +316,11 @@ export function ConnectionModal({
); diff --git a/apps/web/src/components/CreateMenu.test.tsx b/apps/web/src/components/CreateMenu.test.tsx index 1c3d411..13b7c0b 100644 --- a/apps/web/src/components/CreateMenu.test.tsx +++ b/apps/web/src/components/CreateMenu.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { CreateMenuContent } from "./CreateMenu"; describe("Create menu", () => { - it("keeps supported actions separate from explicit Core-unavailable resources", () => { + it("shows only actions supported by the current Core contract", () => { const html = renderToStaticMarkup( { ); expect(html).toContain('role="menu"'); - expect(html).toContain("Environment template"); - expect(html).toContain("Core exposes no template API"); - expect(html).toContain("operator-owned, never browser-managed"); - expect(html.match(/aria-disabled="true"/g)).toHaveLength(2); + 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 index 738f84e..ee67745 100644 --- a/apps/web/src/components/CreateMenu.tsx +++ b/apps/web/src/components/CreateMenu.tsx @@ -1,8 +1,6 @@ import { Bot, ChevronDown, - FileBox, - KeyRound, MessageSquare, Plus, } from "lucide-react"; @@ -27,14 +25,6 @@ export function CreateMenuContent({
diff --git a/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx b/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx index 563549d..343ab09 100644 --- a/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx +++ b/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx @@ -10,7 +10,7 @@ import type { import { StatusIcon, type StatusKind } from "../../../components/StatusIcon"; import { environmentIdsMatch, type EnvironmentObservation } from "./environment-state"; -const parsarBaseline = "0438880ab21aa16d05cb91a4c7f91cc0abc12358"; +const parsarBaseline = "d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee"; const coreSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#native-executor-transport-prerequisite`; const launcherSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/packages/codex-executor/README.md#connect-an-executor`; diff --git a/apps/web/src/lib/execution-compatibility.test.ts b/apps/web/src/lib/execution-compatibility.test.ts deleted file mode 100644 index ce2c37f..0000000 --- a/apps/web/src/lib/execution-compatibility.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - ExecutionCompatibilityError, - executionWriteBlocker, - guardedExecutionWrite, - normalizeExecutionCompatibility, -} from "./execution-compatibility"; - -describe("execution compatibility", () => { - const scope = { connectionGeneration: 7, sessionId: "session-1" }; - const forgedSupported = { - state: "supported" as const, - proof: { ...scope, contractVersion: "agents-execution/v1" }, - }; - - it.each([ - undefined, - null, - "", - "ready", - { state: "supported" }, - { state: "supported", proof: { ...scope, contractVersion: "" } }, - { state: "supported", proof: { ...scope, connectionGeneration: 6, contractVersion: "agents-execution/v1" } }, - { state: "supported", proof: { ...scope, sessionId: "session-2", contractVersion: "agents-execution/v1" } }, - forgedSupported, - ])("normalizes malformed or unproven input to unknown", (value) => { - expect(normalizeExecutionCompatibility(value, scope)).toEqual({ state: "unknown" }); - expect(executionWriteBlocker(value, scope)).toContain("not publicly proven"); - }); - - it("keeps unsupported distinct without enabling writes", () => { - expect(normalizeExecutionCompatibility({ state: "unsupported" }, scope)).toEqual({ state: "unsupported" }); - expect(executionWriteBlocker({ state: "unsupported" }, scope)).toContain("reports this execution profile as unsupported"); - }); - - it.each([{ state: "unknown" }, { state: "unsupported" }, { state: "supported" }, forgedSupported])( - "blocks the final write callback for %j", - async (compatibility) => { - const write = vi.fn(async () => "written"); - - await expect(guardedExecutionWrite(compatibility, scope, write)).rejects.toBeInstanceOf( - ExecutionCompatibilityError, - ); - expect(write).not.toHaveBeenCalled(); - }, - ); - -}); diff --git a/apps/web/src/lib/execution-compatibility.ts b/apps/web/src/lib/execution-compatibility.ts deleted file mode 100644 index 843412b..0000000 --- a/apps/web/src/lib/execution-compatibility.ts +++ /dev/null @@ -1,81 +0,0 @@ -export interface ExecutionWriteScope { - connectionGeneration: number; - sessionId: string; -} - -const verifiedExecutionCompatibility = Symbol("verified execution compatibility"); - -interface VerifiedSupportedExecutionCompatibility { - state: "supported"; - proof: ExecutionWriteScope & { contractVersion: string }; - [verifiedExecutionCompatibility]: true; -} - -export type ExecutionCompatibility = - | { state: "unknown" } - | { state: "unsupported" } - | VerifiedSupportedExecutionCompatibility; - -export const DEFAULT_EXECUTION_COMPATIBILITY: ExecutionCompatibility = Object.freeze({ - state: "unknown", -}); - -const UNKNOWN_EXECUTION_COPY = - "Execution compatibility is not publicly proven by the connected Core. This Web keeps the Session read-only and will not submit execution writes."; - -const UNSUPPORTED_EXECUTION_COPY = - "The connected Core reports this execution profile as unsupported. This Web keeps the Session read-only and will not submit execution writes."; - -export class ExecutionCompatibilityError extends Error { - readonly compatibility: "unknown" | "unsupported"; - - constructor( - compatibility: "unknown" | "unsupported", - message: string, - ) { - super(message); - this.name = "ExecutionCompatibilityError"; - this.compatibility = compatibility; - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function scopeMatches(value: unknown, scope: ExecutionWriteScope): value is ExecutionCompatibility & { state: "supported" } { - if (!isRecord(value) || value.state !== "supported" || !isRecord(value.proof)) return false; - const proof = value.proof; - return ( - (value as Record)[verifiedExecutionCompatibility] === true && - typeof proof.contractVersion === "string" && Boolean(proof.contractVersion.trim()) && - Number.isSafeInteger(proof.connectionGeneration) && proof.connectionGeneration === scope.connectionGeneration && - typeof proof.sessionId === "string" && proof.sessionId === scope.sessionId && Boolean(proof.sessionId) - ); -} - -export function normalizeExecutionCompatibility( - value: unknown, - scope: ExecutionWriteScope, -): ExecutionCompatibility { - if (scopeMatches(value, scope)) return value; - if (isRecord(value) && value.state === "unsupported") return { state: "unsupported" }; - return DEFAULT_EXECUTION_COMPATIBILITY; -} - -export function executionWriteBlocker(value: unknown, scope: ExecutionWriteScope): string | null { - const compatibility = normalizeExecutionCompatibility(value, scope); - if (compatibility.state === "supported") return null; - return compatibility.state === "unsupported" ? UNSUPPORTED_EXECUTION_COPY : UNKNOWN_EXECUTION_COPY; -} - -export async function guardedExecutionWrite( - compatibility: unknown, - scope: ExecutionWriteScope, - write: () => Promise, -): Promise { - const normalized = normalizeExecutionCompatibility(compatibility, scope); - if (normalized.state === "supported") return write(); - const blocker = executionWriteBlocker(normalized, scope) ?? UNKNOWN_EXECUTION_COPY; - throw new ExecutionCompatibilityError(normalized.state, blocker); -} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index df5e1d4..f59bbdb 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -3821,8 +3821,7 @@ button.trace-step-row:hover { box-shadow: var(--shadow-floating); } -.create-menu-panel > button, -.create-menu-unavailable { +.create-menu-panel > button { display: grid; grid-template-columns: 18px minmax(0, 1fr); min-height: 54px; @@ -3840,8 +3839,7 @@ button.trace-step-row:hover { background: var(--hover); } -.create-menu-panel > button:disabled, -.create-menu-unavailable { +.create-menu-panel > button:disabled { color: var(--fg-muted); opacity: 0.72; } @@ -4169,235 +4167,6 @@ button.trace-step-row:hover { line-height: 17px; } -/* Session-derived Environments overview. */ - -.environments-page { - display: flex; - min-height: 0; - flex-direction: column; - overflow: hidden; -} - -.environments-header > div:first-child { - min-width: 0; -} - -.environments-header h1 span { - color: var(--fg-muted); - font-size: 12px; - font-weight: 400; -} - -.environments-header p { - margin: 2px 0 0; - overflow: hidden; - color: var(--fg-muted); - font-size: 11px; - line-height: 15px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.environments-header .search-control { - width: min(340px, 40vw); - margin-left: auto; -} - -.environments-scroll { - min-height: 0; - padding: 22px 24px 48px; - overflow-y: auto; -} - -.environment-catalog-boundary { - display: flex; - max-width: 960px; - padding: 12px 14px; - gap: 10px; - color: var(--fg); - background: color-mix(in srgb, var(--warning) 7%, var(--surface)); - border: 1px solid color-mix(in srgb, var(--warning) 25%, var(--line)); - border-radius: 8px; -} - -.environment-catalog-boundary > svg { - flex: 0 0 auto; - margin-top: 1px; - color: var(--warning); -} - -.environment-catalog-boundary strong { - font-size: 13px; - font-weight: 500; -} - -.environment-catalog-boundary p { - margin: 2px 0 0; - color: var(--fg-muted); - font-size: 12px; - line-height: 17px; -} - -.environment-unavailable-grid { - display: grid; - max-width: 960px; - margin-top: 14px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.environment-unavailable-grid article { - display: grid; - grid-template-columns: 20px minmax(0, 1fr); - padding: 13px; - gap: 4px 9px; - background: var(--surface-subtle); - border: 1px solid var(--line); - border-radius: 8px; -} - -.environment-unavailable-grid article > svg { - margin-top: 1px; - color: var(--fg-muted); -} - -.environment-unavailable-grid article > div { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; -} - -.environment-unavailable-grid strong { - font-size: 13px; - font-weight: 500; -} - -.environment-unavailable-grid span { - color: var(--fg-muted); - font-size: 10px; - text-transform: uppercase; -} - -.environment-unavailable-grid p { - grid-column: 2; - margin: 0; - color: var(--fg-muted); - font-size: 11px; - line-height: 16px; -} - -.observed-environments { - max-width: 960px; - margin-top: 26px; -} - -.observed-environments > header { - display: flex; - align-items: flex-end; - justify-content: space-between; - margin-bottom: 9px; - gap: 12px; -} - -.observed-environments h2 { - margin: 0; - font-size: 15px; - font-weight: 600; -} - -.observed-environments header p { - margin: 2px 0 0; - color: var(--fg-muted); - font-size: 11px; - line-height: 16px; -} - -.observed-environments > header > span { - color: var(--fg-muted); - font-family: var(--font-mono); - font-size: 12px; -} - -.observed-environment-list { - display: grid; - gap: 10px; -} - -.observed-environment { - overflow: hidden; - border: 1px solid var(--line); - border-radius: 8px; -} - -.observed-environment > header { - display: flex; - min-height: 46px; - align-items: center; - justify-content: space-between; - padding: 7px 10px 7px 13px; - gap: 12px; - background: var(--surface-subtle); - border-bottom: 1px solid var(--line); -} - -.observed-environment > header > div { - display: flex; - min-width: 0; - flex-direction: column; -} - -.observed-environment > header strong { - font-size: 13px; - font-weight: 500; -} - -.observed-environment > header span { - overflow: hidden; - color: var(--fg-muted); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.observed-environment > header code { - font-family: var(--font-mono); - font-size: 10px; -} - -.observed-environment > .environment-panel { - margin: 0; - border: 0; - border-radius: 0; -} - -.environment-overview-empty { - display: flex; - min-height: 180px; - align-items: center; - justify-content: center; - padding: 24px; - flex-direction: column; - color: var(--fg-muted); - text-align: center; - background: var(--surface-subtle); - border: 1px dashed var(--line-strong); - border-radius: 8px; -} - -.environment-overview-empty h3 { - margin: 9px 0 2px; - color: var(--fg); - font-size: 14px; - font-weight: 500; -} - -.environment-overview-empty p { - max-width: 560px; - margin: 0 0 12px; - font-size: 12px; -} - @media (max-width: 1000px) { .agent-setup-layout { grid-template-columns: minmax(340px, 1fr) minmax(360px, 1fr); @@ -4463,8 +4232,7 @@ button.trace-step-row:hover { border-top: 1px solid var(--line); } - .agent-form-grid, - .environment-unavailable-grid { + .agent-form-grid { grid-template-columns: 1fr; } @@ -4477,30 +4245,6 @@ button.trace-step-row:hover { margin-left: 0; } - .environments-header { - min-height: auto; - flex: 0 0 auto; - flex-wrap: wrap; - padding: 10px 12px; - } - - .environments-header .search-control { - width: 100%; - margin-left: 0; - } - - .environments-scroll { - padding: 14px 12px 32px; - } - - .observed-environment > header { - align-items: flex-start; - flex-direction: column; - } - - .observed-environment > header .button { - width: 100%; - } } @keyframes overlay-in { diff --git a/docs/architecture.md b/docs/architecture.md index 91eb0d5..b7a3fa5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,8 +17,8 @@ This boundary is intentional: - Core fixes and runtime-provider work are contributed upstream instead of copied or simulated in Web. -The current compatibility baseline is Parsar -[`0438880a`](https://github.com/MiniMax-AI-Dev/parsar/commit/0438880ab21aa16d05cb91a4c7f91cc0abc12358), +The current compatibility audit baseline is Parsar +[`d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/commit/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee), whose contract is pinned to `openai-python` 3.13.0 commit [`d7c41efe`](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents). This is a fixed beta subset, not a claim that every current OpenAI Agents API @@ -142,17 +142,19 @@ Session and Environment identity, request/event revisions, stream epoch, selecti and abort checks reject late state. The TypeScript client exposes Turn list/retrieve for diagnostics, but the current UI does not use them in recovery. -The client never retries an uncertain write automatically. The current UI does not -persist one generated idempotency key across a manual resend, so it must not imply -that pressing Send again is a safe retry. Durable/live terminal precedence and -pending-operation key persistence remain M1 hardening work. +The client never retries an uncertain write automatically. It keeps a failed +message payload and idempotency key in memory only for an explicit byte-for-byte +unchanged manual resend. Editing the payload, changing Session/Core, receiving a +successful exact HTTP 204, or receiving a permanent rejection creates a new +operation. Any other 2xx fails closed and remains uncertain because the documented +Session events contract admits writes only with 204. ## Runtime profiles -The Web currently creates `environment: {"type":"none"}` Sessions. On Parsar -`0438880a`, Core also contains a narrow Codex `self_hosted` profile for empty Sessions -followed by constrained idle text input. The Web does not create or connect that -profile, but for an already selected self-hosted Session it reads the durable +The Web currently creates `environment: {"type":"none"}` Sessions. Parsar +`d91ba48a` also exposes Session-bound `self_hosted` data and documented event inputs. +The Web does not create or connect that profile, but for an already selected +self-hosted Session it reads the durable Environment's exact safe projection and status. Durable `expired` and live-only `ready` remain separate states; empty installation arrays do not describe a host or Workspace. This profile is not equivalent to the internal daemon socket, Docker, @@ -193,8 +195,10 @@ workaround. The development proxy is loopback-only convenience, not a production security boundary. Production must terminate TLS, authenticate Web users, authorize requests, -and hold the Core bearer in a reverse proxy/BFF. See -[Connecting Agent Core](core-connection.md) for the local setup and credential flow. +and hold the Core bearer in a reverse proxy/BFF. Use the immutable +[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md#standalone-http-service) +for Core lifecycle and treat [Connecting Agent Core](core-connection.md) as a legacy +Web runbook pinned to the older revision stated at its top. ## Sources @@ -202,6 +206,6 @@ and hold the Core bearer in a reverse proxy/BFF. See - [Official OpenAI Agents API overview](https://developers.openai.com/api/docs/guides/agents-api/overview) - [Official OpenAI Session lifecycle](https://developers.openai.com/api/docs/guides/agents-api/sessions) - [Pinned `openai-python` Agents resources](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents) -- [Parsar Agents API contract at `0438880a`](https://github.com/MiniMax-AI-Dev/parsar/blob/0438880ab21aa16d05cb91a4c7f91cc0abc12358/contracts/agents-api/README.md) -- [Parsar Environment contract at `0438880a`](https://github.com/MiniMax-AI-Dev/parsar/blob/0438880ab21aa16d05cb91a4c7f91cc0abc12358/contracts/agents-api/environments.md) -- [Parsar standalone service guide at `0438880a`](https://github.com/MiniMax-AI-Dev/parsar/blob/0438880ab21aa16d05cb91a4c7f91cc0abc12358/services/agents-api/README.md) +- [Parsar Agents API contract at `d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/contracts/agents-api/README.md) +- [Parsar Environment contract at `d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/contracts/agents-api/environments.md) +- [Parsar standalone service guide at `d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/blob/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee/services/agents-api/README.md) diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 3d03b29..22305b3 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -5,32 +5,25 @@ OpenAI-hosted service compatibility. ## Compatibility baseline -- Parsar Core: - [`0438880a`](https://github.com/MiniMax-AI-Dev/parsar/commit/0438880ab21aa16d05cb91a4c7f91cc0abc12358) -- The product-navigation, Agent execution-admission, and Environment-unavailable boundaries were re-audited - against Parsar [`c15d42a2`](https://github.com/MiniMax-AI-Dev/parsar/commit/c15d42a270c0667bcaa83a6b9d01a9892ebf7edd). - That revision still exposes only Environment retrieve plus Session-bound - `self_hosted` creation; it adds no public Environment list, template, file, or - browser-facing key management route. -- The execution-compatibility boundary was re-audited against Parsar +- Current Parsar Core compatibility audit: [`d91ba48a`](https://github.com/MiniMax-AI-Dev/parsar/commit/d91ba48ac6c49cfdf6f08d7687b9be76ba6d53ee). - Its public Agents API exposes no versioned execution-readiness resource or - Core/daemon/native-harness fingerprint. The default sandbox still installs - Codex `0.141.0` while the pinned native Environment reference is `0.153.4`, - and `environment:none` preparation still calls `environment/status`. Web - therefore treats current execution compatibility as unknown; it does not - sniff those versions or use a failed Turn as discovery. + This immutable revision re-confirms the Web-used Agent admission, chat, and + Environment boundaries. `OpenAI-Beta: agents=v1` plus the `/v1/agents/**` + resources and Session events endpoint are the versioned Web/Core contract. Core + exposes no additional public execution-readiness, capability, or build-version + resource; Web does not require one before using the documented chat events. It + exposes Environment retrieve plus Session-bound `self_hosted` data, but no public + Environment list, template, file-management, or browser-facing key route. - Upstream resource source: `openai-python` 3.13.0 beta Agents resources at [`d7c41efe`](https://github.com/openai/openai-python/tree/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents) - Required beta header: `OpenAI-Beta: agents=v1` - Core base: same-origin `/v1` through the Web proxy for stock Parsar Core; a direct URL only for a compatible Core or proxy with explicit CORS support -Parsar's pinned inventory contains 42 upstream operations in 15 resource classes; -the referenced Core revision has handlers for 20 operations, and those handlers -still implement partial request/event semantics. Importing an official SDK or -accepting extra fields is not compatibility proof. Unsupported capabilities must -fail explicitly. +Parsar implements a partial subset of the pinned upstream resource inventory, and +its handlers still implement partial request/event semantics. Importing an official +SDK or accepting extra fields is not compatibility proof. Unsupported capabilities +must fail explicitly. Requests use `Authorization: Bearer `. Optional `OpenAI-Organization` and `OpenAI-Project` headers, when present, must exactly match @@ -45,21 +38,21 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. | Sessions create/list/retrieve | Yes | Yes | UI creates idle `environment:none` Sessions; client types also cover the pinned `self_hosted` request and safe response projection | | Sessions update/delete | Yes | Yes | Title/string metadata editing and one-Session confirmed deletion; no bulk or Workspace deletion | | Session live events | Yes | Yes | Authenticated `fetch` stream, not `EventSource` | -| Input message / steering | Yes | Read-only until proven | Unknown or unsupported execution compatibility blocks the control and final App write boundary; no input event is submitted | +| Input message / steering | Yes | Yes | Opens SSE before submitting `agent.session.input.message`; only HTTP 204 is durable admission, while Core errors or an unexpected 2xx remain visible and uncertain failures retain the in-memory payload/key for an explicit unchanged manual retry only | | Active Turn cancel | Yes | Yes | Submitted as a Session event, not a Turn-create endpoint | | Turn list | Yes | Yes, read-only | Selected Sessions load every page in ascending creation order; no Turn mutation UI | | Turn retrieve | Yes | No | Reusable client diagnostic method; timeline recovery uses the all-pages list | | Item list/recovery | Yes | Yes | Authoritative recovery after stream loss | | Parsar `apply_patch` Item presentation | Existing function Item fields | Yes, read-only | Parsar extension recognized only for the pinned `changes[].{path,kind,diff}` shape; not an OpenAI standard Item type | -| Function result/error | Yes | Read-only until proven | The form remains inspectable for `function_call` actions, but unknown or unsupported compatibility disables result/error submission | +| Function result/error | Yes | Yes | Supports text `agent.session.input.tool_result` success/error handoff for exact `function_call` actions | | Initial-input creation stream | Later | No | Idle-create flow avoids the early-event race | | Artifacts/files | Later | No | Required Core resources are not implemented | | Environment connection action | Yes | Render-only | `environment_connection` is distinct from a function call; Web shows an operator-owned, non-actionable state and sends no result | | Environment lifecycle events | Yes | Read-only | UI projects pinned pending, ready, connected, disconnected, and failed live snapshots; unknown/malformed status events clear prior live claims and render as unavailable | | Environment retrieve | Yes | Yes, read-only | For a valid `self_hosted` Session Environment ID, reads the exact public resource fields and durable status; no create/list/update/delete support | -| Environment overview | Session-derived only | Yes, read-only | Shows only `self_hosted` projections in currently loaded Sessions and their already-observed matching status; explicitly not a Core Environment catalog | -| Environment templates | No | Explicitly unavailable | The navigation/Create surface does not simulate template reads or writes | -| Environment keys | No public browser API | Explicitly unavailable | Operator-issued executor credentials never enter browser state or request previews | +| Environment overview | No public list API | No top-level UI | Web does not turn loaded Session projections into a catalog; a selected Session may still show its exact Environment data | +| Environment templates | No | Hidden | No navigation or Create entry is shown without a Core contract | +| Environment keys | No public browser API | Hidden | Operator-issued executor credentials never enter browser state, request previews, navigation, or Create actions | | Vaults | Later | No | Credentials must never be stored in browser metadata | | Protocol Subagents / enabled multi-agent | Later | No | Distinct from storing multiple Agent configurations | | Usage/observability | Response types | Yes, scoped | Session aggregate and per-Turn token Usage are labelled separately; unavailable measurements remain unknown, not zero | @@ -67,33 +60,22 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. ## Runtime boundary - The Web currently creates only `environment: {"type":"none"}` Sessions. -- Execution compatibility has one Web-owned normalized state: - `supported`, `unsupported`, or `unknown`, defaulting to `unknown`. The current - Core surface cannot promote it to `supported`; the supported variant is sealed - inside the future contract adapter, so structurally similar untrusted data stays - `unknown`. While it is `unknown` or - `unsupported`, message, active-Turn steering, and function-result/error writes - are blocked both in Session controls and at the final App operation boundary. - Agent CRUD, idle Session management, Session/Item/Turn/Usage/Environment/SSE - reads, recovery, metadata/deletion flows, and cancellation of already-active - work remain available. -- A future `supported` state requires an authenticated, public, versioned, - tenant- and Session-scoped execution profile. Its proof must bind the exact - engine and Environment mode, derive readiness from native method probing, and - include freshness/scope semantics. The normalized Web proof is additionally - bound to the current connection generation and exact Session ID before every - write. Missing, malformed, stale, wrong-connection, wrong-Session, or unknown - data remains `unknown`. A revision string or private daemon heartbeat is - insufficient. +- Message, active-Turn steering, cancel, and function-result/error writes use the + current `agents=v1` Session events contract. Web does not add a separate private + runtime-readiness gate. Only exact HTTP 204 denotes durable event admission; + every other status, including another 2xx, fails closed. HTTP acceptance, health, + Agent creation, and an open SSE stream still do not prove that execution will + complete; subsequent durable Session, Turn, and Item state is authoritative. - Product navigation and the global Create menu do not widen the protocol. Agent - and idle Session creation call the existing client methods. Environment template - and Environment key entries are non-actionable unavailable states. + and idle Session creation call the existing client methods. The top-level + Environments destination and Environment template/key entries are absent because + Core exposes no corresponding list or management APIs. - The Agent setup request preview is derived entirely from editable Agent fields and the sanitized Core base URL. Its authorization header always contains the literal `${AGENTS_CORE_API_KEY}` placeholder; it never reads or renders the connection's server-managed or current-tab bearer. - Saved Agent persistence and Session execution are separate contracts. Parsar - `c15d42a2` can store explicit reasoning, non-`auto` service tiers, and JSON-schema + `d91ba48a` can store explicit reasoning, non-`auto` service tiers, and JSON-schema text formats, but rejects each of them before creating a Session. Enabled multi-agent configuration, saved-only tool types, deferred/invalid/duplicate function or MCP identities, and MCP credentials without attached Vaults are @@ -106,13 +88,9 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. - Saved Agent names are limited to 128 Unicode characters. Agent metadata is limited to 16 string pairs, 64 Unicode characters per key, and 512 per value; the Web enforces those limits before a write. -- The Environments overview filters the currently loaded Session collection to exact - `self_hosted` projections. A matching durable/live observation may annotate that - row, but an absent row or observation remains unknown. The view does not derive a - list from database state, Environment IDs, executor registrations, or operator keys. -- Parsar Core at the pinned revision has a narrow `self_hosted` profile for empty - Session creation followed by constrained idle text input. The Web does not create - that profile, but it safely renders selected Sessions that already carry one. This +- Parsar Core at the audited revision supports Session-bound `self_hosted` data and + its documented event inputs. This Web does not create or connect that profile; it + safely renders selected Sessions that already carry one. That read-only projection does not expand the missing Environment create/list/update/delete, template, or file-operation surface. - The reusable client distinguishes the admitted `self_hosted` request fields @@ -185,10 +163,10 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. - Core has no standard model-catalog or capability-discovery route in this surface. Web model presets are editable suggestions. Known reasoning, tier, format, multi-agent, executable-tool-shape, and unattached-credential incompatibilities - are authoritative at Session creation. A prior successful or failed Turn is - historical observation, not a versioned readiness contract, and cannot promote - the Web state to `supported`; this Web does not send a paid Turn as a capability - probe. Claude SDK accepts medium verbosity only. + are authoritative at Session creation; a real Turn remains necessary to prove + model/provider execution and conditional Codex `low`/`high` verbosity support. + Web never sends a paid Turn merely as a capability probe. Claude SDK accepts + medium verbosity only. Environment creation and management beyond the narrow read, provider selection, Files, Plugins, Skills, Artifacts, Vault, hosted runtimes, and Workspace lifecycle @@ -324,10 +302,10 @@ read keeps the existing conversation usable. observability. They are not per-Item timing, monetary cost, provider attribution, or a complete OpenAI Trace waterfall. -The client never retries a write automatically. If a future proven compatibility -contract enables input, a message that fails with a network/response-loss error, -HTTP 5xx, or transient 408/409/425/429 keeps the original payload and idempotency key -in memory. Only a later user-initiated Send of the byte-for-byte unchanged payload +The client never retries a write automatically. For an input message that receives +an unexpected non-204 2xx, loses its network/response, or fails with HTTP 5xx or a +transient 408/409/425/429, the Web keeps the original payload and idempotency key in +memory. Only a later user-initiated Send of the byte-for-byte unchanged payload reuses that key. Editing the payload, changing Session/Core, a successful response, or a permanent 4xx starts a new operation with a new key. This state is intentionally not stored in browser persistence, and the UI cannot prove whether an uncertain @@ -335,7 +313,7 @@ request was accepted until durable Core state reconciles. HTTP acceptance, `/healthz`, an open SSE connection, and successful Agent creation do not prove that a daemon, native harness, model ID, or provider credential can -complete a Turn and never upgrade execution compatibility. +complete a Turn. ## Terminology diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index 7b19dc3..c0aaecd 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -436,6 +436,38 @@ describe("OpenAIAgentsClient", () => { }); }); + it.each([ + ["message", (client: OpenAIAgentsClient) => client.sendMessage("session", "hello", "event-key")], + ["cancel", (client: OpenAIAgentsClient) => client.cancelTurn("session", "event-key")], + ["tool result", (client: OpenAIAgentsClient) => client.submitFunctionResult("session", { + callId: "call_1", + turnId: "turn_1", + success: false, + error: "safe failure", + }, "event-key")], + ])("requires HTTP 204 for %s event submission without retrying", async (_label, submit) => { + const successCalls: FetchCall[] = []; + const successClient = new OpenAIAgentsClient({ + fetch: recordingFetch(new Response(null, { status: 204 }), successCalls), + }); + + await expect(submit(successClient)).resolves.toBeUndefined(); + expect(successCalls).toHaveLength(1); + + for (const status of [200, 202]) { + const calls: FetchCall[] = []; + const client = new OpenAIAgentsClient({ + fetch: recordingFetch(jsonResponse({ accepted: true }, status), calls), + }); + + await expect(submit(client)).rejects.toMatchObject({ + status, + message: `Agent core request failed (${status}).`, + }); + expect(calls).toHaveLength(1); + } + }); + it("rejects stream=true before the JSON create method performs a request", async () => { const calls: FetchCall[] = []; const client = new OpenAIAgentsClient({ diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index 42433e6..bb4087c 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -264,11 +264,15 @@ export class OpenAIAgentsClient implements AgentCore { } private submitEvents(sessionId: string, events: unknown[], idempotencyKey = randomKey()): Promise { - return this.request(`/agents/sessions/${encodeURIComponent(sessionId)}/events`, { - method: "POST", - headers: { "Idempotency-Key": idempotencyKey }, - body: JSON.stringify({ events }), - }); + return this.request( + `/agents/sessions/${encodeURIComponent(sessionId)}/events`, + { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + body: JSON.stringify({ events }), + }, + 204, + ); } sendMessage(sessionId: string, text: string, idempotencyKey?: string): Promise {