diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 0600214..58d4382 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -5,6 +5,7 @@ const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18 interface FixtureRequest { method: string; path: string; + query?: string; beta: string | null; authorizationPresent: boolean; idempotencyKeyPresent: boolean; @@ -22,6 +23,11 @@ async function controlFixture(request: APIRequestContext, control: Record { const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); expect(response.ok()).toBe(true); @@ -502,6 +508,100 @@ test("applies a buffered live Environment event after an earlier durable snapsho await expect(panel).not.toContainText("Pending"); }); +test("loads every Turn page, reconciles terminal events, and keeps failures beside conversation Items", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsPageSize: 2, + }); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + const timeline = page.getByRole("region", { name: "Turn timeline" }); + await expect(timeline).toContainText("7 observed Turns"); + for (const status of ["Queued", "In progress", "Waiting", "Completed", "Failed", "Cancelled"]) { + await expect(timeline.getByRole("img", { name: `Turn status: ${status}` }).first()).toBeVisible(); + } + await expect(timeline).toContainText("Running ·"); + await expect(timeline.locator('[data-turn-id="turn_completed"]')).toContainText("7s"); + await expect(timeline.getByRole("region", { name: "Session aggregate usage" })).toContainText("26"); + await expect(timeline.locator('[data-turn-id="turn_completed"]').getByRole("group", { name: "Usage for Turn turn_completed" })).toContainText("13"); + const failed = timeline.locator('[data-turn-id="turn_failed"]'); + await expect(failed).toContainText("The execution could not complete."); + await expect(failed).toContainText("1 linked Item"); + await expect(page.getByText("Persisted input before the Turn failed.")).toBeVisible(); + await expect(timeline).toContainText("1 Item is not associated with an observed Turn yet."); + + const readsBeforeTerminal = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/turns") + )); + expect(readsBeforeTerminal.length).toBeGreaterThanOrEqual(4); + expect(readsBeforeTerminal.some((entry) => entry.query === "?limit=100&order=asc")).toBe(true); + expect(readsBeforeTerminal.some((entry) => entry.query?.includes("after=turn_in_progress"))).toBe(true); + expect(readsBeforeTerminal.every((entry) => entry.body === undefined)).toBe(true); + + const terminal = timeline.locator('[data-turn-id="turn_terminal_refresh"]'); + await expect(terminal).toHaveAttribute("data-turn-status", "in_progress"); + await emitTurnFixture(request, "completed"); + await expect(terminal).toHaveAttribute("data-turn-status", "completed"); + await expect(terminal).toContainText("Turn usage"); + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/turns")).length).toBeGreaterThan(readsBeforeTerminal.length); + + await controlFixture(request, { turnsRetrieveStatus: 503 }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + await expect(timeline.locator(".turn-timeline-failure")).toContainText("Couldn’t load Turn history"); + await expect(timeline).toContainText("last observed Turn timeline remains visible"); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await timeline.evaluate((element) => element.scrollIntoView({ block: "start" })); + const widths = await timeline.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.left).toBeGreaterThanOrEqual(0); + expect(widths.right).toBeLessThanOrEqual(widths.viewport); + await attachElementScreenshot(timeline, testInfo, "narrow-turn-timeline"); +}); + +test("drops a delayed Turn page after switching Sessions", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsRetrieveDelayMs: 700, + turnsPageSize: 2, + }); + await page.goto("/"); + const timeline = page.getByRole("region", { name: "Turn timeline" }); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible({ timeout: 1_500 }); + await expect(timeline).toContainText("Loading every Turn page"); + await page.getByRole("button", { name: "Agents" }).click(); + await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); + await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); + + await expect(timeline).toContainText("No Turns reported yet."); + await page.waitForTimeout(3_000); + await expect(timeline).not.toContainText("turn_queued"); + await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); + + const turnReads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path.endsWith("/turns") + )); + expect(turnReads.some((entry) => entry.path.includes("session_snapshot"))).toBe(true); + expect(turnReads.some((entry) => entry.path.includes("session_created_"))).toBe(true); +}); + test("renders Parsar patches as accessible read-only diffs in desktop and narrow themes", async ({ page, request }, testInfo) => { await resetFixture(request); await controlFixture(request, { itemsScenario: 1 }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index de44073..9e032d5 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -25,6 +25,26 @@ function patchItems() { ]; } +function observableTurns() { + return [ + { id: "turn_queued", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "queued", created_at: baseline - 18, started_at: null, completed_at: null, error: null, usage: null }, + { id: "turn_in_progress", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "in_progress", created_at: baseline - 17, started_at: baseline - 16, completed_at: null, error: null, usage: null }, + { id: "turn_waiting", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "waiting", created_at: baseline - 15, started_at: baseline - 14, completed_at: null, error: null, usage: null }, + { id: "turn_completed", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "completed", created_at: baseline - 13, started_at: baseline - 12, completed_at: baseline - 5, error: null, usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13, input_tokens_details: { cached_tokens: 4 }, output_tokens_details: { reasoning_tokens: 2 } } }, + { id: "turn_failed", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "failed", created_at: baseline - 4, started_at: baseline - 3, completed_at: baseline - 2, error: { code: "internal_error", message: "The execution could not complete." }, usage: null }, + { id: "turn_cancelled", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "cancelled", created_at: baseline - 1, started_at: baseline, completed_at: baseline + 1, error: null, usage: null }, + { id: "turn_terminal_refresh", agent_id: "agent_a", session_id: "session_snapshot", object: "agent.session.turn", status: "in_progress", created_at: baseline + 2, started_at: baseline + 3, completed_at: null, error: null, usage: null }, + ]; +} + +function observableTurnItems() { + return [ + { id: "turn_message", turn_id: "turn_completed", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: "Completed Turn output remains in the conversation." }] }, + { id: "failed_input", turn_id: "turn_failed", type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: "Persisted input before the Turn failed." }] }, + { id: "unassociated", turn_id: "turn_not_loaded", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: "This Item is waiting for its Turn page." }] }, + ]; +} + function savedAgent(id, name, model, updatedAt) { return { id, @@ -67,6 +87,7 @@ function initialState() { created_at: baseline - 20, last_active_at: baseline - 10, }], + turns: [], requests: [], controls: { retrieveDelayMs: 0, @@ -78,6 +99,10 @@ function initialState() { sendStatus: 204, sendResponseLoss: 0, itemsScenario: 0, + turnsScenario: 0, + turnsRetrieveDelayMs: 0, + turnsRetrieveStatus: 200, + turnsPageSize: 2, environmentScenario: 0, environmentRetrieveDelayMs: 0, environmentRetrieveStatus: 200, @@ -93,6 +118,24 @@ function initialState() { }; } +function applyTurnsScenario(value) { + const session = state.sessions[0]; + if (!session) return; + if (value === 1) { + state.turns = observableTurns(); + session.usage = { + input_tokens: 20, + output_tokens: 6, + total_tokens: 26, + input_tokens_details: { cached_tokens: 8 }, + output_tokens_details: { reasoning_tokens: 4 }, + }; + return; + } + state.turns = []; + session.usage = null; +} + function applyEnvironmentScenario(value) { const session = state.sessions[0]; if (!session) return; @@ -126,6 +169,31 @@ function applyEnvironmentScenario(value) { } let state = initialState(); +const streamResponses = new Set(); + +function emitTurnLifecycle(status) { + const index = state.turns.findIndex((turn) => turn.id === "turn_terminal_refresh"); + const existing = state.turns[index]; + if (!existing || !["completed", "failed", "cancelled"].includes(status)) return false; + const terminal = { + ...existing, + status, + completed_at: baseline + 10, + error: status === "failed" ? { code: "internal_error", message: "The execution could not complete." } : null, + usage: status === "completed" ? { input_tokens: 5, output_tokens: 2, total_tokens: 7, input_tokens_details: { cached_tokens: 1 }, output_tokens_details: { reasoning_tokens: 1 } } : null, + }; + state.turns[index] = terminal; + state.sequence += 1; + const event = `id: turn_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.turn.${status}`, + event_id: `turn_${state.sequence}`, + session_id: "session_snapshot", + turn_id: terminal.id, + turn: terminal, + })}\n\n`; + for (const stream of streamResponses) stream.write(event); + return true; +} function sendJson(response, value, status = 200) { const body = JSON.stringify(value); @@ -168,6 +236,7 @@ function recordRequest(request, url, body) { state.requests.push({ method: request.method, path: url.pathname, + query: url.search, beta: request.headers["openai-beta"] ?? null, authorizationPresent: Boolean(request.headers.authorization), idempotencyKeyPresent: Boolean(request.headers["idempotency-key"]), @@ -196,14 +265,23 @@ 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(); + streamResponses.clear(); state = initialState(); return sendJson(response, { reset: true }); } if (request.method === "POST" && url.pathname === "/__fixture/control") { state.controls = { ...state.controls, ...await readJson(request) }; applyEnvironmentScenario(state.controls.environmentScenario); + applyTurnsScenario(state.controls.turnsScenario); return sendJson(response, state.controls); } + if (request.method === "POST" && url.pathname === "/__fixture/emit-turn") { + const input = await readJson(request); + return emitTurnLifecycle(input.status) + ? sendJson(response, { emitted: true }) + : sendError(response, 400, "Fixture terminal Turn is unavailable."); + } if (request.method === "GET" && url.pathname === "/__fixture/requests") { return sendJson(response, state.requests); } @@ -319,7 +397,41 @@ const server = http.createServer(async (request, response) => { } const itemsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/items$/); - if (request.method === "GET" && itemsMatch) return sendJson(response, page(state.controls.itemsScenario ? patchItems() : [])); + if (request.method === "GET" && itemsMatch) { + const sessionId = decodeURIComponent(itemsMatch[1]); + const items = sessionId !== "session_snapshot" + ? [] + : state.controls.itemsScenario + ? patchItems() + : state.controls.turnsScenario + ? observableTurnItems() + : []; + return sendJson(response, page(items)); + } + + const turnsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/turns$/); + if (request.method === "GET" && turnsMatch) { + if (state.controls.turnsRetrieveDelayMs) await wait(state.controls.turnsRetrieveDelayMs); + if (state.controls.turnsRetrieveStatus !== 200) { + return sendError(response, state.controls.turnsRetrieveStatus, "Fixture Turns retrieve failed."); + } + const sessionId = decodeURIComponent(turnsMatch[1]); + if (!state.sessions.some((candidate) => candidate.id === sessionId)) { + return sendError(response, 404, "Fixture Session not found for Turns."); + } + const sessionTurns = state.turns.filter((turn) => turn.session_id === sessionId); + const after = url.searchParams.get("after"); + const start = after ? sessionTurns.findIndex((turn) => turn.id === after) + 1 : 0; + if (after && start === 0) return sendError(response, 400, "Fixture Turn cursor not found."); + const requestedLimit = Number(url.searchParams.get("limit") ?? 20); + const size = Math.max(1, Math.min(requestedLimit, state.controls.turnsPageSize)); + const data = sessionTurns.slice(start, start + size); + return sendJson(response, { + object: "list", + data, + has_more: start + data.length < sessionTurns.length, + }); + } const eventsMatch = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/events$/); if (request.method === "POST" && eventsMatch) { @@ -345,6 +457,7 @@ const server = http.createServer(async (request, response) => { "cache-control": "no-cache, no-transform", connection: "keep-alive", }); + streamResponses.add(response); response.write(": fixture stream open\n\n"); const statuses = [null, "pending", "ready", "connected", "disconnected", "failed", "expired"]; const environmentStatus = statuses[state.controls.environmentEventStatus] ?? null; @@ -378,7 +491,10 @@ const server = http.createServer(async (request, response) => { setTimeout(() => response.end(), state.controls.streamCloseDelayMs); } const heartbeat = setInterval(() => response.write(": fixture heartbeat\n\n"), 10_000); - request.on("close", () => clearInterval(heartbeat)); + request.on("close", () => { + clearInterval(heartbeat); + streamResponses.delete(response); + }); return; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 691a228..27cc065 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,6 +10,7 @@ import { AgentCoreError } from "@agents-core-web/agents-client"; import type { AgentCore, AgentSession, + AgentTurn, CreateAgentInput, FunctionResultInput, SavedAgent, @@ -47,6 +48,13 @@ import { unavailableEnvironmentObservation, visibleEnvironmentObservation, } from "./features/sessions/environment/environment-state"; +import { + listAllTurns, + matchingTurnSnapshot, + mergeDurableAndLiveTurns, + turnReadIsCurrent, + upsertTurn, +} from "./features/sessions/turns/turn-state"; import { SystemView } from "./features/system/SystemView"; import { createCore, @@ -162,6 +170,13 @@ export function App() { const [selectedId, setSelectedId] = useState(null); const [items, setItems] = useState([]); const [itemsSessionId, setItemsSessionId] = useState(null); + const [turns, setTurns] = useState([]); + const [turnsSessionId, setTurnsSessionId] = useState(null); + const [turnCollectionLoad, setTurnCollectionLoad] = useState({ + sessionId: null, + state: "idle", + error: null, + }); const [environmentObservations, setEnvironmentObservations] = useState>( () => new Map(), ); @@ -186,6 +201,7 @@ export function App() { const [busy, setBusy] = useState(false); const selectedIdRef = useRef(selectedId); const itemsSessionIdRef = useRef(itemsSessionId); + const turnsSessionIdRef = useRef(turnsSessionId); const connectionGenerationRef = useRef(0); const agentCollectionRequestRef = useRef(0); const sessionCollectionRequestRef = useRef(0); @@ -194,6 +210,7 @@ export function App() { const sessionRequestRef = useRef(new Map()); const sessionEventRevisionRef = useRef(new Map()); const itemEventRevisionRef = useRef(new Map()); + const turnEventRevisionRef = useRef(new Map()); const environmentEventRevisionRef = useRef(new Map()); const environmentRequestRef = useRef(new Map()); const sessionEnvironmentIdRef = useRef(new Map()); @@ -215,6 +232,12 @@ export function App() { ? selectedSessionLoad.state : "loading"; const detailError = selectedSessionLoad.sessionId === selectedId ? selectedSessionLoad.error : null; + const turnState: SessionDetailState = !selectedId + ? "idle" + : turnCollectionLoad.sessionId === selectedId + ? turnCollectionLoad.state + : "loading"; + const turnError = turnCollectionLoad.sessionId === selectedId ? turnCollectionLoad.error : null; const streamState: StreamState = !selectedId ? "idle" : streamConnection.sessionId !== selectedId @@ -326,8 +349,45 @@ export function App() { const request = (sessionRequestRef.current.get(sessionId) ?? 0) + 1; const sessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; const itemRevision = itemEventRevisionRef.current.get(sessionId) ?? 0; + const turnRevision = turnEventRevisionRef.current.get(sessionId) ?? 0; const environmentRevision = environmentEventRevisionRef.current.get(sessionId) ?? 0; sessionRequestRef.current.set(sessionId, request); + const turnRead = { coreGeneration, request, sessionId }; + void listAllTurns(core, sessionId, signal).then((sessionTurns) => { + const currentTurnRead = { + coreGeneration: connectionGenerationRef.current, + request: sessionRequestRef.current.get(sessionId) ?? 0, + sessionId, + selectedSessionId: selectedIdRef.current, + }; + if (!turnReadIsCurrent(turnRead, currentTurnRead)) return; + const liveRevisionChanged = turnRevision !== (turnEventRevisionRef.current.get(sessionId) ?? 0); + const currentTurnsSessionId = turnsSessionIdRef.current; + turnsSessionIdRef.current = sessionId; + setTurns((current) => ( + liveRevisionChanged + ? mergeDurableAndLiveTurns( + sessionTurns, + currentTurnsSessionId === sessionId ? current : [], + ) + : sessionTurns + )); + setTurnsSessionId(sessionId); + setTurnCollectionLoad({ sessionId, state: "ready", error: null }); + }).catch((error: unknown) => { + const currentTurnRead = { + coreGeneration: connectionGenerationRef.current, + request: sessionRequestRef.current.get(sessionId) ?? 0, + sessionId, + selectedSessionId: selectedIdRef.current, + }; + if (!turnReadIsCurrent(turnRead, currentTurnRead) || isAbort(error)) return; + setTurnCollectionLoad({ + sessionId, + state: "failed", + error: errorMessage(error), + }); + }); try { const [session, sessionItems] = await Promise.all([ core.retrieveSession(sessionId, { signal }), @@ -453,9 +513,13 @@ export function App() { setAgents([]); setSessions([]); setItems([]); + setTurns([]); setEnvironmentObservations(new Map()); itemsSessionIdRef.current = null; setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); setSelectedId(null); void refreshAgents(); void refreshSessions(); @@ -464,15 +528,23 @@ export function App() { useEffect(() => { if (!selectedId) { setItems([]); + setTurns([]); itemsSessionIdRef.current = null; setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); setStreamConnection({ sessionId: null, state: "idle", error: null }); return; } setItems([]); + setTurns([]); itemsSessionIdRef.current = selectedId; setItemsSessionId(selectedId); + turnsSessionIdRef.current = selectedId; + setTurnsSessionId(selectedId); + setTurnCollectionLoad({ sessionId: selectedId, state: "loading", error: null }); setSelectedSessionLoad({ sessionId: selectedId, state: "loading", error: null }); environmentEventRevisionRef.current.set( selectedId, @@ -575,6 +647,22 @@ export function App() { sessionCollectionRevisionRef.current += 1; setSessions((current) => current.map((session) => (session.id === eventSession.id ? eventSession : session))); } + const eventTurn = matchingTurnSnapshot(event, sessionId); + if (eventTurn) { + turnEventRevisionRef.current.set( + sessionId, + (turnEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + if (selectedIdRef.current === sessionId) { + const currentTurnsSessionId = turnsSessionIdRef.current; + turnsSessionIdRef.current = sessionId; + setTurnsSessionId(sessionId); + setTurns((current) => upsertTurn( + currentTurnsSessionId === sessionId ? current : [], + eventTurn, + )); + } + } if (event.item || eventType.includes(".output_text.")) { itemEventRevisionRef.current.set( sessionId, @@ -824,6 +912,7 @@ export function App() { sessionRequestRef.current.clear(); sessionEventRevisionRef.current.clear(); itemEventRevisionRef.current.clear(); + turnEventRevisionRef.current.clear(); environmentEventRevisionRef.current.clear(); environmentRequestRef.current.clear(); sessionEnvironmentIdRef.current.clear(); @@ -837,11 +926,15 @@ export function App() { setSessionCollectionError(null); setSelectedId(null); setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); + setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); setStreamConnection({ sessionId: null, state: "idle", error: null }); setSessionSendFailures(new Map()); setEnvironmentObservations(new Map()); itemsSessionIdRef.current = null; setItemsSessionId(null); + turnsSessionIdRef.current = null; + setTurnsSessionId(null); + setTurns([]); setConnection(normalized); setConnectionOpen(false); }; @@ -932,11 +1025,14 @@ export function App() { sessions={sessions} selected={selected} items={itemsSessionId === selectedId ? items : []} + turns={turnsSessionId === selectedId ? turns : []} busy={busy} coreError={sessionCollectionError} coreState={sessionCollectionState} detailError={detailError} detailState={detailState} + turnError={turnError} + turnState={turnState} environmentObservation={environmentObservation} sendError={sendError} streamError={streamError} diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index aff7974..33c35ef 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -14,6 +14,7 @@ import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from import type { AgentSession, + AgentTurn, EnvironmentConnectionAction, FunctionCallAction, FunctionResultInput, @@ -35,6 +36,7 @@ import { } from "./environment/EnvironmentPanel"; import type { EnvironmentObservation } from "./environment/environment-state"; import { ThreadItems } from "./items/ItemRenderers"; +import { TurnTimeline, type TurnTimelineLoadState } from "./turns/TurnTimeline"; export type StreamState = "idle" | "connecting" | "listening" | "recovering" | "failed"; export type SessionDetailState = "idle" | "loading" | "ready" | "failed"; @@ -44,11 +46,14 @@ interface SessionsViewProps { sessions: AgentSession[]; selected: AgentSession | null; items: SessionItem[]; + turns?: AgentTurn[]; busy: boolean; coreError: string | null; coreState: CoreConnectionState; detailError: string | null; detailState: SessionDetailState; + turnError?: string | null; + turnState?: TurnTimelineLoadState; environmentObservation?: EnvironmentObservation | null; sendError?: FailedPendingSend | null; streamError: string | null; @@ -257,11 +262,14 @@ export function SessionsView({ sessions, selected, items, + turns = [], busy, coreError, coreState, detailError, detailState, + turnError = null, + turnState = "idle", environmentObservation = null, sendError = null, streamError, @@ -469,6 +477,14 @@ export function SessionsView({ connectionActions={environmentConnections} /> + +
diff --git a/apps/web/src/features/sessions/turns/TurnTimeline.test.tsx b/apps/web/src/features/sessions/turns/TurnTimeline.test.tsx new file mode 100644 index 0000000..186c050 --- /dev/null +++ b/apps/web/src/features/sessions/turns/TurnTimeline.test.tsx @@ -0,0 +1,118 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { AgentTurn, SessionItem } from "@agents-core-web/agents-client"; + +import { formatTurnElapsed, TurnTimeline } from "./TurnTimeline"; + +function turn(id: string, status: AgentTurn["status"], overrides: Partial = {}): AgentTurn { + return { + id, + agent_id: "agent-1", + session_id: "session-1", + object: "agent.session.turn", + status, + created_at: 1_700_000_000, + started_at: null, + completed_at: null, + error: null, + usage: null, + ...overrides, + }; +} + +function render(turns: AgentTurn[], options: { + items?: SessionItem[]; + loadState?: "idle" | "loading" | "ready" | "failed"; + sessionUsage?: AgentTurn["usage"]; + error?: string; +} = {}) { + return renderToStaticMarkup( + , + ); +} + +describe("Turn timeline presentation", () => { + it("presents all six statuses with accessible labels", () => { + const html = render([ + turn("queued", "queued"), + turn("progress", "in_progress", { started_at: 1_700_000_000 }), + turn("waiting", "waiting", { started_at: 1_700_000_100 }), + turn("completed", "completed", { started_at: 1_700_000_000, completed_at: 1_700_000_062 }), + turn("failed", "failed"), + turn("cancelled", "cancelled"), + ]); + + for (const label of ["Queued", "In progress", "Waiting", "Completed", "Failed", "Cancelled"]) { + expect(html).toContain(`Turn status: ${label}`); + } + expect(html).toContain("Running · 2m 05s"); + expect(html).toContain("Running · 25s"); + expect(html).toContain("1m 02s"); + }); + + it("uses only valid server timestamps and never converts missing values to zero", () => { + expect(formatTurnElapsed(10, 10)).toBe("0s"); + expect(formatTurnElapsed(10, 9)).toBe("Unknown"); + expect(formatTurnElapsed(null, 20)).toBe("Unknown"); + const html = render([turn("missing", "completed", { started_at: null, completed_at: null })]); + expect(html.match(/Unknown/g)?.length).toBeGreaterThanOrEqual(8); + expect(html).not.toContain("1970-01-01"); + }); + + it("separates Session aggregate from per-Turn usage and keeps partial values unknown", () => { + const html = render([ + turn("measured", "completed", { usage: { + input_tokens: 10, + output_tokens: 4, + total_tokens: 14, + input_tokens_details: { cached_tokens: 3 }, + output_tokens_details: { reasoning_tokens: 2 }, + } }), + turn("partial", "completed", { usage: { input_tokens: 7 } as AgentTurn["usage"] }), + ], { sessionUsage: { + input_tokens: 17, + output_tokens: 4, + total_tokens: 21, + input_tokens_details: { cached_tokens: 3 }, + output_tokens_details: { reasoning_tokens: 2 }, + } }); + + expect(html).toContain("Session aggregate usage"); + expect(html.match(/Turn usage<\/strong>/g)).toHaveLength(2); + expect(html).toContain("Unknown"); + }); + + it("associates Items by turn_id while errors retain the conversation evidence", () => { + const items: SessionItem[] = [ + { id: "one", turn_id: "failed", type: "message", status: "completed", role: "user", content: [] }, + { id: "two", turn_id: "failed", type: "command_execution", status: "failed" }, + { id: "orphan", turn_id: "not-loaded", type: "message", status: "completed", role: "assistant", content: [] }, + ]; + const html = render([ + turn("failed", "failed", { error: { code: "internal_error", message: "Safe durable failure" } }), + ], { items }); + + expect(html).toContain("2 linked Items"); + expect(html).toContain("Safe durable failure"); + expect(html).toContain("Conversation Items remain visible below."); + expect(html).toContain("1 Item is not associated with an observed Turn yet."); + }); + + it("distinguishes loading, empty, and failed durable state", () => { + expect(render([], { loadState: "loading" })).toContain("Loading every Turn page"); + expect(render([turn("live", "in_progress")], { loadState: "loading" })).toContain("Loading complete Turn history; live observations may already appear."); + expect(render([], { loadState: "ready" })).toContain("No Turns reported yet."); + const failed = render([turn("stale", "completed")], { loadState: "failed", error: "read unavailable" }); + expect(failed).toContain("Couldn’t load Turn history"); + expect(failed).toContain("read unavailable"); + expect(failed).toContain("last observed Turn timeline remains visible"); + }); +}); diff --git a/apps/web/src/features/sessions/turns/TurnTimeline.tsx b/apps/web/src/features/sessions/turns/TurnTimeline.tsx new file mode 100644 index 0000000..8eb80f1 --- /dev/null +++ b/apps/web/src/features/sessions/turns/TurnTimeline.tsx @@ -0,0 +1,234 @@ +import { Activity, Clock3 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import type { + AgentTurn, + SessionItem, + TokenUsage, + TurnStatus, +} from "@agents-core-web/agents-client"; + +import { StatusIcon, type StatusKind } from "../../../components/StatusIcon"; + +export type TurnTimelineLoadState = "idle" | "loading" | "ready" | "failed"; + +interface TurnTimelineProps { + turns: AgentTurn[]; + items: SessionItem[]; + sessionUsage: TokenUsage | null; + loadState: TurnTimelineLoadState; + error?: string | null; + nowSeconds?: number; +} + +const usageMetrics = [ + ["Input", ["input_tokens"]], + ["Output", ["output_tokens"]], + ["Total", ["total_tokens"]], + ["Cached", ["input_tokens_details", "cached_tokens"]], + ["Reasoning", ["output_tokens_details", "reasoning_tokens"]], +] as const; + +function metric(value: unknown, path: readonly string[]): number | null { + let current = value; + for (const part of path) { + if (current === null || typeof current !== "object" || Array.isArray(current)) return null; + current = (current as Record)[part]; + } + return typeof current === "number" && Number.isSafeInteger(current) && current >= 0 ? current : null; +} + +function UsageGrid({ + label, + usage, + accessibleLabel = label, + landmark = false, +}: { + label: string; + usage: unknown; + accessibleLabel?: string; + landmark?: boolean; +}) { + const content = ( + <> + {label} +
+ {usageMetrics.map(([name, path]) => { + const value = metric(usage, path); + return
{name}
{value === null ? "Unknown" : value.toLocaleString("en-US")}
; + })} +
+ + ); + return landmark + ?
{content}
+ :
{content}
; +} + +function statusLabel(status: TurnStatus): string { + return status.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase()); +} + +function statusKind(status: TurnStatus): StatusKind { + if (status === "in_progress" || status === "waiting") return "running"; + if (status === "failed") return "failed"; + if (status === "cancelled") return "cancelled"; + if (status === "completed") return "completed"; + return "queued"; +} + +function seconds(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +export function formatTurnTimestamp(value: unknown): string { + const timestamp = seconds(value); + if (timestamp === null) return "Unknown"; + const date = new Date(timestamp * 1_000); + if (Number.isNaN(date.getTime())) return "Unknown"; + return `${date.toISOString().slice(0, 19).replace("T", " ")} UTC`; +} + +export function formatTurnElapsed(startedAt: unknown, endedAt: unknown): string { + const start = seconds(startedAt); + const end = seconds(endedAt); + if (start === null || end === null || end < start) return "Unknown"; + const elapsed = end - start; + const hours = Math.floor(elapsed / 3_600); + const minutes = Math.floor(elapsed % 3_600 / 60); + const remainder = elapsed % 60; + if (hours) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(remainder).padStart(2, "0")}s`; + if (minutes) return `${minutes}m ${String(remainder).padStart(2, "0")}s`; + return `${remainder}s`; +} + +function errorProjection(value: unknown): { code: string; message: string } | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as Record; + if (typeof candidate.code !== "string" || typeof candidate.message !== "string") return null; + return { code: candidate.code || "unknown_error", message: candidate.message || "Unknown" }; +} + +function TurnCard({ turn, itemCount, now }: { turn: AgentTurn; itemCount: number; now: number }) { + const active = turn.status === "in_progress" || turn.status === "waiting"; + const elapsed = active + ? formatTurnElapsed(turn.started_at, now) + : formatTurnElapsed(turn.started_at, turn.completed_at); + const error = errorProjection(turn.error); + const headingId = `turn-${turn.id.replace(/[^a-zA-Z0-9_-]/g, "-")}-heading`; + + return ( +
+
+ +
+ {statusLabel(turn.status)} Turn + {turn.id} +
+ {itemCount} linked {itemCount === 1 ? "Item" : "Items"} +
+ +
+
Started
{formatTurnTimestamp(turn.started_at)}
+
Completed
{formatTurnTimestamp(turn.completed_at)}
+
+
{active ? "Running elapsed" : "Wall clock"}
+
+ {active && elapsed !== "Unknown" ? `Running · ${elapsed}` : elapsed} +
+
+
+ + {error ? ( +
+ Turn error + {error.code} +

{error.message}

+ Conversation Items remain visible below. +
+ ) : null} + + +
+ ); +} + +export function TurnTimeline({ + turns, + items, + sessionUsage, + loadState, + error = null, + nowSeconds, +}: TurnTimelineProps) { + const [clock, setClock] = useState(() => nowSeconds ?? Math.floor(Date.now() / 1_000)); + const active = turns.some((turn) => turn.status === "in_progress" || turn.status === "waiting"); + const counts = useMemo(() => { + const next = new Map(); + for (const item of items) next.set(item.turn_id, (next.get(item.turn_id) ?? 0) + 1); + return next; + }, [items]); + const knownTurnIds = useMemo(() => new Set(turns.map((turn) => turn.id)), [turns]); + const unassociatedItems = items.filter((item) => !knownTurnIds.has(item.turn_id)).length; + + useEffect(() => { + if (nowSeconds !== undefined) { + setClock(nowSeconds); + return; + } + if (!active) return; + setClock(Math.floor(Date.now() / 1_000)); + const interval = window.setInterval(() => setClock(Math.floor(Date.now() / 1_000)), 1_000); + return () => window.clearInterval(interval); + }, [active, nowSeconds]); + + return ( +
+
+
+
+ {loadState === "ready" || turns.length ? `${turns.length} observed ${turns.length === 1 ? "Turn" : "Turns"}` : "Core state"} +
+ + + + {loadState === "loading" ? ( +
+
+ ) : null} + + {loadState === "failed" ? ( +
+ Couldn’t load Turn history +

{error || "The Agent Core Turn read failed."}

+ {turns.length ? The last observed Turn timeline remains visible. : null} +
+ ) : null} + + {loadState === "ready" && !turns.length ? ( +
+
+ ) : null} + + {turns.length ? ( +
    + {turns.map((turn) => ( +
  1. + ))} +
+ ) : null} + + {unassociatedItems ? ( +

+ {unassociatedItems} {unassociatedItems === 1 ? "Item is" : "Items are"} not associated with an observed Turn yet. +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/features/sessions/turns/turn-state.test.ts b/apps/web/src/features/sessions/turns/turn-state.test.ts new file mode 100644 index 0000000..a21bfd3 --- /dev/null +++ b/apps/web/src/features/sessions/turns/turn-state.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentCore, AgentTurn, SessionEvent } from "@agents-core-web/agents-client"; + +import { + listAllTurns, + matchingTurnSnapshot, + mergeDurableAndLiveTurns, + turnReadIsCurrent, + upsertTurn, +} from "./turn-state"; + +function turn(id: string, status: AgentTurn["status"] = "queued", sessionId = "session-1"): AgentTurn { + return { + id, + agent_id: "agent-1", + session_id: sessionId, + object: "agent.session.turn", + status, + created_at: 1, + started_at: null, + completed_at: null, + error: null, + usage: null, + }; +} + +describe("durable Turn loading", () => { + it("loads every page in ascending order and passes one abort signal", async () => { + const signal = new AbortController().signal; + const listTurns = vi.fn(async (_sessionId: string, options?: { after?: string }) => { + if (!options?.after) return { data: [turn("turn-1")], has_more: true, last_id: "turn-1" }; + if (options.after === "turn-1") return { data: [turn("turn-2", "waiting")], has_more: true }; + return { data: [turn("turn-3", "completed")], has_more: false }; + }); + + await expect(listAllTurns({ listTurns } as unknown as AgentCore, "session-1", signal)).resolves.toEqual([ + turn("turn-1"), + turn("turn-2", "waiting"), + turn("turn-3", "completed"), + ]); + expect(listTurns).toHaveBeenNthCalledWith(1, "session-1", { after: undefined, limit: 100, order: "asc", signal }); + expect(listTurns).toHaveBeenNthCalledWith(2, "session-1", { after: "turn-1", limit: 100, order: "asc", signal }); + expect(listTurns).toHaveBeenNthCalledWith(3, "session-1", { after: "turn-2", limit: 100, order: "asc", signal }); + }); + + it("rejects repeated or cyclic cursors and cross-Session Turn data", async () => { + const repeated = { listTurns: async () => ({ data: [turn("same")], has_more: true, last_id: "same" }) } as unknown as AgentCore; + await expect(listAllTurns(repeated, "session-1")).rejects.toThrow("invalid Turns pagination cursor"); + + const cyclic = { + listTurns: async (_sessionId: string, options?: { after?: string }) => ({ + data: [turn(options?.after === "turn-a" ? "turn-b" : "turn-a")], + has_more: true, + }), + } as unknown as AgentCore; + await expect(listAllTurns(cyclic, "session-1")).rejects.toThrow("invalid Turns pagination cursor"); + + const foreign = { listTurns: async () => ({ data: [turn("foreign", "queued", "session-2")], has_more: false }) } as unknown as AgentCore; + await expect(listAllTurns(foreign, "session-1")).rejects.toThrow("outside the selected Session"); + }); + + it("deduplicates overlapping pages without regressing a terminal Turn", async () => { + const listTurns = vi.fn(async (_sessionId: string, options?: { after?: string }) => options?.after + ? { data: [turn("turn-1", "in_progress"), turn("turn-2")], has_more: false } + : { data: [turn("turn-1", "completed")], has_more: true, last_id: "page-1" }); + + await expect(listAllTurns({ listTurns } as unknown as AgentCore, "session-1")).resolves.toEqual([ + turn("turn-1", "completed"), + turn("turn-2"), + ]); + }); +}); + +describe("Turn live reconciliation", () => { + it("preserves durable ordering and lets terminal live snapshots win", () => { + expect(mergeDurableAndLiveTurns( + [turn("one", "in_progress"), turn("two", "completed")], + [turn("one", "failed"), turn("two", "waiting"), turn("three", "queued")], + )).toEqual([ + turn("one", "failed"), + turn("two", "completed"), + turn("three", "queued"), + ]); + expect(upsertTurn([turn("one", "cancelled")], turn("one", "in_progress"))).toEqual([ + turn("one", "cancelled"), + ]); + }); + + it("accepts only exact scoped lifecycle event-to-status pairs", () => { + const statuses: AgentTurn["status"][] = ["queued", "in_progress", "waiting", "completed", "failed", "cancelled"]; + for (const status of statuses) { + const snapshot = turn(`turn-${status}`, status); + const event = { + type: `agent.session.turn.${status === "queued" ? "created" : status}`, + event_id: `event-${status}`, + session_id: "session-1", + turn_id: snapshot.id, + turn: snapshot, + } as SessionEvent; + expect(matchingTurnSnapshot(event, "session-1")).toEqual(snapshot); + } + expect(matchingTurnSnapshot({ + type: "agent.session.turn.completed", + event_id: "foreign", + session_id: "session-1", + turn_id: "foreign", + turn: turn("foreign", "completed", "session-2"), + } as SessionEvent, "session-1")).toBeNull(); + expect(matchingTurnSnapshot({ + type: "agent.session.turn.paused", + event_id: "unknown", + turn: turn("unknown", "completed"), + } as unknown as SessionEvent, "session-1")).toBeNull(); + expect(matchingTurnSnapshot({ + type: "agent.session.turn.item.done", + event_id: "item-event", + turn_id: "item-turn", + turn: turn("item-turn", "completed"), + } as SessionEvent, "session-1")).toBeNull(); + expect(matchingTurnSnapshot({ + type: "agent.session.turn.completed", + event_id: "mismatched-status", + turn_id: "still-running", + turn: turn("still-running", "in_progress"), + } as SessionEvent, "session-1")).toBeNull(); + }); + + it("fences stale request, Core, and selected-Session continuations", () => { + const read = { coreGeneration: 2, request: 4, sessionId: "session-1" }; + expect(turnReadIsCurrent(read, { ...read, selectedSessionId: "session-1" })).toBe(true); + expect(turnReadIsCurrent(read, { ...read, request: 5, selectedSessionId: "session-1" })).toBe(false); + expect(turnReadIsCurrent(read, { ...read, coreGeneration: 3, selectedSessionId: "session-1" })).toBe(false); + expect(turnReadIsCurrent(read, { ...read, selectedSessionId: "session-2" })).toBe(false); + }); +}); diff --git a/apps/web/src/features/sessions/turns/turn-state.ts b/apps/web/src/features/sessions/turns/turn-state.ts new file mode 100644 index 0000000..6720bbc --- /dev/null +++ b/apps/web/src/features/sessions/turns/turn-state.ts @@ -0,0 +1,120 @@ +import type { + AgentCore, + AgentTurn, + SessionEvent, +} from "@agents-core-web/agents-client"; + +const turnStatuses = new Set([ + "queued", + "in_progress", + "waiting", + "completed", + "failed", + "cancelled", +]); + +const lifecycleEventStatus = new Map([ + ["agent.session.turn.created", "queued"], + ["agent.session.turn.in_progress", "in_progress"], + ["agent.session.turn.waiting", "waiting"], + ["agent.session.turn.completed", "completed"], + ["agent.session.turn.failed", "failed"], + ["agent.session.turn.cancelled", "cancelled"], +]); + +export interface TurnReadScope { + coreGeneration: number; + request: number; + sessionId: string; +} + +export interface CurrentTurnReadScope extends TurnReadScope { + selectedSessionId: string | null; +} + +export function turnReadIsCurrent(read: TurnReadScope, current: CurrentTurnReadScope): boolean { + return read.coreGeneration === current.coreGeneration && + read.request === current.request && + read.sessionId === current.sessionId && + current.selectedSessionId === read.sessionId; +} + +function isTurnForSession(value: unknown, sessionId: string): value is AgentTurn { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const turn = value as Record; + return typeof turn.id === "string" && Boolean(turn.id) && + turn.session_id === sessionId && + turn.object === "agent.session.turn" && + typeof turn.status === "string" && turnStatuses.has(turn.status as AgentTurn["status"]); +} + +/** Reads the complete durable Turn collection in server creation order. */ +export async function listAllTurns( + core: AgentCore, + sessionId: string, + signal?: AbortSignal, +): Promise { + const turns: AgentTurn[] = []; + const indexes = new Map(); + const cursors = new Set(); + let after: string | undefined; + + while (true) { + const page = await core.listTurns(sessionId, { after, limit: 100, order: "asc", signal }); + if (!page || !Array.isArray(page.data) || typeof page.has_more !== "boolean") { + throw new Error("The Agent core returned an invalid Turns page."); + } + for (const value of page.data) { + if (!isTurnForSession(value, sessionId)) { + throw new Error("The Agent core returned a Turn outside the selected Session."); + } + const index = indexes.get(value.id); + if (index === undefined) { + indexes.set(value.id, turns.length); + turns.push(value); + } else { + turns[index] = preferTurn(turns[index] as AgentTurn, value); + } + } + if (!page.has_more) return turns; + + const nextAfter = page.last_id ?? page.data[page.data.length - 1]?.id; + if (!nextAfter || cursors.has(nextAfter)) { + throw new Error("The Agent core returned an invalid Turns pagination cursor."); + } + cursors.add(nextAfter); + after = nextAfter; + } +} + +function isTerminal(status: AgentTurn["status"]): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +function preferTurn(current: AgentTurn, incoming: AgentTurn): AgentTurn { + if (isTerminal(current.status) && !isTerminal(incoming.status)) return current; + return incoming; +} + +export function upsertTurn(current: AgentTurn[], incoming: AgentTurn): AgentTurn[] { + const index = current.findIndex((turn) => turn.id === incoming.id); + if (index < 0) return [...current, incoming]; + const preferred = preferTurn(current[index] as AgentTurn, incoming); + if (preferred === current[index]) return current; + return current.map((turn, candidate) => candidate === index ? preferred : turn); +} + +/** Durable order stays authoritative while a newer live snapshot wins safely. */ +export function mergeDurableAndLiveTurns(durable: AgentTurn[], live: AgentTurn[]): AgentTurn[] { + return live.reduce(upsertTurn, durable); +} + +/** Accepts only a scoped, known Turn snapshot carried by a Turn event. */ +export function matchingTurnSnapshot(event: SessionEvent, sessionId: string): AgentTurn | null { + const type = typeof event.type === "string" ? event.type : ""; + const expectedStatus = lifecycleEventStatus.get(type); + if (!expectedStatus || !isTurnForSession(event.turn, sessionId) || event.turn.status !== expectedStatus) return null; + if (event.session_id && event.session_id !== sessionId) return null; + if (event.turn_id && event.turn_id !== event.turn.id) return null; + return event.turn; +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index f1f7b71..bbe149e 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -1697,6 +1697,250 @@ html[data-theme="dark"] .brand-mark-dark { text-align: right; } +.turn-timeline { + width: 100%; + min-width: 0; + margin-bottom: 24px; + padding: 12px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 7px; +} + +.turn-timeline-heading, +.turn-timeline-heading > div, +.turn-card-heading { + display: flex; + min-width: 0; + align-items: center; +} + +.turn-timeline-heading { + justify-content: space-between; + gap: 12px; +} + +.turn-timeline-heading > div { + gap: 7px; +} + +.turn-timeline-heading svg { + flex: 0 0 auto; + color: var(--fg-muted); +} + +.turn-timeline-heading strong, +.turn-card-heading strong, +.turn-usage > strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; + line-height: 17px; +} + +.turn-timeline-heading > span, +.turn-item-count, +.turn-unassociated, +.turn-timeline-state, +.turn-timeline-failure small { + color: var(--sidebar-fg); + font-size: 11px; + line-height: 15px; +} + +.turn-usage { + min-width: 0; + margin-top: 10px; +} + +.turn-usage > strong { + display: block; + margin-bottom: 6px; +} + +.turn-usage dl { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin: 0; + gap: 1px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 5px; +} + +.turn-usage dl > div { + display: flex; + min-width: 0; + flex-direction: column; + padding: 6px 8px; + background: var(--surface); +} + +.turn-usage dt, +.turn-timing dt { + color: var(--sidebar-fg); + font-size: 10px; + font-weight: 500; + line-height: 14px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.turn-usage dd, +.turn-timing dd { + min-width: 0; + margin: 1px 0 0; + color: var(--fg); + font-family: var(--font-mono); + font-size: 11px; + line-height: 16px; + overflow-wrap: anywhere; +} + +.turn-list { + display: flex; + min-width: 0; + flex-direction: column; + margin: 12px 0 0; + padding: 0; + gap: 8px; + list-style: none; +} + +.turn-card { + min-width: 0; + padding: 10px; + background: var(--surface); + border: 1px solid var(--line); + border-left: 2px solid var(--fg-muted); + border-radius: 5px; +} + +.turn-card-in_progress, +.turn-card-waiting { + border-left-color: var(--accent); +} + +.turn-card-completed { + border-left-color: var(--success); +} + +.turn-card-failed { + border-left-color: var(--danger); +} + +.turn-card-cancelled { + border-left-color: var(--warning); +} + +.turn-card-heading { + gap: 8px; +} + +.turn-card-heading > svg { + flex: 0 0 auto; +} + +.turn-card-heading > div { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.turn-card-heading code { + overflow: hidden; + color: var(--sidebar-fg); + font-family: var(--font-mono); + font-size: 10px; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.turn-item-count { + flex: 0 0 auto; + white-space: nowrap; +} + +.turn-timing { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 10px 0 0; + gap: 8px; +} + +.turn-timing > div { + min-width: 0; +} + +.turn-card-in_progress .turn-timing > div:last-child dd, +.turn-card-waiting .turn-timing > div:last-child dd { + color: var(--accent); +} + +.turn-error, +.turn-timeline-failure { + display: grid; + min-width: 0; + margin-top: 10px; + padding: 8px 10px; + gap: 2px 8px; + background: color-mix(in srgb, var(--danger) 7%, transparent); + border-left: 2px solid var(--danger); + border-radius: 4px; +} + +.turn-error strong, +.turn-timeline-failure strong { + color: var(--fg); + font-size: 12px; + font-weight: 500; + line-height: 17px; +} + +.turn-error code { + color: var(--sidebar-fg); + font-family: var(--font-mono); + font-size: 10px; + line-height: 14px; + overflow-wrap: anywhere; +} + +.turn-error p, +.turn-timeline-failure p { + margin: 2px 0 0; + color: var(--fg); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; +} + +.turn-error small { + color: var(--sidebar-fg); + font-size: 11px; + line-height: 15px; +} + +.turn-timeline-state { + display: flex; + align-items: center; + margin-top: 10px; + padding: 9px 10px; + gap: 7px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; +} + +.turn-timeline-state svg { + flex: 0 0 auto; +} + +.turn-unassociated { + margin: 10px 0 0; +} + .message-stack { display: flex; flex-direction: column; @@ -3057,6 +3301,24 @@ button.trace-step-row:hover { .session-page .environment-panel-unavailable > p { text-align: left; } + + .session-page .turn-usage dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .session-page .turn-timing { + grid-template-columns: 1fr; + } + + .session-page .turn-card-heading { + align-items: flex-start; + flex-wrap: wrap; + } + + .session-page .turn-item-count { + width: 100%; + padding-left: 22px; + } } .connection-guide { diff --git a/docs/core-connection.md b/docs/core-connection.md index 0552243..5217dd7 100644 --- a/docs/core-connection.md +++ b/docs/core-connection.md @@ -414,11 +414,11 @@ Agent CRUD and idle Session/history operations work. Chat input intentionally re This exact response occurs before the event body is admitted, so after an intentional HTTP-only start it is safe to enable execution and submit the message. A timeout or -disconnected write is different. The client never retries automatically, but the -current UI does not retain the generated idempotency key across a manual resend. -Refresh the durable Session and Items, and use the client's Turn reads or Core logs -for diagnosis before deciding whether another submission is safe. A caller that -implements a retry must explicitly reuse the original key. +disconnected write is different. The client never retries automatically. The current +UI retains the original payload and idempotency key in memory only for an explicit, +byte-for-byte unchanged manual resend; editing the payload creates a new operation. +Refresh the durable Session, Items, and Turn timeline, and use Core logs when the +public resources are insufficient before deciding whether another submission is safe. ## Credential ownership @@ -501,9 +501,11 @@ In Agents Core Web: 2. Create an Agent using a model known to the selected native runtime. 3. Create an `environment:none` Session and send one text message. 4. Confirm the events POST returns `204` and live lifecycle/output events arrive. -5. Confirm Items contain the user and assistant messages. For independent terminal - Turn proof, use the client's Turn read or the corresponding authenticated API read; - the current UI does not yet display the durable Turn resource. +5. Confirm Items contain the user and assistant messages, then confirm the Turn + timeline shows the terminal Turn snapshot, server wall-clock timestamps, and + reported Usage. The timeline may also advance from an exact live lifecycle event; + reload or use the corresponding authenticated API read for independent durable + proof. 6. Reload and confirm the completed state is recovered from resource reads. A completed Turn plus durable Item readback is execution evidence. Successful Agent @@ -588,11 +590,13 @@ those settings. SSE is live-only and does not replay missed history, including with `Last-Event-ID`. The current UI reconnects for future events, buffers them, then retrieves Session and -Items and, for a current valid `self_hosted` ID, the durable Environment. It applies -that snapshot before newer buffered Environment events and merges Items by stable Item -ID. Late reads/events are fenced across Core, Session, Environment, request/event -revision, stream epoch, selection, and abort boundaries. Turn list/retrieve methods -exist in the client for diagnostics but are not part of the current UI recovery path. +Items and, for a current valid `self_hosted` ID, the durable Environment. It also +starts an independent all-pages Turn read so a slow Turn endpoint cannot delay +conversation recovery. It applies the Session/Items/Environment snapshot before newer +buffered events, merges Items by stable Item ID, and reconciles the eventual Turn list +with newer exact lifecycle snapshots. Late reads/events are fenced across Core, +Session, Environment, request/event revision, stream epoch, selection, and abort +boundaries. Inspect durable state before resending an input whose acceptance is uncertain. ## Stop safely diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 9fac1da..cdf3623 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -34,7 +34,8 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. | Session live events | Yes | Yes | Authenticated `fetch` stream, not `EventSource` | | Input message | Yes | Yes | Opens SSE before submission; uncertain failures retain the in-memory payload/key for an explicit unchanged manual retry only | | Active Turn cancel | Yes | Yes | Submitted as a Session event, not a Turn-create endpoint | -| Turn retrieve/list | Yes | No | Durable diagnostics UI deferred | +| Turn list | Yes | Yes, read-only | Selected Sessions load every page in ascending creation order; no Turn mutation UI | +| Turn retrieve | Yes | No | Reusable client diagnostic method; timeline recovery uses the all-pages list | | Item list/recovery | Yes | Yes | Authoritative recovery after stream loss | | Parsar `apply_patch` Item presentation | Existing function Item fields | Yes, read-only | Parsar extension recognized only for the pinned `changes[].{path,kind,diff}` shape; not an OpenAI standard Item type | | Function result/error | Yes | Yes | Initial UI supports text result/error handoff only for `function_call` actions | @@ -45,7 +46,7 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. | Environment retrieve | Yes | Yes, read-only | For a valid `self_hosted` Session Environment ID, reads the exact public resource fields and durable status; no create/list/update/delete support | | Vaults | Later | No | Credentials must never be stored in browser metadata | | Protocol Subagents / enabled multi-agent | Later | No | Distinct from storing multiple Agent configurations | -| Usage/observability | Response types | No | Missing measurements remain unknown, not zero | +| Usage/observability | Response types | Yes, scoped | Session aggregate and per-Turn token Usage are labelled separately; unavailable measurements remain unknown, not zero | ## Runtime boundary @@ -139,25 +140,33 @@ replay missed work. Every accepted replacement stream follows this order: 1. reconnect the stream and buffer newly arriving events; -2. retrieve the persisted Session and Items; +2. retrieve the persisted Session and every page of Items while independently + starting the all-pages Turn read; 3. after the current Session supplies a valid `self_hosted` ID, retrieve its durable Environment resource; -4. apply the durable Session, Items, and Environment snapshot, then merge buffered - Items and apply newer buffered Environment events; -5. inspect durable state before resubmitting an uncertain write. +4. apply the durable Session, Items, and Environment snapshot without making a slow + or unavailable Turn endpoint block conversation recovery, then release buffered + events; +5. when the independent Turn read settles, apply it only if its Core, request, and + selected Session are still current, merging any newer live Turn snapshot by event + revision; +6. inspect durable state before resubmitting an uncertain write. At replacement-stream acceptance the Web clears the previous live Environment observation before the durable reads. Supported Environment events arriving during those reads are buffered and applied afterward, so a newer live state wins over the earlier durable snapshot. A late stream callback or read is fenced by Core generation, Session ID, Environment ID, Session and Environment request revisions, -stream epoch, event revisions, selection, and abort signal. A missing, unauthorized, +Turn and Item event revisions, stream epoch, selection, and abort signal. A missing, unauthorized, failed, or malformed Environment response clears stale connection claims and renders status as unavailable without blocking Session, Items, or conversation use. The UI never infers connected from health, stream state, Agent/model metadata, installation arrays, or absence of an action. -For a same-ID Item, `completed`, `failed`, or `incomplete` beats `in_progress` +For a same-ID Turn, a `completed`, `failed`, or `cancelled` snapshot does not +regress to a later-arriving non-terminal snapshot. Durable creation order remains +authoritative while a newer live snapshot can advance the same Turn. For a same-ID +Item, `completed`, `failed`, or `incomplete` beats `in_progress` regardless of whether the terminal value came from the durable read or the live buffer. Otherwise, the later live projection wins while durable ordering remains authoritative. Duplicate, out-of-order, unknown, and no-op events do not stop later @@ -166,9 +175,46 @@ generation checks also isolate any late result that could not be cancelled. Terminal Session (`idle`, `requires_action`, `failed`), Turn (`completed`, `failed`, `cancelled`), and live Environment (`ready`, `connected`, `disconnected`, `failed`) -events schedule a coalesced durable Session/Items/Environment refresh. They do not -restart the stream. Turn list/retrieve methods exist for diagnostics, but the current -UI does not invoke them during recovery. +events schedule a coalesced durable Session/Turns/Items/Environment refresh. They do +not restart the stream. The Turn read shares the refresh's abort signal and request +fence but settles independently, so a slow or failed Turn endpoint cannot delay +durable conversation Items or buffered Item events. A Turn-list failure is isolated +to its timeline: the last observed Turns remain visible, and a successful Session/Items +read keeps the existing conversation usable. + +## Turn observability boundary + +- The selected Session loads `GET /agents/sessions/{session_id}/turns` with + `limit=100&order=asc`, follows `has_more` using the last returned Turn ID when the + optional list cursors are absent, and rejects a repeated/cyclic cursor or a Turn + scoped to another Session. Reads are abortable and never retried automatically. +- The timeline presents observed Core snapshots for `queued`, `in_progress`, + `waiting`, `completed`, `failed`, and `cancelled`. Its all-pages read supplies the + authoritative creation order, while a newer exact lifecycle SSE snapshot may + advance a Turn before that read settles. Live projection is limited to exact + `created→queued`, `in_progress→in_progress`, `waiting→waiting`, + `completed→completed`, `failed→failed`, and `cancelled→cancelled` event/status + pairs; Item/output or unknown Turn event names and mismatched snapshots are ignored. + The UI therefore does not label the mixed projection as wholly durable. Items are + counted against their owning Turn only by the protocol `turn_id`; unmatched Items + remain in the conversation and are explicitly reported rather than hidden or + guessed. +- Ended wall-clock duration is calculated only when both server `started_at` and + `completed_at` are valid and ordered. `in_progress` and `waiting` Turns show a + live, explicitly labelled running elapsed value from server `started_at` to the + viewer's current clock. Missing, invalid, or reversed timestamps render as + `Unknown`; Item `duration_ms` values are tool progress and are never summed or + relabelled as Turn wall-clock time. +- A failed Turn's safe public `error.code` and `error.message` render beside its + timeline entry without removing conversation Items. The Web does not expose Core, + daemon, provider, or native-harness diagnostics absent from that resource. +- The Web displays `input_tokens`, `output_tokens`, `total_tokens`, cached input + tokens, and reasoning output tokens. Session aggregate Usage and each Turn's Usage + use separately labelled areas. A null resource, missing nested metric, malformed + value, or unavailable measurement renders as `Unknown`, never inferred zero. +- Turn status, timings, Usage, errors, and tool progress are resource-level + observability. They are not per-Item timing, monetary cost, provider attribution, + or a complete OpenAI Trace waterfall. The client never retries a write automatically. For an input message that fails with a network/response-loss error, HTTP 5xx, or transient 408/409/425/429, the Web keeps diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index 17ff413..d065ed8 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -333,6 +333,28 @@ describe("OpenAIAgentsClient", () => { expect(cancelled).toBe(true); }); + it("lists Turns with encoded Session scope, pagination, ordering, and cancellation", async () => { + const calls: FetchCall[] = []; + const controller = new AbortController(); + const client = new OpenAIAgentsClient({ + baseUrl: "https://core.example.test/v1/", + fetch: recordingFetch(jsonResponse({ data: [], has_more: false }), calls), + }); + + await client.listTurns("session/one", { + after: "turn/previous", + limit: 100, + order: "asc", + signal: controller.signal, + }); + + expect(calls).toHaveLength(1); + expect(String(calls[0]?.input)).toBe("https://core.example.test/v1/agents/sessions/session%2Fone/turns?after=turn%2Fprevious&limit=100&order=asc"); + expect(calls[0]?.init?.method).toBeUndefined(); + expect(calls[0]?.init?.signal).toBe(controller.signal); + expect(new Headers(calls[0]?.init?.headers).get("OpenAI-Beta")).toBe("agents=v1"); + }); + it("submits typed function-result parts with an explicit idempotency key", async () => { const calls: FetchCall[] = []; const client = new OpenAIAgentsClient({ fetch: recordingFetch(new Response(null, { status: 204 }), calls) }); diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index ecdf71e..42433e6 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -249,10 +249,12 @@ export class OpenAIAgentsClient implements AgentCore { }); } - listTurns(sessionId: string, options?: PageOptions): Promise> { + listTurns(sessionId: string, options?: PageOptions & ReadOptions): Promise> { const params = new URLSearchParams(); addPageOptions(params, options); - return this.request(withQuery(`/agents/sessions/${encodeURIComponent(sessionId)}/turns`, params)); + return this.request(withQuery(`/agents/sessions/${encodeURIComponent(sessionId)}/turns`, params), { + signal: options?.signal, + }); } retrieveTurn(sessionId: string, turnId: string): Promise { diff --git a/packages/agents-client/src/fixtures/parsar-0438880a/turn-resources.json b/packages/agents-client/src/fixtures/parsar-0438880a/turn-resources.json new file mode 100644 index 0000000..fd1f2c4 --- /dev/null +++ b/packages/agents-client/src/fixtures/parsar-0438880a/turn-resources.json @@ -0,0 +1,92 @@ +{ + "turns": [ + { + "id": "turn_queued", + "agent_id": "agent_snapshot", + "session_id": "session_01", + "object": "agent.session.turn", + "status": "queued", + "created_at": 1700000000, + "started_at": null, + "completed_at": null, + "error": null, + "usage": null + }, + { + "id": "turn_in_progress", + "agent_id": "agent_snapshot", + "session_id": "session_01", + "object": "agent.session.turn", + "status": "in_progress", + "created_at": 1700000001, + "started_at": 1700000002, + "completed_at": null, + "error": null, + "usage": null + }, + { + "id": "turn_waiting", + "agent_id": "agent_snapshot", + "session_id": "session_01", + "object": "agent.session.turn", + "status": "waiting", + "created_at": 1700000003, + "started_at": 1700000004, + "completed_at": null, + "error": null, + "usage": null + }, + { + "id": "turn_completed", + "agent_id": "agent_snapshot", + "session_id": "session_01", + "object": "agent.session.turn", + "status": "completed", + "created_at": 1700000005, + "started_at": 1700000006, + "completed_at": 1700000068, + "error": null, + "usage": { + "input_tokens": 10, + "output_tokens": 3, + "total_tokens": 13, + "input_tokens_details": { "cached_tokens": 4 }, + "output_tokens_details": { "reasoning_tokens": 2 } + } + }, + { + "id": "turn_failed", + "agent_id": "agent_snapshot", + "session_id": "session_01", + "object": "agent.session.turn", + "status": "failed", + "created_at": 1700000069, + "started_at": 1700000070, + "completed_at": 1700000072, + "error": { + "code": "internal_error", + "message": "The execution could not complete." + }, + "usage": null + }, + { + "id": "turn_cancelled", + "agent_id": "agent_snapshot", + "session_id": "session_01", + "object": "agent.session.turn", + "status": "cancelled", + "created_at": 1700000073, + "started_at": 1700000074, + "completed_at": 1700000075, + "error": null, + "usage": null + } + ], + "session_usage": { + "input_tokens": 20, + "output_tokens": 6, + "total_tokens": 26, + "input_tokens_details": { "cached_tokens": 8 }, + "output_tokens_details": { "reasoning_tokens": 4 } + } +} diff --git a/packages/agents-client/src/protocol-types.test.ts b/packages/agents-client/src/protocol-types.test.ts index cc6c002..b7f0605 100644 --- a/packages/agents-client/src/protocol-types.test.ts +++ b/packages/agents-client/src/protocol-types.test.ts @@ -2,9 +2,11 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import fixture from "./fixtures/parsar-8cc2898c/environment-protocol.json"; import environmentResources from "./fixtures/parsar-0438880a/environment-resources.json"; +import turnResources from "./fixtures/parsar-0438880a/turn-resources.json"; import type { AgentEnvironmentResource, AgentEnvironment, + AgentTurn, AgentSessionEnvironmentEvent, EnvironmentConnectionAction, EnvironmentResourceStatus, @@ -15,6 +17,8 @@ import type { UnknownSessionEvent, UnknownSessionItem, SessionEnvironmentStatus, + TokenUsage, + TurnStatus, } from "./types"; describe("Parsar 8cc2898c Environment protocol types", () => { @@ -118,3 +122,35 @@ describe("Parsar 0438880a Environment retrieve resource", () => { expect(resource.object).toBe("agent.environment"); }); }); + +describe("Parsar 0438880a Turn observability resources", () => { + it("models every durable lifecycle state and nullable measurements", () => { + const turns = turnResources.turns as AgentTurn[]; + + expect(turns.map((turn) => turn.status)).toEqual([ + "queued", + "in_progress", + "waiting", + "completed", + "failed", + "cancelled", + ] satisfies TurnStatus[]); + expect(turns[0]?.started_at).toBeNull(); + expect(turns[0]?.usage).toBeNull(); + expect(turns[3]?.completed_at).toBe(1700000068); + expect(turns[4]?.error).toEqual({ + code: "internal_error", + message: "The execution could not complete.", + }); + }); + + it("keeps aggregate Session Usage distinct from one Turn measurement", () => { + const turns = turnResources.turns as AgentTurn[]; + const aggregate = turnResources.session_usage as TokenUsage; + + expect(turns[3]?.usage?.total_tokens).toBe(13); + expect(aggregate.total_tokens).toBe(26); + expect(aggregate.input_tokens_details.cached_tokens).toBe(8); + expect(aggregate.output_tokens_details.reasoning_tokens).toBe(4); + }); +}); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 6f3ea53..5d944ab 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -406,7 +406,7 @@ export interface AgentCore { updateSession(sessionId: string, metadata: Record | null): Promise; deleteSession(sessionId: string): Promise; listItems(sessionId: string, options?: PageOptions & ReadOptions): Promise>; - listTurns(sessionId: string, options?: PageOptions): Promise>; + listTurns(sessionId: string, options?: PageOptions & ReadOptions): Promise>; retrieveTurn(sessionId: string, turnId: string): Promise; sendMessage(sessionId: string, text: string, idempotencyKey?: string): Promise; cancelTurn(sessionId: string, idempotencyKey?: string): Promise;