Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/ai-agent-dashboard-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 17 additions & 6 deletions apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" || p.state === "output-denied") {
resultSummary = p.approval?.approved
? "approved"
: `denied${p.approval?.reason ? `: ${p.approval.reason}` : ""}`;
} else if (p.state === "output-error") {
resultSummary = `error: ${p.errorText ?? "unknown"}`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<ToolUseRow
key={i}
Expand All @@ -129,12 +145,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
toolName,
inputJson: JSON.stringify(p.input ?? {}, null, 2),
resultOutput,
resultSummary:
p.state === "input-streaming" || p.state === "input-available"
? "calling..."
: p.state === "output-error"
? `error: ${p.errorText ?? "unknown"}`
: undefined,
resultSummary,
subAgent: isSubAgent
? {
parts: p.output.parts,
Expand Down
135 changes: 126 additions & 9 deletions apps/webapp/app/components/runs/v3/agent/AgentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,13 @@ function useAgentSessionMessages({
// outside the UIMessage so React doesn't see it as a renderable prop.
const orchestrationRef = useRef<Map<string, MessageOrchestrationState>>(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<Map<string, Record<string, unknown>>>(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.
Expand Down Expand Up @@ -290,6 +297,48 @@ 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<Record<string, unknown>>;
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" ||
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 (
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;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down Expand Up @@ -468,6 +517,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 {
Expand Down Expand Up @@ -513,19 +571,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<string, unknown>;
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 {
Expand Down Expand Up @@ -733,6 +810,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);
Expand Down
Loading
Loading