[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

This commit is contained in:
ciregenz
2026-07-20 18:05:15 -07:00
parent 96c6c8e2aa
commit 4a1eaee3a7
5 changed files with 59 additions and 10 deletions
+28 -3
View File
@@ -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)
@@ -1714,7 +1714,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (isShowUiPair(item)) {
return (
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<ToolUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} />
<ToolUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} sessionRunning={sessionRunning} />
{compactionChip}
</Box>
);
@@ -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 (
<ToolCallBubble call={pair.call} result={pair.result} isPending={isPending} sessionId={sessionId} suppressReveal={suppressReveal} />
@@ -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 } };
}
@@ -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<Props> = ({
// 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';