From 39ea16fefcdd006430b2b4502e2b7428471d81db Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 4 Jun 2026 15:42:31 +0100 Subject: [PATCH 1/2] fix(webapp): fix AI agent dashboard rendering and snapshot loads Three fixes to the AI agent dashboard surface: - The generation-span inspector and run metrics now read both the v6 (`ai.*`) and v7 (`gen_ai.*`) telemetry attribute shapes, so Messages, Provider, and Tools populate on v7 instead of showing empty/unknown. - The conversation view renders human-in-the-loop tool approvals and denials (awaiting, approved, denied with reason) instead of a blank tool part. - Chat session snapshots resolve through one shared storage-key helper used by both the SDK write and the dashboard read, fixing 404s where the write applied the default object-store protocol but the read fell back to a different store. --- .server-changes/ai-agent-dashboard-fixes.md | 6 + .../runs/v3/agent/AgentMessageView.tsx | 23 +- .../components/runs/v3/agent/AgentView.tsx | 132 +++++++++- .../runs/v3/ai/extractAISpanData.ts | 249 +++++++++++++++++- .../presenters/v3/SessionPresenter.server.ts | 13 +- ...api.v1.sessions.$sessionId.snapshot-url.ts | 14 +- .../services/realtime/chatSnapshot.server.ts | 21 ++ .../v3/utils/enrichCreatableEvents.server.ts | 51 +++- 8 files changed, 461 insertions(+), 48 deletions(-) create mode 100644 .server-changes/ai-agent-dashboard-fixes.md diff --git a/.server-changes/ai-agent-dashboard-fixes.md b/.server-changes/ai-agent-dashboard-fixes.md new file mode 100644 index 00000000000..62c6e811db9 --- /dev/null +++ b/.server-changes/ai-agent-dashboard-fixes.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The AI agent dashboard now renders AI SDK 7 generation spans and metrics, shows human-in-the-loop tool approvals and denials in the conversation view, and loads chat session snapshots from the correct object store. diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index 8d1978e2e3a..12ddaca85dd 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -121,6 +121,22 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { typeof p.output === "string" ? p.output : JSON.stringify(p.output, null, 2); } + // Status label for the tool row. AI SDK 7 HITL adds the + // approval-requested / approval-responded states between input-available + // and output-available, so surface those alongside the existing states. + let resultSummary: string | undefined; + if (p.state === "input-streaming" || p.state === "input-available") { + resultSummary = "calling..."; + } else if (p.state === "approval-requested") { + resultSummary = "awaiting approval"; + } else if (p.state === "approval-responded") { + resultSummary = p.approval?.approved + ? "approved" + : `denied${p.approval?.reason ? `: ${p.approval.reason}` : ""}`; + } else if (p.state === "output-error") { + resultSummary = `error: ${p.errorText ?? "unknown"}`; + } + return ( >(new Map()); + // Buffered HITL resolutions keyed by toolCallId. `addToolApprovalResponse` / + // `addToolOutput` send a slim assistant message on the `.in` channel carrying + // just the resolved tool part; the agent never echoes these on `.out`. We + // stash them here and overlay onto the matching tool part once it exists, so + // a denial/approval lands regardless of which stream arrives first. + const pendingResolutionsRef = useRef>>(new Map()); + // React state snapshot of pendingRef. Only updated via the throttled // `scheduleFlush`. The Map *reference* changes on every flush so React // detects the state update and the downstream `useMemo` recomputes. @@ -290,6 +297,45 @@ function useAgentSessionMessages({ useEffect(() => { const abort = new AbortController(); + // Overlay a buffered HITL resolution (approval/output delivered on `.in`) + // onto the matching tool part. Returns true if a part changed. Safe to call + // repeatedly — after each `.out` tool chunk and whenever a `.in` resolution + // arrives — so the resolution lands regardless of cross-stream ordering. + // Never downgrades a part that already reached a terminal output state (so + // an approved-then-executed tool keeps its `output-available` + output). + const applyToolResolution = (toolCallId: string): boolean => { + const res = pendingResolutionsRef.current.get(toolCallId); + if (!res) return false; + for (const [mid, msg] of pendingRef.current) { + const parts = (msg.parts ?? []) as Array>; + const idx = parts.findIndex((p) => (p as { toolCallId?: string }).toolCallId === toolCallId); + if (idx < 0) continue; + const cur = parts[idx]!; + const terminal = cur.state === "output-available" || cur.state === "output-error"; + const nextState = res.state != null && !terminal ? res.state : cur.state; + const sameApproval = JSON.stringify(cur.approval) === JSON.stringify(res.approval); + if ( + nextState === cur.state && + sameApproval && + res.output === undefined && + res.errorText === undefined + ) { + return false; // already applied + } + const next = parts.slice(); + next[idx] = { + ...cur, + ...(res.approval != null ? { approval: res.approval } : {}), + ...(res.output !== undefined ? { output: res.output } : {}), + ...(res.errorText !== undefined ? { errorText: res.errorText } : {}), + state: nextState, + }; + pendingRef.current.set(mid, { ...msg, parts: next } as UIMessage); + return true; + } + return false; + }; + const encodedSession = encodeURIComponent(sessionId); // Always use the page's own origin to avoid CORS preflight failures // when the configured `apiOrigin` (e.g. `localhost`) differs from the @@ -468,6 +514,15 @@ function useAgentSessionMessages({ pendingRef.current.set(currentMessageId, updated); scheduleFlush.current(); } + + // A `.out` chunk just established/updated a tool part — (re)apply any + // buffered `.in` resolution for it. Covers the `.in`-before-`.out` + // order and corrects a `.out` chunk that downgraded the state (e.g. + // a replayed `tool-approval-request` arriving after the denial). + const outToolCallId = (chunk as { toolCallId?: string }).toolCallId; + if (typeof outToolCallId === "string" && applyToolResolution(outToolCallId)) { + scheduleFlush.current(); + } } } finally { try { @@ -513,19 +568,38 @@ function useAgentSessionMessages({ : payload.message ? [payload.message] : []; - const incomingUsers = candidates.filter( - (m): m is UIMessage => - m != null && (m as { role?: string }).role === "user" && typeof m.id === "string" - ); - if (incomingUsers.length === 0) continue; let changed = false; - for (const msg of incomingUsers) { - if (pendingRef.current.has(msg.id)) continue; - pendingRef.current.set(msg.id, msg); - timestampsRef.current.set(msg.id, value.timestamp); + + // New user turns — merge in (dedupe by id). + for (const m of candidates) { + if (m == null || (m as { role?: string }).role !== "user" || typeof m.id !== "string") { + continue; + } + if (pendingRef.current.has(m.id)) continue; + pendingRef.current.set(m.id, m as UIMessage); + timestampsRef.current.set(m.id, value.timestamp); changed = true; } + + // HITL resolutions ride on `.in` as a slim *assistant* message + // carrying just the resolved tool part (state + approval/output). + // Buffer each by toolCallId and overlay onto the matching tool part + // (which usually arrived on `.out` as `tool-approval-request`). + for (const m of candidates) { + if (m == null || (m as { role?: string }).role !== "assistant") continue; + const parts = (m as { parts?: unknown[] }).parts; + if (!Array.isArray(parts)) continue; + for (const sp of parts) { + const part = sp as Record; + if (typeof part.type !== "string" || !part.type.startsWith("tool-")) continue; + const tcId = (part as { toolCallId?: string }).toolCallId; + if (typeof tcId !== "string") continue; + pendingResolutionsRef.current.set(tcId, part); + if (applyToolResolution(tcId)) changed = true; + } + } + if (changed) scheduleFlush.current(); } } finally { @@ -733,6 +807,46 @@ function applyOutputChunk( ); } + // HITL approval (AI SDK 7) ------------------------------------------------- + // + // v7 added human-in-the-loop tool approval. A `needsApproval` tool emits a + // `tool-approval-request` after its input is available; the tool then waits + // for a `tool-approval-response` (approve/deny) before executing. Mirror AI + // SDK 7's `processUIMessageStream`: the request marks the matching part + // `approval-requested` and records `approval.id`; the response (matched by + // that id) marks it `approval-responded` with the verdict. An approved tool + // then proceeds to `tool-output-available` as usual. + if (type === "tool-approval-request") { + return updatePart(msg, (p) => + (p as { toolCallId?: string }).toolCallId === chunk.toolCallId + ? { + ...p, + state: "approval-requested", + approval: { + id: chunk.approvalId, + ...(chunk.isAutomatic === true ? { isAutomatic: true } : {}), + }, + } + : null + ); + } + if (type === "tool-approval-response") { + return updatePart(msg, (p) => { + const approval = (p as { approval?: { id?: string; isAutomatic?: boolean } }).approval; + if (!approval || approval.id !== chunk.approvalId) return null; + return { + ...p, + state: "approval-responded", + approval: { + ...approval, + id: chunk.approvalId, + approved: chunk.approved, + ...(chunk.reason != null ? { reason: chunk.reason } : {}), + }, + }; + }); + } + // Source / file / step / data parts — pass through as a whole ------------- if (type === "source-url" || type === "source-document" || type === "file") { return withNewPart(msg, chunk as unknown as AnyPart); diff --git a/apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts b/apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts index 73c5418e488..853f75c493f 100644 --- a/apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts +++ b/apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts @@ -26,6 +26,10 @@ export function extractAISpanData( const gRequest = rec(g.request); const gUsage = rec(g.usage); const gOperation = rec(g.operation); + const gProvider = rec(g.provider); + const gInput = rec(g.input); + const gOutput = rec(g.output); + const gTool = rec(g.tool); const aiModel = rec(ai.model); const aiResponse = rec(ai.response); const aiPrompt = rec(ai.prompt); @@ -49,7 +53,17 @@ export function extractAISpanData( ? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10 : undefined); - const toolDefs = parseToolDefinitions(aiPrompt.tools); + // AI SDK 7 moves span emission into `@ai-sdk/otel`, which emits OTel GenAI + // semantic-convention attributes (gen_ai.input/output.messages, gen_ai.provider.name, + // gen_ai.tool.definitions, gen_ai.response.finish_reasons). AI SDK 6 emits the older + // `ai.*` keys (ai.prompt.messages, ai.response.text/toolCalls/finishReason). Customers + // run either major, so detect the shape and read both. + const isV7 = + typeof gInput.messages === "string" || + typeof gOutput.messages === "string" || + typeof gProvider.name === "string"; + + const toolDefs = parseToolDefinitions(isV7 ? gTool.definitions : aiPrompt.tools); const providerMeta = parseProviderMetadata(aiResponse.providerMetadata); const aiTelemetry = rec(ai.telemetry); const telemetryMetaRaw = rec(aiTelemetry.metadata); @@ -63,15 +77,15 @@ export function extractAISpanData( return { model, - provider: str(g.system) ?? "unknown", + provider: str(gProvider.name) ?? str(g.system) ?? str(aiModel.provider) ?? "unknown", operationName: str(gOperation.name) ?? str(ai.operationId) ?? "", responseId: str(gResponse.id) || undefined, - finishReason: str(aiResponse.finishReason), + finishReason: isV7 ? firstFinishReason(gResponse.finish_reasons) : str(aiResponse.finishReason), serviceTier: providerMeta?.serviceTier, resolvedProvider: providerMeta?.resolvedProvider, toolChoice: parseToolChoice(aiPrompt.toolChoice), toolCount: toolDefs?.length, - messageCount: countMessages(aiPrompt.messages), + messageCount: isV7 ? countGenAiMessages(gInput.messages) : countMessages(aiPrompt.messages), telemetryMetadata: telemetryMeta, promptSlug: promptSlug || undefined, promptVersion: promptVersion || undefined, @@ -81,9 +95,14 @@ export function extractAISpanData( inputTokens, outputTokens, totalTokens, - cachedTokens: num(aiUsage.cachedInputTokens) ?? num(gUsage.cache_read_input_tokens), + cachedTokens: + num(aiUsage.cachedInputTokens) ?? + num(gUsage.cache_read_input_tokens) ?? + num(rec(gUsage.cache_read).input_tokens), cacheCreationTokens: - num(aiUsage.cacheCreationInputTokens) ?? num(gUsage.cache_creation_input_tokens), + num(aiUsage.cacheCreationInputTokens) ?? + num(gUsage.cache_creation_input_tokens) ?? + num(rec(gUsage.cache_creation).input_tokens), reasoningTokens: num(aiUsage.reasoningTokens) ?? num(gUsage.reasoning_tokens), tokensPerSecond, msToFirstChunk: num(aiResponse.msToFirstChunk), @@ -91,10 +110,14 @@ export function extractAISpanData( inputCost: num(triggerLlm.input_cost), outputCost: num(triggerLlm.output_cost), totalCost: num(triggerLlm.total_cost), - responseText: str(aiResponse.text) || undefined, + responseText: isV7 + ? extractGenAiAssistantText(gOutput.messages) || undefined + : str(aiResponse.text) || undefined, responseObject: str(aiResponse.object) || undefined, toolDefinitions: toolDefs, - items: buildDisplayItems(aiPrompt.messages, aiResponse.toolCalls, toolDefs), + items: isV7 + ? buildGenAiDisplayItems(g.system_instructions, gInput.messages, gOutput.messages, toolDefs) + : buildDisplayItems(aiPrompt.messages, aiResponse.toolCalls, toolDefs), }; } @@ -450,3 +473,213 @@ function countMessages(raw: unknown): number | undefined { } } +// --------------------------------------------------------------------------- +// AI SDK 7 — @ai-sdk/otel GenAI semantic-convention shape +// --------------------------------------------------------------------------- +// +// v7 moved span emission out of `ai` core into `@ai-sdk/otel`, which emits OTel +// GenAI semantic-convention attributes instead of the v6 `ai.*` keys: +// gen_ai.input.messages JSON string: [{ role, parts: [...] }] +// gen_ai.output.messages JSON string: [{ role:"assistant", parts, finish_reason }] +// gen_ai.system_instructions system prompt (plain string, or [{ type:"text", content }]) +// Message parts (per @ai-sdk/otel's convertMessagePartToSemConv): +// { type:"text" | "reasoning", content } +// { type:"tool_call", id, name, arguments } +// { type:"tool_call_response", id, response } // response already unwrapped from the AI SDK envelope +// Media / approval / custom parts are not surfaced in the display yet. + +type GenAiMessage = { role: string; parts: Record[]; finishReason?: string }; + +function parseGenAiMessages(raw: unknown): GenAiMessage[] | undefined { + if (typeof raw !== "string") return undefined; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return undefined; + return parsed.map((m) => { + const o = rec(m); + return { + role: str(o.role) ?? "user", + parts: Array.isArray(o.parts) ? o.parts.map(rec) : [], + finishReason: str(o.finish_reason), + }; + }); + } catch { + return undefined; + } +} + +/** `gen_ai.response.finish_reasons` arrives as a JSON array string (e.g. `["stop"]`); take the first. */ +function firstFinishReason(raw: unknown): string | undefined { + if (typeof raw !== "string") return undefined; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const first = parsed.find((r) => typeof r === "string"); + return typeof first === "string" ? first : undefined; + } + if (typeof parsed === "string") return parsed || undefined; + } catch { + return raw || undefined; + } + return undefined; +} + +function countGenAiMessages(raw: unknown): number | undefined { + const msgs = parseGenAiMessages(raw); + return msgs && msgs.length > 0 ? msgs.length : undefined; +} + +/** Concatenated text of all `text` parts across the assistant output messages. */ +function extractGenAiAssistantText(outputRaw: unknown): string { + const msgs = parseGenAiMessages(outputRaw); + if (!msgs) return ""; + const texts: string[] = []; + for (const m of msgs) { + if (m.role !== "assistant") continue; + for (const p of m.parts) { + if (p.type === "text" && typeof p.content === "string") texts.push(p.content); + } + } + return texts.join("\n"); +} + +/** Plain text of a message's `text` parts (reasoning parts aren't surfaced yet). */ +function genAiMessageText(parts: Record[]): string { + const texts: string[] = []; + for (const p of parts) { + if (p.type === "text" && typeof p.content === "string") texts.push(p.content); + } + return texts.join("\n"); +} + +/** Parse `gen_ai.system_instructions` (plain string, or a JSON array of `{ type:"text", content }`). */ +function parseSystemInstructions(raw: unknown): string | undefined { + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + const text = parsed + .map((p) => str(rec(p).content)) + .filter((t): t is string => Boolean(t)) + .join("\n"); + return text || undefined; + } + } catch { + // fall through to raw + } + } + return raw || undefined; +} + +/** + * Build display items from the v7 GenAI message attributes: the system prompt, + * the input message history, and the assistant's output for this span. Assistant + * tool_call parts are paired with tool_call_response parts from following tool messages. + */ +function buildGenAiDisplayItems( + systemInstructionsRaw: unknown, + inputMessagesRaw: unknown, + outputMessagesRaw: unknown, + toolDefs?: ToolDefinition[] +): DisplayItem[] | undefined { + const items: DisplayItem[] = []; + + const systemText = parseSystemInstructions(systemInstructionsRaw); + if (systemText) items.push({ type: "system", text: systemText }); + + const messages = [ + ...(parseGenAiMessages(inputMessagesRaw) ?? []), + ...(parseGenAiMessages(outputMessagesRaw) ?? []), + ]; + appendGenAiMessages(items, messages); + + if (toolDefs && toolDefs.length > 0) { + const defsByName = new Map(toolDefs.map((d) => [d.name, d])); + for (const item of items) { + if (item.type === "tool-use") { + for (const tool of item.tools) { + const def = defsByName.get(tool.toolName); + if (def) { + tool.description = def.description; + tool.parametersJson = def.parametersJson; + } + } + } + } + } + + return items.length > 0 ? items : undefined; +} + +function appendGenAiMessages(items: DisplayItem[], messages: GenAiMessage[]): void { + let i = 0; + while (i < messages.length) { + const msg = messages[i]; + + if (msg.role === "system") { + const text = genAiMessageText(msg.parts); + if (text) items.push({ type: "system", text }); + i++; + continue; + } + + if (msg.role === "user") { + const text = genAiMessageText(msg.parts); + if (text) items.push({ type: "user", text }); + i++; + continue; + } + + if (msg.role === "assistant") { + const text = genAiMessageText(msg.parts); + if (text) items.push({ type: "assistant", text }); + + const toolCalls = msg.parts.filter((p) => p.type === "tool_call"); + if (toolCalls.length > 0) { + // Collect tool_call_response parts from the tool messages that follow. + const responsesById = new Map(); + let j = i + 1; + while (j < messages.length && messages[j].role === "tool") { + for (const p of messages[j].parts) { + if (p.type === "tool_call_response") { + const id = str(p.id); + if (id) responsesById.set(id, p.response); + } + } + j++; + } + + const tools: ToolUse[] = toolCalls.map((tc) => { + const id = str(tc.id) ?? ""; + let resultSummary: string | undefined; + let resultOutput: string | undefined; + if (id && responsesById.has(id)) { + const summarized = summarizeToolOutput(responsesById.get(id)); + resultSummary = summarized.summary; + resultOutput = summarized.formattedOutput; + } + return { + toolCallId: id, + toolName: str(tc.name) ?? "", + inputJson: JSON.stringify(tc.arguments ?? {}, null, 2), + resultSummary, + resultOutput, + }; + }); + + items.push({ type: "tool-use", tools }); + i = j; + continue; + } + + i++; + continue; + } + + // tool-role messages are consumed via the assistant pairing above; skip stragglers. + i++; + } +} + diff --git a/apps/webapp/app/presenters/v3/SessionPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts index 4d75abb85b5..c63f9e39a2a 100644 --- a/apps/webapp/app/presenters/v3/SessionPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts @@ -1,8 +1,8 @@ import { type Span } from "@opentelemetry/api"; -import { chatSnapshotKeySuffix } from "@trigger.dev/core/v3"; import { type PrismaClientOrTransaction } from "@trigger.dev/database"; import { env } from "~/env.server"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; +import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server"; import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; import { logger } from "~/services/logger.server"; import { generatePresignedUrl } from "~/v3/objectStore.server"; @@ -132,10 +132,11 @@ export class SessionPresenter { // and the dashboard falls back to seq=0 SSE (which, post-trim, // shows only the most recent turn — accepted, those customers // have their own DB-backed dashboards). - // The agent writes snapshots keyed on the session's friendlyId (the - // `session_*` form), which matches what the SDK's `chat.agent` payload - // carries as `sessionId`. Use the same key shape here so the dashboard - // hits the same S3 object. + // Resolve the snapshot key via the SAME helper the SDK write + boot read + // use (`chatSnapshotStorageKey`), so the dashboard GET hits the exact + // object (and object store) the snapshot was written to. Recomputing a + // bare key here was the bug: an unqualified key reads the base store while + // the write applied OBJECT_STORE_DEFAULT_PROTOCOL, so they could diverge. let snapshotPresignedUrl: string | undefined; try { const signed = await startActiveSpan( @@ -144,7 +145,7 @@ export class SessionPresenter { generatePresignedUrl( projectExternalRef, environmentSlug, - chatSnapshotKeySuffix(session.friendlyId), + chatSnapshotStorageKey(session), "GET" ) ); diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts index 537845d8b41..fc0335847bb 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -1,7 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server"; +import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server"; import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; import { createActionApiRoute, @@ -13,14 +13,6 @@ const ParamsSchema = z.object({ sessionId: z.string(), }); -// `chatSnapshotStoragePath` is stamped on every new Session at row creation -// (see api.v1.sessions.ts). The fallback handles sessions created before -// the column existed — read against the currently-configured default -// protocol and compute the same path the SDK uploaded under. -function snapshotKey(session: { friendlyId: string; chatSnapshotStoragePath: string | null }) { - return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId); -} - const routeConfig = { params: ParamsSchema, allowJWT: true, @@ -39,7 +31,7 @@ export const { action } = createActionApiRoute( const signed = await generatePresignedUrl( authentication.environment.project.externalRef, authentication.environment.slug, - snapshotKey(session), + chatSnapshotStorageKey(session), "PUT" ); if (!signed.success) { @@ -58,7 +50,7 @@ export const loader = createLoaderApiRoute(routeConfig, async ({ authentication, const signed = await generatePresignedUrl( authentication.environment.project.externalRef, authentication.environment.slug, - snapshotKey(session), + chatSnapshotStorageKey(session), "GET" ); if (!signed.success) { diff --git a/apps/webapp/app/services/realtime/chatSnapshot.server.ts b/apps/webapp/app/services/realtime/chatSnapshot.server.ts index 83db0d94197..8d0ef537fec 100644 --- a/apps/webapp/app/services/realtime/chatSnapshot.server.ts +++ b/apps/webapp/app/services/realtime/chatSnapshot.server.ts @@ -11,3 +11,24 @@ export function chatSnapshotStoragePathForSession(friendlyId: string): string { const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL; return protocol ? `${protocol}://${path}` : path; } + +/** + * Resolve the storage key/URI a session's chat snapshot is written to and read + * from. Single source of truth shared by every reader/writer so they all hit + * the same object store: + * - the SDK write + boot read (via the `snapshot-url` presign route), and + * - the dashboard `SessionPresenter` (Agent/Session view). + * + * Prefers `chatSnapshotStoragePath` stamped at row creation (already + * protocol-qualified, e.g. `s3://sessions/{id}/snapshot.json`), falling back to + * recomputing it for sessions created before the column existed. Using a bare, + * unqualified key here is the bug this guards against: the object store applies + * `OBJECT_STORE_DEFAULT_PROTOCOL` to unprefixed keys on PUT but not on GET, so a + * bare key can write to one store and read from another. + */ +export function chatSnapshotStorageKey(session: { + friendlyId: string; + chatSnapshotStoragePath: string | null; +}): string { + return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId); +} diff --git a/apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts b/apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts index 64382010496..94e1539bb44 100644 --- a/apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts +++ b/apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts @@ -157,12 +157,10 @@ function enrichLlmMetrics(event: CreateEventInput): void { } } - // Extract new performance/behavioral fields - const finishReason = typeof props["ai.response.finishReason"] === "string" - ? props["ai.response.finishReason"] - : typeof props["gen_ai.response.finish_reasons"] === "string" - ? props["gen_ai.response.finish_reasons"] - : ""; + // Extract new performance/behavioral fields. + // v6 emits ai.response.finishReason (plain string); v7 (@ai-sdk/otel) emits + // gen_ai.response.finish_reasons as a JSON array string (e.g. `["stop"]`). + const finishReason = readFinishReason(props); const operationId = typeof props["ai.operationId"] === "string" ? props["ai.operationId"] : typeof props["gen_ai.operation.name"] === "string" @@ -181,7 +179,12 @@ function enrichLlmMetrics(event: CreateEventInput): void { // Set _llmMetrics side-channel for dual-write to llm_metrics_v1 const llmMetrics: LlmMetricsData = { - genAiSystem: typeof props["gen_ai.system"] === "string" ? props["gen_ai.system"] : "unknown", + genAiSystem: + typeof props["gen_ai.system"] === "string" + ? props["gen_ai.system"] + : typeof props["gen_ai.provider.name"] === "string" + ? props["gen_ai.provider.name"] + : "unknown", requestModel: typeof props["gen_ai.request.model"] === "string" ? props["gen_ai.request.model"] : responseModel, responseModel, baseResponseModel: modelCatalog[responseModel]?.baseModelName ?? responseModel, @@ -224,8 +227,11 @@ function extractUsageDetails(props: Record): Record): Record): string { + const v6 = props["ai.response.finishReason"]; + if (typeof v6 === "string" && v6) return v6; + + const v7 = props["gen_ai.response.finish_reasons"]; + if (typeof v7 === "string" && v7) { + const trimmed = v7.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + const first = parsed.find((r) => typeof r === "string"); + if (typeof first === "string") return first; + } + } catch { + // fall through to the raw value + } + } + return v7; + } + + return ""; +} + function enrichStyle(event: CreateEventInput) { const baseStyle = event.style ?? {}; const props = event.properties; @@ -250,7 +285,7 @@ function enrichStyle(event: CreateEventInput) { return baseStyle; } - const system = props["gen_ai.system"]; + const system = props["gen_ai.system"] ?? props["gen_ai.provider.name"]; const modelId = props["gen_ai.request.model"] ?? props["ai.model.id"]; const provider = resolveAiProvider( From 0b02f58f62722f24f28092171714b3f15160fb54 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 4 Jun 2026 16:23:44 +0100 Subject: [PATCH 2/2] fix(webapp): render output-denied as a terminal HITL state A denied human-in-the-loop tool can land in the terminal `output-denied` state, not just `approval-responded`. The conversation view now shows the denial label for `output-denied`, and the buffered `.in` resolution overlay treats `output-denied` as terminal so a later replay cannot overwrite it, matching the SDK authoritative-state semantics. --- .../webapp/app/components/runs/v3/agent/AgentMessageView.tsx | 2 +- apps/webapp/app/components/runs/v3/agent/AgentView.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index 12ddaca85dd..6d3365752a6 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -129,7 +129,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { resultSummary = "calling..."; } else if (p.state === "approval-requested") { resultSummary = "awaiting approval"; - } else if (p.state === "approval-responded") { + } else if (p.state === "approval-responded" || p.state === "output-denied") { resultSummary = p.approval?.approved ? "approved" : `denied${p.approval?.reason ? `: ${p.approval.reason}` : ""}`; diff --git a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx index 92dfc0fc454..49b34db14c3 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx @@ -311,7 +311,10 @@ function useAgentSessionMessages({ const idx = parts.findIndex((p) => (p as { toolCallId?: string }).toolCallId === toolCallId); if (idx < 0) continue; const cur = parts[idx]!; - const terminal = cur.state === "output-available" || cur.state === "output-error"; + const terminal = + cur.state === "output-available" || + cur.state === "output-error" || + cur.state === "output-denied"; const nextState = res.state != null && !terminal ? res.state : cur.state; const sameApproval = JSON.stringify(cur.approval) === JSON.stringify(res.approval); if (