From 4a1eaee3a77e825e9b76fae9bc255360aefc7315 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 20 Jul 2026 18:05:15 -0700 Subject: [PATCH] [eric] ui-tools: full jsonschema validation server-side (constraint classes like step>=1 slipped the hand walker); dead turns freeze in-progress spinners to pending in chat and pill; model told to end trackers truthfully --- backend/apps/agents/show_ui_mcp_server.py | 31 +++++++++++++++++-- .../src/app/pages/AgentChat/AgentChat.tsx | 2 +- .../pages/AgentChat/tool-ui/ToolUiBubble.tsx | 13 +++++--- .../pages/AgentChat/tool-ui/showUiPayload.ts | 16 ++++++++++ .../app/pages/Dashboard/cards/AgentCard.tsx | 7 +++-- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/backend/apps/agents/show_ui_mcp_server.py b/backend/apps/agents/show_ui_mcp_server.py index 88164f3d..fa487274 100644 --- a/backend/apps/agents/show_ui_mcp_server.py +++ b/backend/apps/agents/show_ui_mcp_server.py @@ -85,7 +85,9 @@ TOOLS = [ "The component renders in place of raw text; still give a one-line text summary after. " "LIVE UPDATES: calling ShowUI again with the SAME component and props.id updates that " "card in place. Use this to advance progress-tracker/plan step statuses AS you complete " - "each step of real work, or to refresh data; never mint a new id for an update." + "each step of real work, or to refresh data; never mint a new id for an update. Before " + "ending your turn, send a final same-id update with truthful terminal statuses; never " + "leave a step marked in-progress for work you are not actually doing." ), "inputSchema": { "type": "object", @@ -133,10 +135,14 @@ def validate(component: str, props: dict) -> str: return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}" # Vendored components: validate against the GENERATED JSON Schema so a bad payload comes back # as a teaching error the model can fix in-turn, instead of a dead render it never hears about. + # jsonschema gives full-constraint parity with the client zod gate (minimum/minLength/minItems + # slipped through the hand walker: question-flow step>=1 rendered server-side, died client-side). entry = GENERATED.get(component) if entry and isinstance(entry.get("schema"), dict): - errors = [] - p_check(props, entry["schema"], "props", errors) + errors = p_full_validate(props, entry["schema"]) + if errors is None: + errors = [] + p_check(props, entry["schema"], "props", errors) if errors: return ( f"{component} payload invalid: " + "; ".join(errors[:4]) @@ -145,6 +151,25 @@ def validate(component: str, props: dict) -> str: return "" +def p_full_validate(props: dict, schema: dict): + """Full JSON Schema validation via jsonschema; None = library unavailable (fallback walker runs).""" + try: + import jsonschema + except ImportError: + return None + try: + validator = jsonschema.Draft202012Validator(schema) + out = [] + for err in sorted(validator.iter_errors(props), key=lambda e: len(e.path)): + where = "props" + "".join(f".{p}" if isinstance(p, str) else f"[{p}]" for p in err.path) + out.append(f"{where}: {err.message[:90]}") + if len(out) >= 6: + break + return out + except Exception: + return None + + def p_type_ok(value, t: str) -> bool: if t == "string": return isinstance(value, str) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index e2077877..998d69e6 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1714,7 +1714,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (isShowUiPair(item)) { return ( - + {compactionChip} ); diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx index 292b55aa..e43acc62 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import Box from '@mui/material/Box'; import ToolCallBubble from '../tool-bubbles/ToolCallBubble'; import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; -import { parseShowUiPayload } from './showUiPayload'; +import { parseShowUiPayload, freezeIfDone } from './showUiPayload'; import ShowUiWidgetView from './ShowUiWidgetView'; interface ToolUiBubbleProps { @@ -10,11 +10,16 @@ interface ToolUiBubbleProps { sessionId: string; isPending: boolean; suppressReveal: boolean; + sessionRunning?: boolean; } /** Renders a ShowUI call as its inline component; any schema mismatch falls back to the plain tool bubble. */ -function ToolUiBubble({ pair, sessionId, isPending, suppressReveal }: ToolUiBubbleProps): React.ReactElement { - const payload = parseShowUiPayload(pair); +function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunning = false }: ToolUiBubbleProps): React.ReactElement { + const rawPayload = parseShowUiPayload(pair); + const payload = useMemo( + () => (rawPayload ? freezeIfDone(rawPayload, sessionRunning) : null), + [rawPayload, sessionRunning], + ); if (!payload) { return ( diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index c3ea58f8..42f4d832 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -181,3 +181,19 @@ function parseShowUiInput(input: unknown): ShowUiPayload | null { return null; } + +// A dead turn must not keep spinners alive: once the agent stops, any step still marked +// in-progress is work that is NOT happening, so it renders as its truthful stalled state. +export function freezeIfDone(payload: ShowUiPayload, running: boolean): ShowUiPayload { + if (running || payload.component !== 'vendored') return payload; + if (payload.name !== 'progress-tracker' && payload.name !== 'plan') return payload; + const steps = payload.props.steps ?? payload.props.todos; + if (!Array.isArray(steps)) return payload; + const liveKey = payload.name === 'plan' ? 'in_progress' : 'in-progress'; + if (!steps.some((s) => (s as { status?: string })?.status === liveKey)) return payload; + const frozen = steps.map((s) => + (s as { status?: string })?.status === liveKey ? { ...(s as object), status: 'pending' } : s, + ); + const key = payload.name === 'plan' ? 'todos' : 'steps'; + return { ...payload, props: { ...payload.props, [key]: frozen } }; +} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index c4d407e6..12faa8d3 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -38,7 +38,7 @@ import WindowControls from './WindowControls'; import { useTiledStyle } from './tileZones'; import AgentNarratorPill from '../desktop/AgentNarratorPill'; import { extractLatestTodos } from '../desktop/agentTodos'; -import { extractLatestShowUi } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import { extractLatestShowUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import { getWebview } from '@/shared/browserRegistry'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar'; @@ -694,7 +694,10 @@ const AgentCard: React.FC = ({ // Desktop-shell narrator pill: a collapsed card with nothing to ask renders as the minimal pill // (live turn label + plan checklist); approvals and drafts keep the full card so their UI has a home. const todos = useMemo(() => extractLatestTodos(session.messages || []), [session.messages]); - const pillArtifact = useMemo(() => extractLatestShowUi(session.messages || []), [session.messages]); + const pillArtifact = useMemo(() => { + const artifact = extractLatestShowUi(session.messages || []); + return artifact ? freezeIfDone(artifact, session.status === 'running') : null; + }, [session.messages, session.status]); const pillMode = !expanded && !hasPending && !isDraft && !tileZone; const pillLabel = session.turn_label?.label || displayChatTitle(session); const pillRunning = session.status === 'running';