From cb541c2100e1232321ce285c2416a4978712bee5 Mon Sep 17 00:00:00 2001 From: sam2tom Date: Tue, 15 Sep 2026 23:28:17 +0800 Subject: [PATCH 1/7] feat: hydrate durable Environment status --- apps/web/e2e/agents-lifecycle.spec.ts | 98 ++++++++++- apps/web/e2e/fixture-core.mjs | 31 ++++ apps/web/src/App.tsx | 152 ++++++++++++++++-- .../environment/EnvironmentPanel.test.tsx | 65 +++++++- .../sessions/environment/EnvironmentPanel.tsx | 42 +++-- .../environment/environment-state.test.ts | 88 +++++++++- .../sessions/environment/environment-state.ts | 121 +++++++++++--- apps/web/src/style.css | 16 ++ docs/architecture.md | 39 +++-- docs/core-connection.md | 35 +++- docs/protocol-coverage.md | 83 ++++++---- packages/agents-client/src/client.test.ts | 99 +++++++++++- packages/agents-client/src/client.ts | 49 +++++- .../environment-resources.json | 17 ++ .../agents-client/src/protocol-types.test.ts | 37 ++++- packages/agents-client/src/types.ts | 21 ++- 16 files changed, 871 insertions(+), 122 deletions(-) create mode 100644 packages/agents-client/src/fixtures/parsar-0438880a/environment-resources.json diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 4f12dec..b1333be 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -17,7 +17,7 @@ async function resetFixture(request: APIRequestContext) { expect(response.ok()).toBe(true); } -async function controlFixture(request: APIRequestContext, control: Record) { +async function controlFixture(request: APIRequestContext, control: Record) { const response = await request.post(`${fixtureBaseUrl}/__fixture/control`, { data: control }); expect(response.ok()).toBe(true); } @@ -340,8 +340,8 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect.poll(async () => ( await fixtureRequests(request) ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/events")).length).toBeGreaterThanOrEqual(2); - await expect(panel).toContainText("Connection required"); - await expect(panel).not.toContainText("Pending"); + await expect(panel).toContainText("Pending"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); await panel.evaluate((element) => element.scrollIntoView({ block: "start" })); await attachElementScreenshot(panel, testInfo, "desktop-light-environment-panel"); await attachScreenshot(page, testInfo, "desktop-light-self-hosted-environment"); @@ -384,6 +384,98 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect(missing).toContainText("unsafe or malformed URL"); }); +test("hydrates durable expired and unavailable Environment states without a write or paid Turn", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentResourceStatus: "expired", + environmentEventStatus: 0, + }); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + await expect(panel).toContainText("Expired"); + await expect(panel).toContainText("Environment expired"); + await expect(panel).toContainText("no API-managed files, plugins, or skills"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + const initialRequests = await fixtureRequests(request); + const sessionReadIndex = initialRequests.findIndex( + (entry) => entry.method === "GET" && /^\/v1\/agents\/sessions\/[^/]+$/.test(entry.path), + ); + const environmentReadIndex = initialRequests.findIndex( + (entry) => entry.method === "GET" && entry.path.startsWith("/v1/agents/environments/"), + ); + expect(sessionReadIndex).toBeGreaterThanOrEqual(0); + expect(environmentReadIndex).toBeGreaterThan(sessionReadIndex); + + let environmentRequests = initialRequests.filter( + (entry) => entry.path.startsWith("/v1/agents/environments/"), + ); + expect(environmentRequests.length).toBeGreaterThanOrEqual(1); + expect(environmentRequests.every((entry) => entry.method === "GET" && entry.body === undefined)).toBe(true); + + await controlFixture(request, { environmentRetrieveStatus: 503 }); + await page.reload(); + await expect(panel).toContainText("Unavailable"); + await expect(panel).toContainText("conversation remains usable"); + await expect(panel).not.toContainText("Expired"); + await expect(panel).not.toContainText("Connected"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + await controlFixture(request, { + environmentRetrieveStatus: 200, + environmentResourceVariant: "missing_skills", + }); + await page.reload(); + await expect(panel).toContainText("Unavailable"); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + environmentRequests = (await fixtureRequests(request)).filter( + (entry) => entry.path.startsWith("/v1/agents/environments/"), + ); + expect(environmentRequests.every((entry) => entry.method === "GET")).toBe(true); +}); + +test("hydrates durable Environment state even when the live stream is rejected", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentResourceStatus: "expired", + streamStatus: 401, + }); + await page.goto("/"); + + const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + await expect(panel).toContainText("Expired"); + await expect(panel).toContainText("Status comes from the durable Environment resource"); + await expect(page.getByText("failed", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + const requests = await fixtureRequests(request); + expect(requests.some((entry) => ( + entry.method === "GET" && entry.path.startsWith("/v1/agents/environments/") + ))).toBe(true); +}); + +test("applies a buffered live Environment event after an earlier durable snapshot", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + environmentScenario: 4, + environmentRetrieveDelayMs: 500, + environmentResourceStatus: "pending", + environmentEventStatus: 3, + environmentEventCount: 1, + }); + await page.goto("/"); + + const panel = page.getByRole("region", { name: "Environment and Workspace status" }); + await expect(panel).toContainText("Connected"); + await expect(panel).toContainText("last supported live event observed after the durable Environment snapshot"); + await expect(panel).not.toContainText("Pending"); +}); + test("renders Parsar patches as accessible read-only diffs in desktop and narrow themes", async ({ page, request }, testInfo) => { await resetFixture(request); await controlFixture(request, { itemsScenario: 1 }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index df7b785..f0d8bf9 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -77,8 +77,13 @@ function initialState() { sendResponseLoss: 0, itemsScenario: 0, environmentScenario: 0, + environmentRetrieveDelayMs: 0, + environmentRetrieveStatus: 200, + environmentResourceStatus: "pending", + environmentResourceVariant: "valid", environmentEventStatus: 0, environmentEventCount: 0, + streamStatus: 200, streamCloseCount: 0, streamCloseDelayMs: 30, }, @@ -285,6 +290,29 @@ const server = http.createServer(async (request, response) => { return session ? sendJson(response, session) : sendError(response, 404, "Fixture Session not found."); } + const environmentMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)$/); + if (request.method === "GET" && environmentMatch) { + if (state.controls.environmentRetrieveDelayMs) await wait(state.controls.environmentRetrieveDelayMs); + if (state.controls.environmentRetrieveStatus !== 200) { + return sendError(response, state.controls.environmentRetrieveStatus, "Fixture Environment retrieve failed."); + } + const id = decodeURIComponent(environmentMatch[1]); + if (id !== "environment_fixture") return sendError(response, 404, "Fixture Environment not found."); + const resource = { + id, + object: "agent.environment", + type: "self_hosted", + status: state.controls.environmentResourceStatus, + files: [], + plugins: [], + skills: [], + }; + if (state.controls.environmentResourceVariant === "missing_skills") delete resource.skills; + if (state.controls.environmentResourceVariant === "wrong_id") resource.id = "another_environment"; + if (state.controls.environmentResourceVariant === "extra_field") resource.extra = true; + return sendJson(response, resource); + } + const itemsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/items$/); if (request.method === "GET" && itemsMatch) return sendJson(response, page(state.controls.itemsScenario ? patchItems() : [])); @@ -304,6 +332,9 @@ const server = http.createServer(async (request, response) => { return; } if (request.method === "GET" && eventsMatch) { + if (state.controls.streamStatus !== 200) { + return sendError(response, state.controls.streamStatus, "Fixture stream rejected."); + } response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5f542b8..07cd74d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -36,10 +36,14 @@ import { type StreamState, } from "./features/sessions/SessionsView"; import { + environmentObservationFromResource, + environmentReadIsCurrent, + matchingSessionSnapshot, reduceEnvironmentObservation, - reconcileEnvironmentObservation, + selfHostedEnvironmentId, type EnvironmentObservation, type ScopedEnvironmentObservation, + unavailableEnvironmentObservation, visibleEnvironmentObservation, } from "./features/sessions/environment/environment-state"; import { SystemView } from "./features/system/SystemView"; @@ -190,6 +194,8 @@ export function App() { const sessionEventRevisionRef = useRef(new Map()); const itemEventRevisionRef = useRef(new Map()); const environmentEventRevisionRef = useRef(new Map()); + const environmentRequestRef = useRef(new Map()); + const sessionEnvironmentIdRef = useRef(new Map()); const operationRequestRef = useRef(0); const streamEpochRef = useRef(0); selectedIdRef.current = selectedId; @@ -266,6 +272,37 @@ export function App() { ) return false; if (result.status === "fulfilled") { if (sessionRevision === sessionCollectionRevisionRef.current) { + const nextEnvironmentIds = new Map( + result.value.data.map((session) => [session.id, selfHostedEnvironmentId(session.environment)]), + ); + const changedEnvironmentSessions = new Set(); + for (const [sessionId, environmentId] of nextEnvironmentIds) { + if (sessionEnvironmentIdRef.current.get(sessionId) !== environmentId) { + changedEnvironmentSessions.add(sessionId); + } + } + for (const sessionId of sessionEnvironmentIdRef.current.keys()) { + if (!nextEnvironmentIds.has(sessionId)) changedEnvironmentSessions.add(sessionId); + } + for (const sessionId of changedEnvironmentSessions) { + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + } + sessionEnvironmentIdRef.current = nextEnvironmentIds; + if (changedEnvironmentSessions.size) { + setEnvironmentObservations((current) => { + if (![...changedEnvironmentSessions].some((sessionId) => current.has(sessionId))) return current; + const next = new Map(current); + for (const sessionId of changedEnvironmentSessions) next.delete(sessionId); + return next; + }); + } setSessions(result.value.data); setSelectedId((current) => { if (current && result.value.data.some((session) => session.id === current)) return current; @@ -299,7 +336,11 @@ export function App() { coreGeneration !== connectionGenerationRef.current || request !== sessionRequestRef.current.get(sessionId) ) return false; - if (sessionRevision === (sessionEventRevisionRef.current.get(sessionId) ?? 0)) { + const currentSessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; + const sessionIsCurrent = sessionRevision === currentSessionRevision; + const environmentId = selfHostedEnvironmentId(session.environment); + if (sessionIsCurrent) { + sessionEnvironmentIdRef.current.set(sessionId, environmentId); sessionCollectionRevisionRef.current += 1; setSessions((current) => { const found = current.some((value) => value.id === session.id); @@ -308,17 +349,6 @@ export function App() { : [session, ...current]; }); } - if (environmentRevision === (environmentEventRevisionRef.current.get(sessionId) ?? 0)) { - setEnvironmentObservations((current) => { - const existing = current.get(sessionId); - const reconciled = reconcileEnvironmentObservation(existing?.observation ?? null, session); - if (reconciled === existing?.observation) return current; - const next = new Map(current); - if (reconciled && existing) next.set(sessionId, { ...existing, observation: reconciled }); - else next.delete(sessionId); - return next; - }); - } if (selectedIdRef.current === sessionId) { const liveRevisionChanged = itemRevision !== (itemEventRevisionRef.current.get(sessionId) ?? 0); const currentItemsSessionId = itemsSessionIdRef.current; @@ -336,6 +366,59 @@ export function App() { if (selectedIdRef.current === sessionId) { setSelectedSessionLoad({ sessionId, state: "ready", error: null }); } + + if (!sessionIsCurrent) return true; + if (!environmentId) { + if (environmentRevision === (environmentEventRevisionRef.current.get(sessionId) ?? 0)) { + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + } + return true; + } + if (signal?.aborted || selectedIdRef.current !== sessionId) return false; + + const environmentStreamEpoch = streamEpochRef.current; + const environmentRequest = (environmentRequestRef.current.get(sessionId) ?? 0) + 1; + environmentRequestRef.current.set(sessionId, environmentRequest); + const environmentRead = { + coreGeneration, + sessionId, + environmentId, + sessionRequest: request, + environmentRequest, + streamEpoch: environmentStreamEpoch, + sessionRevision, + environmentRevision, + }; + let observation: EnvironmentObservation; + try { + const resource = await core.retrieveEnvironment(environmentId, { signal }); + observation = environmentObservationFromResource(resource, environmentId) + ?? unavailableEnvironmentObservation(environmentId); + } catch (error) { + if (isAbort(error)) return false; + observation = unavailableEnvironmentObservation(environmentId); + } + if (signal?.aborted || !environmentReadIsCurrent(environmentRead, { + coreGeneration: connectionGenerationRef.current, + sessionId, + environmentId: sessionEnvironmentIdRef.current.get(sessionId) ?? "", + sessionRequest: sessionRequestRef.current.get(sessionId) ?? 0, + environmentRequest: environmentRequestRef.current.get(sessionId) ?? 0, + streamEpoch: streamEpochRef.current, + sessionRevision: sessionEventRevisionRef.current.get(sessionId) ?? 0, + environmentRevision: environmentEventRevisionRef.current.get(sessionId) ?? 0, + selectedSessionId: selectedIdRef.current, + })) return false; + setEnvironmentObservations((current) => { + const next = new Map(current); + next.set(sessionId, { observation, sessionId, streamEpoch: environmentStreamEpoch }); + return next; + }); return true; } catch (error) { if ( @@ -394,6 +477,10 @@ export function App() { selectedId, (environmentEventRevisionRef.current.get(selectedId) ?? 0) + 1, ); + environmentRequestRef.current.set( + selectedId, + (environmentRequestRef.current.get(selectedId) ?? 0) + 1, + ); setEnvironmentObservations((current) => { if (!current.has(selectedId)) return current; const next = new Map(current); @@ -436,6 +523,28 @@ export function App() { if (!isCurrentStream()) return; if (typeof event.session_id === "string" && event.session_id && event.session_id !== sessionId) return; const eventType = typeof event.type === "string" ? event.type : ""; + const eventSession = matchingSessionSnapshot(event, sessionId); + if (eventSession) { + const nextEnvironmentId = selfHostedEnvironmentId(eventSession.environment); + const previousEnvironmentId = sessionEnvironmentIdRef.current.get(sessionId); + if (previousEnvironmentId !== nextEnvironmentId) { + sessionEnvironmentIdRef.current.set(sessionId, nextEnvironmentId); + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + } + } const isEnvironmentEvent = eventType.startsWith("agent.session.environment."); if (isEnvironmentEvent) { environmentEventRevisionRef.current.set( @@ -446,19 +555,24 @@ export function App() { const next = new Map(current); const existing = current.get(sessionId); const previous = existing?.streamEpoch === streamEpoch ? existing.observation : null; - const reduced = reduceEnvironmentObservation(previous, event, sessionId); + const reduced = reduceEnvironmentObservation( + previous, + event, + sessionId, + sessionEnvironmentIdRef.current.get(sessionId) ?? null, + ); if (reduced) next.set(sessionId, { observation: reduced, sessionId, streamEpoch }); else next.delete(sessionId); return next; }); } - if (event.session) { + if (eventSession) { sessionEventRevisionRef.current.set( sessionId, (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, ); sessionCollectionRevisionRef.current += 1; - setSessions((current) => current.map((session) => (session.id === event.session?.id ? event.session : session))); + setSessions((current) => current.map((session) => (session.id === eventSession.id ? eventSession : session))); } if (event.item || eventType.includes(".output_text.")) { itemEventRevisionRef.current.set( @@ -514,6 +628,10 @@ export function App() { sessionId, (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, ); + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); setEnvironmentObservations((current) => { if (!current.has(sessionId)) return current; const next = new Map(current); @@ -706,6 +824,8 @@ export function App() { sessionEventRevisionRef.current.clear(); itemEventRevisionRef.current.clear(); environmentEventRevisionRef.current.clear(); + environmentRequestRef.current.clear(); + sessionEnvironmentIdRef.current.clear(); operationRequestRef.current += 1; streamEpochRef.current += 1; saveConnection(normalized); diff --git a/apps/web/src/features/sessions/environment/EnvironmentPanel.test.tsx b/apps/web/src/features/sessions/environment/EnvironmentPanel.test.tsx index 2ac0a4e..43103df 100644 --- a/apps/web/src/features/sessions/environment/EnvironmentPanel.test.tsx +++ b/apps/web/src/features/sessions/environment/EnvironmentPanel.test.tsx @@ -1,10 +1,15 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { AgentEnvironment, EnvironmentStatus } from "@agents-core-web/agents-client"; +import type { + AgentEnvironment, + AgentEnvironmentResource, + EnvironmentResourceStatus, + SessionEnvironmentStatus, +} from "@agents-core-web/agents-client"; import { EnvironmentPanel, sanitizeRemoteUrl } from "./EnvironmentPanel"; -import type { EnvironmentObservation } from "./environment-state"; +import type { EnvironmentObservation, LiveEnvironmentObservation } from "./environment-state"; const selfHosted: AgentEnvironment = { type: "self_hosted", @@ -14,8 +19,9 @@ const selfHosted: AgentEnvironment = { capability_directories: ["/capabilities/one", `/capabilities/${"long/".repeat(80)}`], }; -function observation(status: EnvironmentStatus): EnvironmentObservation { +function observation(status: SessionEnvironmentStatus): LiveEnvironmentObservation { return { + source: "live", environmentId: "environment_01", environmentType: "self_hosted", status, @@ -28,6 +34,25 @@ function observation(status: EnvironmentStatus): EnvironmentObservation { }; } +function durableObservation(status: EnvironmentResourceStatus): EnvironmentObservation { + const resource: AgentEnvironmentResource = { + id: "environment_01", + object: "agent.environment", + type: "self_hosted", + status, + files: [], + plugins: [], + skills: [], + }; + return { + source: "durable", + environmentId: "environment_01", + environmentType: "self_hosted", + status, + resource, + }; +} + function render(environment: AgentEnvironment, live: EnvironmentObservation | null = null) { return renderToStaticMarkup( , @@ -64,16 +89,46 @@ describe("EnvironmentPanel", () => { expect(html).not.toContain("file://"); }); - it.each(["pending", "ready", "connected", "disconnected", "failed"] as EnvironmentStatus[])( + it.each(["pending", "ready", "connected", "disconnected", "failed"] as SessionEnvironmentStatus[])( "renders the pinned %s live observation without executor inference", (status) => { const html = render(selfHosted, observation(status)); expect(html).toContain(status.charAt(0).toUpperCase() + status.slice(1)); expect(html).toContain("last supported live event observed"); - expect(html).toContain("do not expose connection status"); + expect(html).toContain("does not prove executor"); }, ); + it.each(["pending", "connected", "disconnected", "expired", "failed"] as EnvironmentResourceStatus[])( + "renders durable %s after reload without treating inventory as host capability", + (status) => { + const html = render(selfHosted, durableObservation(status)); + expect(html).toContain(status.charAt(0).toUpperCase() + status.slice(1)); + expect(html).toContain("Status comes from the durable Environment resource"); + expect(html).toContain("no API-managed files, plugins, or skills"); + expect(html).toContain("not host or Workspace inventory"); + expect(html).toContain("does not prove executor"); + if (status === "expired") { + expect(html).toContain("Environment expired"); + expect(html).toContain("does not retry or recreate it"); + } + }, + ); + + it("renders a failed durable read as unavailable while leaving Workspace context visible", () => { + const html = render(selfHosted, { + source: "unavailable", + environmentId: "environment_01", + environmentType: "self_hosted", + status: null, + }); + expect(html).toContain("Unavailable"); + expect(html).toContain("conversation remains usable"); + expect(html).toContain("/workspace/<script>alert(1)</script>/project"); + expect(html).not.toContain("Connected"); + expect(html).not.toContain("Ready"); + }); + it("shows directories only as escaped text and redacts unsafe error content", () => { const html = render(selfHosted, observation("failed")); expect(html).toContain("/workspace/<script>alert(1)</script>/project"); diff --git a/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx b/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx index 0eff86f..15066ee 100644 --- a/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx +++ b/apps/web/src/features/sessions/environment/EnvironmentPanel.tsx @@ -3,14 +3,16 @@ import { ExternalLink, Folder, HardDrive, Server, TerminalSquare } from "lucide- import type { AgentEnvironment, EnvironmentConnectionAction, - EnvironmentStatus, + EnvironmentResourceStatus, + SessionEnvironmentStatus, } from "@agents-core-web/agents-client"; import { StatusIcon, type StatusKind } from "../../../components/StatusIcon"; import type { EnvironmentObservation } from "./environment-state"; -const coreSetupUrl = "https://github.com/MiniMax-AI-Dev/parsar/blob/8cc2898ca42b272cb3771234ee6a0ad0d2e932ba/services/agents-api/README.md#native-executor-transport-prerequisite"; -const launcherSetupUrl = "https://github.com/MiniMax-AI-Dev/parsar/blob/8cc2898ca42b272cb3771234ee6a0ad0d2e932ba/packages/codex-executor/README.md#connect-an-executor"; +const parsarBaseline = "0438880ab21aa16d05cb91a4c7f91cc0abc12358"; +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`; export interface SafeRemoteUrl { href: string; @@ -43,16 +45,19 @@ function directories(value: unknown): string[] | null { : null; } -function statusKind(status: EnvironmentStatus | "required" | "unknown"): StatusKind { +type EnvironmentDisplayStatus = SessionEnvironmentStatus | EnvironmentResourceStatus | "required" | "unknown" | "unavailable"; + +function statusKind(status: EnvironmentDisplayStatus): StatusKind { if (status === "connected" || status === "ready") return "completed"; if (status === "failed") return "failed"; if (status === "pending" || status === "required") return "running"; return "interrupted"; } -function statusLabel(status: EnvironmentStatus | "required" | "unknown"): string { +function statusLabel(status: EnvironmentDisplayStatus): string { if (status === "required") return "Connection required"; if (status === "unknown") return "Unknown"; + if (status === "unavailable") return "Unavailable"; return status.charAt(0).toUpperCase() + status.slice(1); } @@ -124,7 +129,9 @@ export function EnvironmentPanel({ const remoteUrl = sanitizeRemoteUrl(raw.remote_url); const live = matchingObservation(observation, environmentId); const requiresConnection = Boolean(environmentId && connectionActions.some((action) => action.environment_id === environmentId)); - const status: EnvironmentStatus | "required" | "unknown" = live?.status ?? (requiresConnection ? "required" : "unknown"); + const status: EnvironmentDisplayStatus = live?.source === "unavailable" + ? "unavailable" + : live?.status ?? (requiresConnection ? "required" : "unknown"); return (
@@ -162,11 +169,17 @@ export function EnvironmentPanel({

- {live - ? "Connection is the last supported live event observed; durable Session reads confirm identity and Workspace details but do not expose connection status." - : requiresConnection - ? "Core durably requires an operator connection. No executor availability is inferred." - : "Connection status is unknown because the durable Session projection does not expose it."} + {live?.source === "live" + ? "Connection is the last supported live event observed after the durable Environment snapshot. It does not prove executor, runtime, model, or provider readiness." + : live?.source === "durable" + ? live.resource.files.length === 0 && live.resource.plugins.length === 0 && live.resource.skills.length === 0 + ? "Status comes from the durable Environment resource. Core reports no API-managed files, plugins, or skills; this is not host or Workspace inventory and does not prove executor, runtime, model, or provider readiness." + : "Status comes from the durable Environment resource. API-managed installation metadata is present but is not rendered as host or Workspace inventory and does not prove executor, runtime, model, or provider readiness." + : live?.source === "unavailable" + ? "Durable Environment status is unavailable. The conversation remains usable, and no previous live readiness claim is retained." + : requiresConnection + ? "Core durably requires an operator connection. No executor availability is inferred." + : "Connection status is unknown because the durable Session projection does not expose it."}

{status === "failed" ? ( @@ -176,6 +189,13 @@ export function EnvironmentPanel({ ) : null} + {status === "expired" ? ( +
+ Environment expired +

The durable Environment resource expired. Reconnect or provision it through the Core operator; this Web does not retry or recreate it.

+
+ ) : null} +

Workspace is this Environment’s execution directory, not a top-level workspaces API. Paths and the executor URL are shown as text only.

); } diff --git a/apps/web/src/features/sessions/actions/SessionActionsDialog.test.tsx b/apps/web/src/features/sessions/actions/SessionActionsDialog.test.tsx new file mode 100644 index 0000000..dc495ec --- /dev/null +++ b/apps/web/src/features/sessions/actions/SessionActionsDialog.test.tsx @@ -0,0 +1,75 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { AgentSession } from "@agents-core-web/agents-client"; + +import { + SessionDeleteConfirmation, + SessionDetails, + SessionMetadataForm, +} from "./SessionActionsDialog"; + +const session: AgentSession = { + id: "session_1", + object: "agent.session", + agent: { + id: "agent_1", + model: "provider/model", + name: "Builder", + instructions: null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + }, + environment: { + type: "self_hosted", + id: "environment_1", + remote_url: "https://executor.example.test/connect", + workspace_directory: "/workspace/project", + capability_directories: [], + }, + status: "requires_action", + error: null, + metadata: { title: "Release review", team: "web" }, + required_actions: [], + vault_ids: [], + usage: null, + created_at: 1_700_000_000, + last_active_at: 1_700_000_100, +}; + +describe("Session actions dialog content", () => { + it("renders complete durable details without editing controls", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("session_1"); + expect(html).toContain("requires action"); + expect(html).toContain("Release review"); + expect(html).toContain("Never store credentials"); + expect(html).not.toMatch(/<(input|textarea|select)/); + }); + + it("renders accessible title and arbitrary string metadata controls with a secrets warning", () => { + const html = renderToStaticMarkup( + undefined} />, + ); + expect(html).toContain('
Title"); + expect(html).toContain("Additional metadata"); + expect(html).toContain('"team": "web"'); + expect(html).not.toContain('"title":'); + expect(html).toContain("Never store credentials"); + }); + + it("states confirmation, no-retry, lifecycle, erasure, and Workspace boundaries", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Delete Release review from Agent Core?"); + expect(html).toContain("Exact Session: session_1"); + expect(html).toContain("only after Core confirms success"); + expect(html).toContain("never retried automatically"); + expect(html).toContain("server lifecycle semantics"); + expect(html).toContain("not a promise of physical history erasure"); + expect(html).toContain("Workspace files"); + }); +}); diff --git a/apps/web/src/features/sessions/actions/SessionActionsDialog.tsx b/apps/web/src/features/sessions/actions/SessionActionsDialog.tsx new file mode 100644 index 0000000..e5d41a4 --- /dev/null +++ b/apps/web/src/features/sessions/actions/SessionActionsDialog.tsx @@ -0,0 +1,346 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { createPortal } from "react-dom"; +import { useEffect, useRef, useState, type FormEvent } from "react"; + +import type { AgentSession } from "@agents-core-web/agents-client"; + +import { Modal } from "../../../components/Modal"; +import { + SessionActionError, + SessionMetadataConflictError, + rebaseSessionMetadataDraft, + validateSessionMetadata, + valuesFromMetadata, + valuesFromSession, + type SessionMetadataValues, +} from "./session-actions"; + +type DialogMode = "detail" | "edit" | "delete"; + +interface SessionActionsDialogProps { + busy: boolean; + session: AgentSession | null; + onClose: () => void; + onDelete: (sessionId: string) => Promise; + onDeleted: (sessionId: string) => void; + onRetrieve: (sessionId: string) => Promise; + onUpdate: ( + sessionId: string, + baselineMetadata: Record, + draftMetadata: Record, + ) => Promise; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "The Session request failed."; +} + +function formatTimestamp(seconds: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }) + .format(new Date(seconds * 1000)); +} + +function sessionTitle(session: AgentSession): string { + return session.metadata.title || session.agent.name || "Untitled Session"; +} + +function StructuredMetadata({ metadata }: { metadata: Record }) { + return
{JSON.stringify(metadata, null, 2)}
; +} + +export function SessionDetails({ session }: { session: AgentSession }) { + return ( +
+
+
ID
{session.id}
+
Status
{session.status.replaceAll("_", " ")}
+
Created
+
Last active
+
Agent
{session.agent.name || Untitled Agent}
+
Model
{session.agent.model}
+
Metadata
+
+
+ Session metadata is durable Core data. Never store credentials, access tokens, private keys, or other secrets here. +
+
+ ); +} + +export function SessionDeleteConfirmation({ session }: { session: AgentSession }) { + return ( +
+

Delete {sessionTitle(session)} from Agent Core?

+

Exact Session: {session.id}

+

The Web removes this Session only after Core confirms success. A missing, conflicting, unavailable, or uncertain response leaves the current durable view in place and is never retried automatically.

+

Parsar deletion follows server lifecycle semantics. It is not a promise of physical history erasure, immediate native executor shutdown, or deletion of executor Workspace files.

+
+ ); +} + +interface SessionMetadataFormProps { + disabled?: boolean; + formId: string; + session: AgentSession; + onSubmit: (metadata: Record) => Promise | void; + replacement?: { revision: number; values: SessionMetadataValues } | null; +} + +export function SessionMetadataForm({ disabled = false, formId, session, onSubmit, replacement = null }: SessionMetadataFormProps) { + const [values, setValues] = useState(() => valuesFromSession(session)); + const [metadataError, setMetadataError] = useState(null); + + useEffect(() => { + if (!replacement) return; + setValues(replacement.values); + setMetadataError(null); + }, [replacement]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + const result = validateSessionMetadata(values); + setMetadataError(result.metadataError ?? null); + if (!result.metadata) return; + void onSubmit(result.metadata); + }; + + return ( + + +