diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 58d4382..e991d14 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -18,11 +18,37 @@ 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); } +interface FixtureState { + sessions: Array<{ id: string; metadata: Record }>; + aborts: { sessionReads: number; itemReads: number; turnReads: number; streams: number }; + openStreams: string[]; +} + +async function fixtureState(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/state`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function setFixtureSessionMetadata( + request: APIRequestContext, + id: string, + metadata: Record, +) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/session-metadata`, { data: { id, metadata } }); + expect(response.ok()).toBe(true); +} + +async function removeFixtureSession(request: APIRequestContext, id: string) { + const response = await request.post(`${fixtureBaseUrl}/__fixture/remove-session`, { data: { id } }); + expect(response.ok()).toBe(true); +} + async function emitTurnFixture(request: APIRequestContext, status: "completed" | "failed" | "cancelled") { const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-turn`, { data: { status } }); expect(response.ok()).toBe(true); @@ -34,6 +60,42 @@ async function fixtureRequests(request: APIRequestContext): Promise; } +async function expectSelectedDeleteAbortsSessionRead( + page: Page, + request: APIRequestContext, + startRead: () => Promise, +) { + const path = "/v1/agents/sessions/session_snapshot"; + const failedReads = new Map(); + page.on("requestfailed", (failedRequest) => { + const failedPath = new URL(failedRequest.url()).pathname; + if (failedRequest.method() === "GET") { + failedReads.set(failedPath, failedRequest.failure()?.errorText ?? "unknown failure"); + } + }); + const before = await fixtureState(request); + const previousReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length; + + await startRead(); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length).toBeGreaterThan(previousReads); + + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + + await expect.poll(() => failedReads.get(path)).toContain("ERR_ABORTED"); + await expect.poll(async () => (await fixtureState(request)).aborts.sessionReads) + .toBeGreaterThan(before.aborts.sessionReads); + expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); +} + async function openAgents(page: Page, request: APIRequestContext) { await resetFixture(request); await page.goto("/"); @@ -315,6 +377,443 @@ test("starts one Session with an idempotency key and without browser authorizati } }); +test("updates Session title and metadata after a latest read while preserving failed and unknown drafts", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/events") + )).length; + + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Renamed Session"); + await dialog.getByLabel("Additional Session metadata", { exact: true }).fill('{"team":"web","note":"safe"}'); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("heading", { name: "Renamed Session" })).toBeVisible(); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + + let requests = await fixtureRequests(request); + const updateIndex = requests.findLastIndex((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + const latestReadIndex = requests.findLastIndex((entry, index) => ( + index < updateIndex && entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(latestReadIndex).toBeGreaterThanOrEqual(0); + expect(latestReadIndex).toBeLessThan(updateIndex); + expect(requests[updateIndex]?.body).toEqual({ + metadata: { team: "web", note: "safe", title: "Renamed Session" }, + }); + expect(requests.filter((entry) => entry.method === "GET" && entry.path.endsWith("/events"))).toHaveLength(streamReadsBefore); + + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Draft survives 503"); + await controlFixture(request, { sessionUpdateStatus: 503 }); + const postsBefore503 = requests.filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("503"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + await page.waitForTimeout(350); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot")) + .toHaveLength(postsBefore503 + 1); + + await controlFixture(request, { sessionUpdateResponseLoss: 1 }); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("result is unknown"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); + await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); + const postsAfterLoss = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(postsAfterLoss); +}); + +test("preserves and safely rebases a Session metadata draft after a same-key conflict", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("My preserved draft"); + await setFixtureSessionMetadata(request, "session_snapshot", { + title: "Concurrent title", + concurrent: "must survive", + }); + + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("Metadata changed in Agent Core"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("My preserved draft"); + await expect(dialog.getByLabel("Additional Session metadata", { exact: true })).toContainText('"concurrent": "must survive"'); + await expect(page.locator(".conversation-header h2")).toHaveText("Concurrent title"); + let writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(writes).toHaveLength(0); + + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("heading", { name: "My preserved draft" })).toBeVisible(); + writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )); + expect(writes).toHaveLength(1); + expect(writes[0]?.body).toEqual({ + metadata: { title: "My preserved draft", concurrent: "must survive" }, + }); +}); + +test("rejects wrong-id and deep-malformed Session reads before writes or delete retries", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + await controlFixture(request, { sessionRetrieveVariant: "wrong_id" }); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("alert")).toContainText("invalid Session retrieval response"); + await expect(dialog).toContainText("session_snapshot"); + await expect(dialog).not.toContainText("another_session"); + + await dialog.getByRole("button", { name: "Edit", exact: true }).click(); + await dialog.getByLabel("Session title", { exact: true }).fill("Draft stays local"); + await controlFixture(request, { sessionRetrieveVariant: "deep_malformed" }); + const writesBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog.getByRole("alert")).toContainText("invalid Session retrieval response"); + await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft stays local"); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(writesBefore); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog).toContainText("Exact Session: session_snapshot"); + await controlFixture(request, { + sessionDeleteResponseLoss: 2, + sessionRetrieveVariant: "deep_malformed", + }); + const deletesBefore = (await fixtureRequests(request)).filter((entry) => entry.method === "DELETE").length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); + await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")) + .toHaveLength(deletesBefore + 1); +}); + +test("requires confirmation and reconciles unknown Session deletes once without retrying the write", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog).toContainText("Exact Session: session_snapshot"); + await expect(dialog).toContainText("not a promise of physical history erasure"); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(0); + + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteStatus: 409 }); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("compatible Core rejected"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await controlFixture(request, { sessionDeleteStatus: 503 }); + const readsBefore503 = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("503"); + await expect(dialog.getByRole("alert")).toContainText("refresh confirmed that the Session is still present"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + let requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(2); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBefore503 + 1); + + await controlFixture(request, { + sessionDeleteResponseLoss: 2, + sessionRetrieveStatus: 503, + }); + const readsBeforeUnresolved = requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("result is unknown"); + await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(3); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBeforeUnresolved + 1); + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(3); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + await manage.click(); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteResponseLoss: 1 }); + const readsBeforeAppliedLoss = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + )).length; + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(4); + expect(requests.filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" + ))).toHaveLength(readsBeforeAppliedLoss + 1); + await page.waitForTimeout(350); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(4); +}); + +test("keeps a stale Session row and surfaces each explicit repeated 404 deletion", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + const manage = page.locator(".conversation-session-action"); + await manage.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await removeFixtureSession(request, "session_snapshot"); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + + for (const expectedDeletes of [1, 2]) { + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("not found in Agent Core"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(expectedDeletes); + } +}); + +test("deletes an inactive Session without disturbing the active composer or listening stream", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + 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"); + const before = await fixtureState(request); + const activeId = before.sessions.find((session) => session.id !== "session_snapshot")?.id; + expect(activeId).toBeTruthy(); + const activeStreamReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` + )).length; + + const inactiveRow = page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }); + await inactiveRow.locator(".session-row-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await controlFixture(request, { sessionDeleteDelayMs: 1_500 }); + const deleteButton = dialog.locator(".modal-footer .button.danger"); + const deleteClick = deleteButton.click(); + await expect(deleteButton).toBeDisabled(); + await expect(deleteButton).toHaveText("Deleting…"); + await expect(inactiveRow).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + await deleteClick; + await expect(dialog).toHaveCount(0); + await expect(inactiveRow).toHaveCount(0); + await expect(composer).toHaveValue("active draft must survive"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(page.locator(".conversation-session-action")).toBeFocused(); + + const after = await fixtureState(request); + expect(after.aborts.streams).toBe(before.aborts.streams); + expect(after.openStreams).toContain(activeId); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` + ))).toHaveLength(activeStreamReads); +}); + +for (const pendingRead of [ + { + label: "Session", + path: "/v1/agents/sessions/session_snapshot", + control: { sessionRetrieveDelayMs: 5_000 }, + }, + { + label: "Item", + path: "/v1/agents/sessions/session_snapshot/items", + control: { itemsRetrieveDelayMs: 5_000 }, + }, + { + label: "Turn", + path: "/v1/agents/sessions/session_snapshot/turns", + control: { turnsRetrieveDelayMs: 5_000 }, + }, +] as const) { + test(`aborts the selected Session's pending ${pendingRead.label} read and SSE after confirmed delete`, async ({ page, request }) => { + const failedReads = new Map(); + page.on("requestfailed", (failedRequest) => { + const path = new URL(failedRequest.url()).pathname; + if (failedRequest.method() === "GET") { + failedReads.set(path, failedRequest.failure()?.errorText ?? "unknown failure"); + } + }); + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await controlFixture(request, { turnsScenario: 1 }); + await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + const before = await fixtureState(request); + const previousReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === pendingRead.path + )).length; + await controlFixture(request, pendingRead.control); + await emitTurnFixture(request, "completed"); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === pendingRead.path + )).length).toBeGreaterThan(previousReads); + + await controlFixture(request, { sessionDeleteStreamCloseDelayMs: 3_000 }); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); + await expect(page.locator('[data-turn-id="turn_completed"]')).toHaveCount(0); + await expect(page.locator(".conversation-session-action")).toBeFocused(); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + await expect.poll(() => failedReads.get(pendingRead.path)).toContain("ERR_ABORTED"); + await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); + const after = await fixtureState(request); + expect(after.sessions.some((session) => session.id === "session_snapshot")).toBe(false); + expect(after.openStreams).not.toContain("session_snapshot"); + }); +} + +test("aborts a pending manual recovery read after deleting the selected Session", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); + + await expectSelectedDeleteAbortsSessionRead(page, request, () => ( + page.getByRole("button", { name: "Recover durable state" }).click() + )); +}); + +test("aborts a pending detail retry read after deleting the selected Session", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await controlFixture(request, { sessionRetrieveStatus: 503 }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + const detailError = page.locator(".session-detail-error"); + await expect(detailError).toBeVisible(); + await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); + + await expectSelectedDeleteAbortsSessionRead(page, request, () => ( + detailError.getByRole("button", { name: "Retry" }).click() + )); +}); + +test("deletes the selected Session while its SSE is still connecting", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + await controlFixture(request, { streamOpenDelayMs: 3_000 }); + await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await expect(page.getByText("connecting", { exact: true })).toBeVisible(); + const before = await fixtureState(request); + + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); + expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); +}); + +test("keeps Session actions accessible and contained at 390 px in dark mode", async ({ page, request }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Dark theme" }).click(); + const manage = page.locator(".conversation-session-action"); + await manage.focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + const metrics = await dialog.evaluate((element) => { + const box = element.getBoundingClientRect(); + const footer = element.querySelector(".modal-footer")?.getBoundingClientRect(); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + left: box.left, + right: box.right, + bottom: box.bottom, + footerBottom: footer?.bottom ?? Number.POSITIVE_INFINITY, + }; + }); + expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.left).toBeGreaterThanOrEqual(0); + expect(metrics.right).toBeLessThanOrEqual(390); + expect(metrics.bottom).toBeLessThanOrEqual(844); + expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.bottom); + + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(manage).toBeFocused(); +}); + test("renders self-hosted Environment and Workspace state safely across reconnect and narrow themes", async ({ page, request }, testInfo) => { await resetFixture(request); await controlFixture(request, { diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 9e032d5..0d72f98 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -111,8 +111,27 @@ function initialState() { environmentEventStatus: 0, environmentEventCount: 0, streamStatus: 200, + streamOpenDelayMs: 0, streamCloseCount: 0, streamCloseDelayMs: 30, + sessionRetrieveDelayMs: 0, + sessionRetrieveStatus: 200, + sessionRetrieveVariant: "valid", + sessionUpdateDelayMs: 0, + sessionUpdateStatus: 200, + sessionUpdateResponseLoss: 0, + sessionDeleteDelayMs: 0, + sessionDeleteStatus: 200, + sessionDeleteResponseLoss: 0, + sessionDeleteStreamCloseDelayMs: 0, + itemsRetrieveDelayMs: 0, + itemsRetrieveStatus: 200, + }, + aborts: { + sessionReads: 0, + itemReads: 0, + turnReads: 0, + streams: 0, }, sequence: 0, }; @@ -169,7 +188,7 @@ function applyEnvironmentScenario(value) { } let state = initialState(); -const streamResponses = new Set(); +const streamResponses = new Map(); function emitTurnLifecycle(status) { const index = state.turns.findIndex((turn) => turn.id === "turn_terminal_refresh"); @@ -191,7 +210,7 @@ function emitTurnLifecycle(status) { turn_id: terminal.id, turn: terminal, })}\n\n`; - for (const stream of streamResponses) stream.write(event); + for (const stream of streamResponses.keys()) stream.write(event); return true; } @@ -257,6 +276,16 @@ function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +function trackAbort(response, key) { + let finished = false; + response.once("finish", () => { + finished = true; + }); + response.once("close", () => { + if (!finished) state.aborts[key] += 1; + }); +} + const server = http.createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", `http://${host}:${port}`); @@ -265,7 +294,7 @@ const server = http.createServer(async (request, response) => { return sendJson(response, { ready: true }); } if (request.method === "POST" && url.pathname === "/__fixture/reset") { - for (const stream of streamResponses) stream.end(); + for (const stream of streamResponses.keys()) stream.end(); streamResponses.clear(); state = initialState(); return sendJson(response, { reset: true }); @@ -285,6 +314,26 @@ const server = http.createServer(async (request, response) => { if (request.method === "GET" && url.pathname === "/__fixture/requests") { return sendJson(response, state.requests); } + if (request.method === "GET" && url.pathname === "/__fixture/state") { + return sendJson(response, { + sessions: state.sessions, + aborts: state.aborts, + openStreams: [...streamResponses.values()], + }); + } + if (request.method === "POST" && url.pathname === "/__fixture/session-metadata") { + const input = await readJson(request); + const target = state.sessions.find((session) => session.id === input.id); + if (!target) return sendError(response, 404, "Fixture Session not found."); + target.metadata = input.metadata; + return sendJson(response, target); + } + if (request.method === "POST" && url.pathname === "/__fixture/remove-session") { + const input = await readJson(request); + const before = state.sessions.length; + state.sessions = state.sessions.filter((session) => session.id !== input.id); + return sendJson(response, { removed: state.sessions.length !== before }); + } const body = request.method === "GET" || request.method === "DELETE" ? undefined : await readJson(request); recordRequest(request, url, body); @@ -365,9 +414,96 @@ const server = http.createServer(async (request, response) => { } const sessionMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)$/); - if (request.method === "GET" && sessionMatch) { - const session = state.sessions.find((candidate) => candidate.id === decodeURIComponent(sessionMatch[1])); - return session ? sendJson(response, session) : sendError(response, 404, "Fixture Session not found."); + if (sessionMatch) { + const id = decodeURIComponent(sessionMatch[1]); + const session = state.sessions.find((candidate) => candidate.id === id); + if (!session) return sendError(response, 404, "Fixture Session not found."); + + if (request.method === "GET") { + trackAbort(response, "sessionReads"); + const delayMs = state.controls.sessionRetrieveDelayMs; + const status = state.controls.sessionRetrieveStatus; + const variant = state.controls.sessionRetrieveVariant; + state.controls.sessionRetrieveDelayMs = 0; + state.controls.sessionRetrieveStatus = 200; + state.controls.sessionRetrieveVariant = "valid"; + const retrievedSession = variant === "wrong_id" + ? { ...session, id: "another_session" } + : variant === "malformed" + ? { id, object: "agent.session", metadata: session.metadata } + : variant === "deep_malformed" + ? { ...session, agent: { model: session.agent.model } } + : session; + if (delayMs && status === 200) { + const payload = JSON.stringify(retrievedSession); + response.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload) + 1, + "cache-control": "no-store", + }); + response.write(" "); + await wait(delayMs); + if (response.destroyed) return; + response.end(payload); + return; + } + if (delayMs) await wait(delayMs); + if (response.destroyed) return; + if (status !== 200) return sendError(response, status, "Fixture Session retrieve failed."); + return sendJson(response, retrievedSession); + } + + if (request.method === "POST") { + const delayMs = state.controls.sessionUpdateDelayMs; + const status = state.controls.sessionUpdateStatus; + const responseLoss = state.controls.sessionUpdateResponseLoss; + state.controls.sessionUpdateDelayMs = 0; + state.controls.sessionUpdateStatus = 200; + state.controls.sessionUpdateResponseLoss = 0; + if (delayMs) await wait(delayMs); + if (status !== 200) return sendError(response, status, "Fixture Session update failed."); + session.metadata = body.metadata ?? session.metadata; + if (responseLoss) { + response.destroy(); + return; + } + return sendJson(response, session); + } + + if (request.method === "DELETE") { + const delayMs = state.controls.sessionDeleteDelayMs; + const status = state.controls.sessionDeleteStatus; + const responseLoss = state.controls.sessionDeleteResponseLoss; + state.controls.sessionDeleteDelayMs = 0; + state.controls.sessionDeleteStatus = 200; + state.controls.sessionDeleteResponseLoss = 0; + if (delayMs) await wait(delayMs); + if (status !== 200) return sendError(response, status, "Fixture Session delete failed."); + if (responseLoss === 2) { + response.destroy(); + return; + } + state.sessions = state.sessions.filter((candidate) => candidate.id !== id); + state.turns = state.turns.filter((turn) => turn.session_id !== id); + const targetStreams = [...streamResponses] + .filter(([, streamSessionId]) => streamSessionId === id) + .map(([stream]) => stream); + const closeStreams = () => { + for (const stream of targetStreams) { + if (!stream.destroyed) stream.end(); + } + }; + if (state.controls.sessionDeleteStreamCloseDelayMs) { + setTimeout(closeStreams, state.controls.sessionDeleteStreamCloseDelayMs); + } else { + closeStreams(); + } + if (responseLoss) { + response.destroy(); + return; + } + return sendJson(response, { id, object: "agent.session.deleted", deleted: true }); + } } const environmentMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)$/); @@ -398,7 +534,16 @@ const server = http.createServer(async (request, response) => { const itemsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/items$/); if (request.method === "GET" && itemsMatch) { + trackAbort(response, "itemReads"); + if (state.controls.itemsRetrieveDelayMs) await wait(state.controls.itemsRetrieveDelayMs); + if (response.destroyed) return; + if (state.controls.itemsRetrieveStatus !== 200) { + return sendError(response, state.controls.itemsRetrieveStatus, "Fixture Items retrieve failed."); + } const sessionId = decodeURIComponent(itemsMatch[1]); + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for Items."); + } const items = sessionId !== "session_snapshot" ? [] : state.controls.itemsScenario @@ -411,7 +556,9 @@ const server = http.createServer(async (request, response) => { const turnsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/turns$/); if (request.method === "GET" && turnsMatch) { + trackAbort(response, "turnReads"); if (state.controls.turnsRetrieveDelayMs) await wait(state.controls.turnsRetrieveDelayMs); + if (response.destroyed) return; if (state.controls.turnsRetrieveStatus !== 200) { return sendError(response, state.controls.turnsRetrieveStatus, "Fixture Turns retrieve failed."); } @@ -449,6 +596,13 @@ const server = http.createServer(async (request, response) => { return; } if (request.method === "GET" && eventsMatch) { + trackAbort(response, "streams"); + const sessionId = decodeURIComponent(eventsMatch[1]); + if (state.controls.streamOpenDelayMs) await wait(state.controls.streamOpenDelayMs); + if (response.destroyed) return; + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for stream."); + } if (state.controls.streamStatus !== 200) { return sendError(response, state.controls.streamStatus, "Fixture stream rejected."); } @@ -457,7 +611,7 @@ const server = http.createServer(async (request, response) => { "cache-control": "no-cache, no-transform", connection: "keep-alive", }); - streamResponses.add(response); + streamResponses.set(response, sessionId); response.write(": fixture stream open\n\n"); const statuses = [null, "pending", "ready", "connected", "disconnected", "failed", "expired"]; const environmentStatus = statuses[state.controls.environmentEventStatus] ?? null; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 27cc065..5e3a667 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -36,6 +36,17 @@ import { type SessionDetailState, type StreamState, } from "./features/sessions/SessionsView"; +import { + removeSession, + reconcileUnknownSessionDelete, + replaceSessionMetadata, + requestSessionDelete, + requestSessionDetail, + requestSessionUpdate, + selectionAfterSessionDelete, + SessionActionError, + SessionMetadataConflictError, +} from "./features/sessions/actions/session-actions"; import { environmentObservationFromResource, environmentIdsMatch, @@ -200,6 +211,7 @@ export function App() { ); const [busy, setBusy] = useState(false); const selectedIdRef = useRef(selectedId); + const sessionsRef = useRef(sessions); const itemsSessionIdRef = useRef(itemsSessionId); const turnsSessionIdRef = useRef(turnsSessionId); const connectionGenerationRef = useRef(0); @@ -216,7 +228,10 @@ export function App() { const sessionEnvironmentIdRef = useRef(new Map()); const operationRequestRef = useRef(0); const streamEpochRef = useRef(0); + const selectedSessionReadAbortRef = useRef(null); + const selectedStreamAbortRef = useRef(null); selectedIdRef.current = selectedId; + sessionsRef.current = sessions; const core = useMemo(() => createCore(connection), [connection]); const coreGeneration = connectionGenerationRef.current; @@ -498,6 +513,16 @@ export function App() { [core, coreGeneration, notify], ); + const refreshSelectedSession = useCallback((sessionId: string): Promise => { + if (selectedIdRef.current !== sessionId) return Promise.resolve(false); + selectedSessionReadAbortRef.current?.abort(); + const controller = new AbortController(); + // Turn pagination can outlive refreshSession's Session/Item result, so retain this + // controller until the next selected read, selection change, deletion, or Core change. + selectedSessionReadAbortRef.current = controller; + return refreshSession(sessionId, controller.signal); + }, [refreshSession]); + const recoverSessionWorkspace = useCallback(() => { void (async () => { const generation = coreGeneration; @@ -505,9 +530,9 @@ export function App() { const refreshed = await refreshSessions(); if (!refreshed || generation !== connectionGenerationRef.current) return; const sessionId = selectedIdRef.current; - if (sessionId) await refreshSession(sessionId); + if (sessionId) await refreshSelectedSession(sessionId); })(); - }, [coreGeneration, refreshAgents, refreshSession, refreshSessions]); + }, [coreGeneration, refreshAgents, refreshSelectedSession, refreshSessions]); useEffect(() => { setAgents([]); @@ -526,7 +551,9 @@ export function App() { }, [refreshAgents, refreshSessions]); useEffect(() => { + selectedSessionReadAbortRef.current?.abort(); if (!selectedId) { + selectedSessionReadAbortRef.current = null; setItems([]); setTurns([]); itemsSessionIdRef.current = null; @@ -560,16 +587,20 @@ export function App() { next.delete(selectedId); return next; }); - const controller = new AbortController(); - void refreshSession(selectedId, controller.signal); - return () => controller.abort(); - }, [refreshSession, selectedId]); + void refreshSelectedSession(selectedId); + return () => { + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + }; + }, [refreshSelectedSession, selectedId]); useEffect(() => { + selectedStreamAbortRef.current?.abort(); if (!selectedId) return; const sessionId = selectedId; const controller = new AbortController(); + selectedStreamAbortRef.current = controller; const streamEpoch = streamEpochRef.current + 1; streamEpochRef.current = streamEpoch; setStreamConnection({ sessionId, state: "connecting", error: null }); @@ -775,6 +806,7 @@ export function App() { refreshCoordinator?.dispose(); recovery.invalidate(); controller.abort(); + if (selectedStreamAbortRef.current === controller) selectedStreamAbortRef.current = null; }; }, [core, coreGeneration, notify, refreshSession, selectedId, streamRetryRevision]); @@ -848,6 +880,148 @@ export function App() { setView("sessions"); }; + const retrieveSessionForAction = useCallback(async (sessionId: string) => { + const generation = coreGeneration; + try { + const session = await requestSessionDetail(core, sessionId); + return generation === connectionGenerationRef.current ? session : undefined; + } catch (error) { + if (generation !== connectionGenerationRef.current) return undefined; + throw error; + } + }, [core, coreGeneration]); + + const updateSessionMetadata = async ( + sessionId: string, + baselineMetadata: Record, + draftMetadata: Record, + ): Promise => { + const generation = coreGeneration; + let updated: AgentSession; + try { + updated = await requestSessionUpdate(core, sessionId, baselineMetadata, draftMetadata); + } catch (error) { + if (generation !== connectionGenerationRef.current) return undefined; + if (error instanceof SessionMetadataConflictError && error.latestSession) { + sessionCollectionRevisionRef.current += 1; + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setSessions((current) => replaceSessionMetadata(current, error.latestSession as AgentSession)); + } + throw error; + } + if (generation !== connectionGenerationRef.current) { + notify("The previous Core returned a Session update after the connection changed. The current Core view was not modified.", "error"); + return undefined; + } + sessionCollectionRevisionRef.current += 1; + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setSessions((current) => replaceSessionMetadata(current, updated)); + notify("Session metadata updated.", "success"); + return updated; + }; + + const removeSessionFromWorkspace = (sessionId: string, message: string): boolean => { + const selectedAtCompletion = selectedIdRef.current; + const deletingSelected = selectedAtCompletion === sessionId; + const nextSelectedId = selectionAfterSessionDelete( + sessionsRef.current, + selectedAtCompletion, + sessionId, + ); + const increment = (revisions: Map) => { + revisions.set(sessionId, (revisions.get(sessionId) ?? 0) + 1); + }; + sessionCollectionRevisionRef.current += 1; + increment(sessionRequestRef.current); + increment(sessionEventRevisionRef.current); + increment(itemEventRevisionRef.current); + increment(turnEventRevisionRef.current); + increment(environmentEventRevisionRef.current); + increment(environmentRequestRef.current); + sessionEnvironmentIdRef.current.delete(sessionId); + + setSessions((current) => { + const next = removeSession(current, sessionId); + sessionsRef.current = next; + return next; + }); + setSessionSendFailures((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + + if (deletingSelected) { + selectedIdRef.current = nextSelectedId; + streamEpochRef.current += 1; + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + selectedStreamAbortRef.current?.abort(); + selectedStreamAbortRef.current = null; + itemsSessionIdRef.current = null; + turnsSessionIdRef.current = null; + setItems([]); + setItemsSessionId(null); + setTurns([]); + setTurnsSessionId(null); + setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); + setStreamConnection({ sessionId: null, state: "idle", error: null }); + setSelectedId(nextSelectedId); + } + notify(message, "success"); + return true; + }; + + const deleteSessionFromCore = async (sessionId: string): Promise => { + const generation = coreGeneration; + try { + await requestSessionDelete(core, sessionId); + } catch (error) { + if (generation !== connectionGenerationRef.current) return false; + if (!(error instanceof SessionActionError) || error.kind !== "unknown_write") throw error; + + const reconciliation = await reconcileUnknownSessionDelete(core, sessionId); + if (generation !== connectionGenerationRef.current) return false; + if (reconciliation.state === "missing") { + return removeSessionFromWorkspace( + sessionId, + "Session is absent from Agent Core after reconciling the unknown deletion result.", + ); + } + if (reconciliation.state === "unknown") { + throw new SessionActionError( + `${error.message} The follow-up durable Session refresh also failed, so the result remains unknown.`, + "unknown_write", + { cause: error }, + ); + } + throw new SessionActionError( + `${error.message} A follow-up durable Session refresh confirmed that the Session is still present.`, + "request_failed", + { cause: error }, + ); + } + if (generation !== connectionGenerationRef.current) { + notify("The previous Core confirmed Session deletion after the connection changed. The current Core view was not modified.", "error"); + return false; + } + return removeSessionFromWorkspace(sessionId, "Session deleted from Agent Core."); + }; + const sendMessage = async (text: string) => { const sessionId = selectedId; if (!sessionId) return; @@ -883,7 +1057,7 @@ export function App() { next.delete(sessionId); return next; }); - await refreshSession(sessionId); + await refreshSelectedSession(sessionId); }; const cancel = async () => { @@ -891,7 +1065,7 @@ export function App() { if (!sessionId) return; await run(() => core.cancelTurn(sessionId), "Cancellation requested."); if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; - await refreshSession(sessionId); + await refreshSelectedSession(sessionId); }; const submitFunctionResult = async (input: FunctionResultInput) => { @@ -899,7 +1073,7 @@ export function App() { if (!sessionId) return; await run(() => core.submitFunctionResult(sessionId, input), "Function result submitted."); if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; - await refreshSession(sessionId); + await refreshSelectedSession(sessionId); }; const applyConnection = (next: CoreConnection) => { @@ -918,6 +1092,10 @@ export function App() { sessionEnvironmentIdRef.current.clear(); operationRequestRef.current += 1; streamEpochRef.current += 1; + selectedSessionReadAbortRef.current?.abort(); + selectedSessionReadAbortRef.current = null; + selectedStreamAbortRef.current?.abort(); + selectedStreamAbortRef.current = null; saveConnection(normalized); setBusy(false); setAgentCollectionState("connecting"); @@ -1021,6 +1199,7 @@ export function App() {
{view === "sessions" ? ( { - if (selectedId) void refreshSession(selectedId); + if (selectedId) void refreshSelectedSession(selectedId); }} onRetryStream={retryCurrentStream} + onRetrieveSession={retrieveSessionForAction} onSelect={setSelectedId} onSend={sendMessage} + onUpdateSession={updateSessionMetadata} /> ) : null} {view === "agents" ? ( diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx index ce26f82..047615a 100644 --- a/apps/web/src/features/CoreCollectionStates.test.tsx +++ b/apps/web/src/features/CoreCollectionStates.test.tsx @@ -15,12 +15,15 @@ const agentsCallbacks = { const sessionsCallbacks = { onCancel: async () => undefined, onCreateSession: async () => undefined, + onDeleteSession: async () => true, onFunctionResult: async () => undefined, onRefresh: () => undefined, onRetrySession: () => undefined, onRetryStream: () => undefined, + onRetrieveSession: async () => undefined, onSelect: () => undefined, onSend: async () => undefined, + onUpdateSession: async () => undefined, }; const selectedSession: AgentSession = { diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 33c35ef..5e47f6b 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -4,6 +4,7 @@ import { Bot, Clock3, Code2, + Ellipsis, ExternalLink, MessageSquare, Plus, @@ -37,6 +38,7 @@ import { import type { EnvironmentObservation } from "./environment/environment-state"; import { ThreadItems } from "./items/ItemRenderers"; import { TurnTimeline, type TurnTimelineLoadState } from "./turns/TurnTimeline"; +import { SessionActionsDialog } from "./actions/SessionActionsDialog"; export type StreamState = "idle" | "connecting" | "listening" | "recovering" | "failed"; export type SessionDetailState = "idle" | "loading" | "ready" | "failed"; @@ -60,12 +62,19 @@ interface SessionsViewProps { streamState: StreamState; onCancel: () => Promise; onCreateSession: (agentId: string) => Promise; + onDeleteSession: (sessionId: string) => Promise; onFunctionResult: (input: FunctionResultInput) => Promise; onRefresh: () => void; onRetrySession: () => void; onRetryStream: () => void; + onRetrieveSession: (sessionId: string) => Promise; onSelect: (sessionId: string) => void; onSend: (text: string) => Promise; + onUpdateSession: ( + sessionId: string, + baselineMetadata: Record, + draftMetadata: Record, + ) => Promise; } const executorSetupUrl = "https://github.com/MiniMax-AI-Dev/parsar/blob/main/services/agents-api/README.md#internal-execution-device-connection"; @@ -276,19 +285,27 @@ export function SessionsView({ streamState, onCancel, onCreateSession, + onDeleteSession, onFunctionResult, onRefresh, onRetrySession, onRetryStream, + onRetrieveSession, onSelect, onSend, + onUpdateSession, }: SessionsViewProps) { const [message, setMessage] = useState(""); const [newSessionOpen, setNewSessionOpen] = useState(false); const [agentId, setAgentId] = useState(agents[0]?.id ?? ""); + const [actionSession, setActionSession] = useState(null); const [viewport, setViewport] = useState(null); const [threadContent, setThreadContent] = useState(null); const sendingRef = useRef(false); + const pageRef = useRef(null); + const newSessionActionRef = useRef(null); + const conversationActionRef = useRef(null); + const restoreFocusAfterDeleteRef = useRef(false); const draftsBySessionRef = useRef(new Map()); const selectedIdRef = useRef(selected?.id ?? null); selectedIdRef.current = selected?.id ?? null; @@ -302,6 +319,22 @@ export function SessionsView({ if (!agentId && agents[0]) setAgentId(agents[0].id); }, [agentId, agents]); + useEffect(() => { + if (actionSession && !sessions.some((session) => session.id === actionSession.id)) { + setActionSession(null); + } + }, [actionSession, sessions]); + + useEffect(() => { + if (actionSession || !restoreFocusAfterDeleteRef.current) return; + const frame = window.requestAnimationFrame(() => { + const newSessionAction = newSessionActionRef.current?.disabled ? null : newSessionActionRef.current; + (conversationActionRef.current ?? newSessionAction ?? pageRef.current)?.focus(); + restoreFocusAfterDeleteRef.current = false; + }); + return () => window.cancelAnimationFrame(frame); + }, [actionSession, selected?.id, sessions]); + useEffect(() => { const sessionId = selected?.id; setMessage(sessionId ? draftsBySessionRef.current.get(sessionId) ?? "" : ""); @@ -379,7 +412,7 @@ export function SessionsView({ ); return ( -
+
@@ -410,19 +443,34 @@ export function SessionsView({ /> ) : null} {coreState === "ready" || sessions.length ? sessions.map((session) => ( - + + + )) : null} {coreState === "ready" && !sessions.length ? (
@@ -460,6 +508,17 @@ export function SessionsView({ {streamState}
+
@@ -658,6 +717,19 @@ export function SessionsView({ The Session starts idle so the UI can subscribe before the first Turn.
+ setActionSession(null)} + onDelete={onDeleteSession} + onDeleted={(sessionId) => { + draftsBySessionRef.current.delete(sessionId); + if (selectedIdRef.current === sessionId) setMessage(""); + restoreFocusAfterDeleteRef.current = true; + }} + onRetrieve={onRetrieveSession} + onUpdate={onUpdateSession} + /> ); } 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 ( + + +