From b532abcd4ca36317ad3227b24259aa13c302dcd6 Mon Sep 17 00:00:00 2001 From: earayu Date: Sat, 25 Apr 2026 23:12:46 +0800 Subject: [PATCH 1/4] feat(phase8 #77 D8.4b): FE message-parts renderer (text/tool/source/citation/activity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D8.4b first-cut. Replaces the legacy `AgentTurnCard` + `legacy-snapshot-shim` projection with a renderer that consumes the new `useAgentTurnStream` seam (D8.4a, merge `63a9d522`) directly. Each `AgentMessagePart` is rendered by type; transient `data-activity` is surfaced through a separate inline indicator and never persisted. ## What lands * **NEW** `web/src/components/chat/agent-turn-renderer.tsx` — rebuilds the activity card from the `parts` stream. Keeps the L1 visual baseline (avatar + status badge + activity stream Collapsible + answer Card + debug Collapsible + references Sheet + feedback + copy) so non-technical users see the same affordance. * `` — one entry per `tool-${SafeToolName}` part; state-aware label / icon / debug-expand previews of input + output (or errorText on `output-error`). * `` — transient `data-activity` rendered inline above the activity stream entries; replaced on each new frame and never persisted. * `` / `` — fallback rendering for `data-tool-consent` / `data-elicitation` parts when no interactive slot is provided. **#78 chenyexuan** plugs in concrete components via the new `ConsentSlot` / `ElicitationSlot` props on `AgentTurnRendererProps`. * References sheet now sources from `source-url` / `source-document` parts + `data-citation` content, replacing the old `reference_bundle` artifact path. * `chat-messages.tsx` — `AgentTurnStreamCard` now feeds the hook output directly into `AgentTurnRenderer`; the `projectToLegacySnapshot` projection layer is gone. * **DELETE** `web/src/components/chat/agent-turn-card.tsx` (1279 LOC) — replaced by the new renderer end-to-end. * **DELETE** `web/src/features/agent-runtime/legacy-snapshot-shim.ts` — its only caller (`AgentTurnStreamCard`) no longer needs the projection. `getRunningToolName` / `projectToLegacySnapshot` / `LegacySnapshotShim` are dropped from the feature module re-exports. ## Slot props (the only seam crossing into #78 territory) ```ts type ConsentSlotProps = { chatId: string; turnId: string; part: AgentToolConsentPart; }; type ElicitationSlotProps = { chatId: string; turnId: string; part: AgentElicitationPart; }; type AgentTurnRendererProps = { // ... part stream + status from useAgentTurnStream ConsentSlot?: React.ComponentType; ElicitationSlot?: React.ComponentType; }; ``` #78 chenyexuan implements `consent-prompt.tsx` + `elicitation-form.tsx` that conform to these prop signatures; both call `decideToolConsent` / `submitElicitation` from the agent-runtime API client landed in D8.4a. Optional by design — the placeholder fallback keeps the parts visible even if a slot is not yet wired. ## i18n Adds to `page_chat.json` (zh-CN + en-US): * `activity_stream.tool.title` + `activity_stream.tool.state.{input-streaming|input-available|output-available|output-error}` * `activity_stream.transient.{thinking|searching_knowledge|reading_source|comparing_results|writing_answer|waiting|completed|error}` * `activity_stream.consent.placeholder_{title,state}` * `activity_stream.elicitation.placeholder_state` * `activity_stream.{completed_empty,pending_empty}` * `answer_section.completed_empty` ## Verification * `yarn lint` clean. * `tsc --noEmit` clean for the touched files (the four pre-existing errors in `chat-input.tsx` are unrelated and untouched here). * `yarn dev` boots in 2.8s on port 3012; `GET /`, `/auth/signin`, `/workspace/collections`, `/workspace` all return 200. ## Notes * The EOF-before-terminal regression test follow-up that Weston flagged on D8.4a (msg=b7ae3bfd) is not bundled here — there is no FE test infra in the repo today, and adding `vitest` is its own scope. The behavior is documented at the relevant code paths in `stream-client.ts` + `reducer.ts`; recommend adding a dedicated test-infra PR after `#77/#78` land. * No hook contract changes; `useAgentTurnStream` and the `AgentMessagePart` typed union are exactly as merged in `63a9d522`. Co-Authored-By: Claude Opus 4.7 --- web/src/components/chat/agent-turn-card.tsx | 1279 ----------------- .../components/chat/agent-turn-renderer.tsx | 819 +++++++++++ web/src/components/chat/chat-messages.tsx | 40 +- web/src/features/agent-runtime/index.ts | 6 - .../agent-runtime/legacy-snapshot-shim.ts | 131 -- web/src/i18n/en-US.json | 29 + web/src/i18n/en-US/page_chat.json | 29 + web/src/i18n/zh-CN.json | 29 + web/src/i18n/zh-CN/page_chat.json | 29 + 9 files changed, 951 insertions(+), 1440 deletions(-) delete mode 100644 web/src/components/chat/agent-turn-card.tsx create mode 100644 web/src/components/chat/agent-turn-renderer.tsx delete mode 100644 web/src/features/agent-runtime/legacy-snapshot-shim.ts diff --git a/web/src/components/chat/agent-turn-card.tsx b/web/src/components/chat/agent-turn-card.tsx deleted file mode 100644 index e76ddabfc..000000000 --- a/web/src/components/chat/agent-turn-card.tsx +++ /dev/null @@ -1,1279 +0,0 @@ -'use client'; - -import type { Feedback, Reference } from '@/features/bot/types'; -import { CopyToClipboard } from '@/components/copy-to-clipboard'; -import { Markdown } from '@/components/markdown'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { - Card, - CardContent, -} from '@/components/ui/card'; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from '@/components/ui/sheet'; -import { cn } from '@/lib/utils'; -import { - AlertTriangle, - BookOpen, - Brain, - BrainCircuit, - CheckCircle2, - ChevronRight, - Clock3, - LoaderCircle, - PencilLine, - Search, - Sparkles, -} from 'lucide-react'; -import { useFormatter, useTranslations } from 'next-intl'; -import { useMemo } from 'react'; -import { MessageCollapseContent } from './message-collapse-content'; -import { MessageFeedback } from './message-feedback'; - -export type AgentArtifactEnvelope = { - schema_version: string; - artifact_id: string; - turn_id: string; - artifact_type: string; - summary?: string | null; - payload: Record; - storage_ref?: string | null; - created_at?: string | null; - updated_at?: string | null; -}; - -export type AgentTurnEnvelope = { - schema_version: string; - turn_id: string; - chat_id: string; - user_id: string; - bot_id: string; - request_id: string; - client_idempotency_key: string; - status: string; - input_text: string; - model_profile: Record; - error_code?: string | null; - error_message?: string | null; - answer_artifact_id?: string | null; - reference_bundle_artifact_id?: string | null; - timeline_cursor: number; - started_at?: string | null; - finished_at?: string | null; - created_at?: string | null; - updated_at?: string | null; -}; - -export type AgentTimelineEventEnvelope = { - schema_version: string; - event_id: string; - turn_id: string; - sequence: number; - timestamp: string; - type: string; - technical_type?: string | null; - label?: string | null; - status?: string | null; - actor: 'agent' | 'tool' | 'system'; - data: Record; - user_activity?: UserActivityEnvelope | null; -}; - -export type AgentTurnSnapshot = { - turn: AgentTurnEnvelope; - timeline: AgentTimelineEventEnvelope[]; - artifacts: AgentArtifactEnvelope[]; -}; - -export type ReferenceBundleItem = { - source_type: string; - source_id?: string | null; - title?: string | null; - snippet?: string | null; - score?: number | null; - uri?: string | null; - metadata?: Record; -}; - -export type UserActivityIntent = - | 'thinking' - | 'searching_knowledge' - | 'reading_source' - | 'comparing_results' - | 'writing_answer' - | 'waiting' - | 'completed' - | 'error'; - -export type UserActivityContext = { - source_name?: string | null; - keyword?: string | null; - count?: number | null; - target_type?: 'knowledge_base' | 'document' | 'web' | null; - scope_label?: string | null; -}; - -export type UserActivityEnvelope = { - intent: UserActivityIntent; - title_key: string; - subtitle_key: string; - detail_key?: string | null; - context?: UserActivityContext | null; -}; - -type OrderedTimelineItem = { - key: string; - status: string; - rawType: string; - technicalType?: string | null; - userActivity: UserActivityEnvelope; - timestamp?: string | null; - argsPreview?: string; - resultPreview?: string; - occurrences?: number; -}; - -const terminalStatuses = new Set(['COMPLETED', 'FAILED', 'CANCELLED']); -const terminalTimelineItemStatuses = new Set(['completed', 'failed']); -const knowledgeSearchTools = new Set(['list_collections', 'search_collection']); -const webSearchTools = new Set(['search_web', 'web_search']); -const readingTools = new Set(['read_document', 'web_read']); - -function mapReferenceItem(item: ReferenceBundleItem): Reference { - return { - score: item.score ?? undefined, - text: item.snippet || '', - metadata: { - ...(item.metadata || {}), - title: item.title, - source_type: item.source_type, - source_id: item.source_id, - uri: item.uri, - }, - }; -} - -function extractAnswerText( - snapshot: AgentTurnSnapshot, - streamingAnswer: string, -) { - const answerArtifact = snapshot.artifacts.find( - (artifact) => artifact.artifact_type === 'answer', - ); - const artifactText = - typeof answerArtifact?.payload?.text === 'string' - ? answerArtifact.payload.text - : typeof answerArtifact?.payload?.content === 'string' - ? answerArtifact.payload.content - : ''; - if (artifactText) return artifactText; - if (streamingAnswer) return streamingAnswer; - return ''; -} - -function extractReferences(snapshot: AgentTurnSnapshot): Reference[] { - const referenceArtifact = snapshot.artifacts.find( - (artifact) => artifact.artifact_type === 'reference_bundle', - ); - const items = Array.isArray(referenceArtifact?.payload?.items) - ? (referenceArtifact.payload.items as ReferenceBundleItem[]) - : []; - if (items.length > 0) { - return items.map(mapReferenceItem); - } - return []; -} - -function compactPreview(value: unknown, maxLength = 220) { - if (value == null) return undefined; - - const raw = - typeof value === 'string' - ? value - : (() => { - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } - })(); - - const normalized = raw.trim(); - if (!normalized) return undefined; - if (normalized.length <= maxLength) return normalized; - return `${normalized.slice(0, maxLength).trimEnd()}...`; -} - -function normalizeActivityText(value: unknown, maxLength = 160) { - if (typeof value !== 'string') return undefined; - const normalized = value.trim().replace(/\s+/g, ' '); - if (!normalized) return undefined; - if (normalized.length <= maxLength) return normalized; - return `${normalized.slice(0, maxLength - 3).trimEnd()}...`; -} - -function iterActivityPayloads( - data: Record, -): Record[] { - const payloads: Record[] = [data]; - - for (const key of ['args', 'result']) { - const nested = data[key]; - if (nested && typeof nested === 'object' && !Array.isArray(nested)) { - payloads.push(nested as Record); - } - } - - return payloads; -} - -function extractActivityString( - data: Record, - keys: string[], - maxLength = 160, -) { - for (const payload of iterActivityPayloads(data)) { - for (const key of keys) { - const value = normalizeActivityText(payload[key], maxLength); - if (value) return value; - } - } - return undefined; -} - -function extractActivityCount(data: Record) { - for (const payload of iterActivityPayloads(data)) { - for (const key of ['count', 'total', 'total_count', 'result_count']) { - const value = payload[key]; - if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { - return value; - } - } - - for (const key of ['items', 'results']) { - const items = payload[key]; - if (Array.isArray(items)) { - return items.length; - } - } - } - - return undefined; -} - -function inferToolName(event: AgentTimelineEventEnvelope) { - return ( - extractActivityString(event.data, ['tool_name']) || - normalizeActivityText(event.label) - ); -} - -function inferTargetType( - toolName?: string, -): UserActivityContext['target_type'] | undefined { - if (!toolName) return undefined; - if (knowledgeSearchTools.has(toolName)) return 'knowledge_base'; - if (webSearchTools.has(toolName)) return 'web'; - if (readingTools.has(toolName)) { - return toolName === 'read_document' ? 'document' : 'web'; - } - return undefined; -} - -function buildActivityContext( - data: Record, - toolName?: string, -): UserActivityContext | undefined { - const keyword = extractActivityString(data, [ - 'query', - 'keyword', - 'keywords', - 'search_query', - ]); - const sourceName = extractActivityString( - data, - [ - 'source_name', - 'collection_name', - 'collection_title', - 'document_title', - 'title', - 'name', - ], - 120, - ); - const count = extractActivityCount(data); - const targetType = inferTargetType(toolName); - const scopeLabel = extractActivityString( - data, - ['collection_id', 'document_id', 'url'], - 120, - ); - - if ( - keyword == null && - sourceName == null && - count == null && - targetType == null && - scopeLabel == null - ) { - return undefined; - } - - return { - keyword, - source_name: sourceName, - count, - target_type: targetType, - scope_label: - scopeLabel && scopeLabel !== sourceName ? scopeLabel : undefined, - }; -} - -function getActivityDetailKey( - intent: UserActivityIntent, - context?: UserActivityContext, -) { - if (!context) return undefined; - if (intent === 'searching_knowledge') { - if (context.keyword) return 'activity.searching_knowledge.detail.keyword'; - if (context.source_name) { - return 'activity.searching_knowledge.detail.source_name'; - } - if (context.count != null) { - return 'activity.searching_knowledge.detail.count'; - } - } - if (intent === 'reading_source' && context.source_name) { - return 'activity.reading_source.detail.source_name'; - } - if (intent === 'comparing_results' && context.count != null) { - return 'activity.comparing_results.detail.count'; - } - return undefined; -} - -function createUserActivity( - intent: UserActivityIntent, - context?: UserActivityContext, -): UserActivityEnvelope { - return { - intent, - title_key: `activity.${intent}.title`, - subtitle_key: `activity.${intent}.subtitle`, - detail_key: getActivityDetailKey(intent, context), - context, - }; -} - -function inferActivityIntentFromTool(toolName?: string): UserActivityIntent { - if (!toolName) return 'waiting'; - if (knowledgeSearchTools.has(toolName) || webSearchTools.has(toolName)) { - return 'searching_knowledge'; - } - if (readingTools.has(toolName)) { - return 'reading_source'; - } - return 'waiting'; -} - -function inferUserActivity( - event: AgentTimelineEventEnvelope, -): UserActivityEnvelope | undefined { - if (event.user_activity) return event.user_activity; - - const technicalType = event.technical_type || event.type; - const normalizedStatus = String(event.status || '').toLowerCase(); - const toolName = inferToolName(event); - const context = buildActivityContext(event.data, toolName); - - if (technicalType === 'agent.state.changed') { - if (normalizedStatus === 'thinking') { - return createUserActivity('thinking'); - } - if (normalizedStatus === 'searching') { - return createUserActivity('searching_knowledge', context); - } - if (normalizedStatus === 'calling_tool') { - return createUserActivity(inferActivityIntentFromTool(toolName), context); - } - if (normalizedStatus === 'reading_result') { - return createUserActivity('comparing_results', context); - } - if (normalizedStatus === 'composing' || normalizedStatus === 'streaming') { - return createUserActivity('writing_answer'); - } - if (normalizedStatus === 'done') { - return createUserActivity('completed'); - } - if (normalizedStatus === 'failed' || normalizedStatus === 'error') { - return createUserActivity('error'); - } - return createUserActivity('waiting'); - } - - if ( - technicalType === 'tool.started' || - technicalType === 'external_action.started' - ) { - return createUserActivity(inferActivityIntentFromTool(toolName), context); - } - - if ( - technicalType === 'tool.finished' || - technicalType === 'external_action.finished' - ) { - if (normalizedStatus === 'failed' || normalizedStatus === 'error') { - return createUserActivity('error'); - } - const intent = inferActivityIntentFromTool(toolName); - return createUserActivity( - intent === 'waiting' ? 'comparing_results' : intent, - context, - ); - } - - if (technicalType === 'text.delta') { - return createUserActivity('writing_answer'); - } - - if (technicalType === 'turn.started') { - return createUserActivity('thinking'); - } - if (technicalType === 'turn.completed') { - return createUserActivity('completed'); - } - if ( - technicalType === 'turn.failed' || - technicalType === 'turn.cancelled' - ) { - return createUserActivity('error'); - } - - return createUserActivity('waiting'); -} - -function normalizeTimelineStatus(event: AgentTimelineEventEnvelope) { - const technicalType = event.technical_type || event.type; - const normalized = String(event.status || '').toLowerCase(); - - if (technicalType === 'agent.state.changed') { - if (normalized === 'failed' || normalized === 'error') return 'failed'; - if (normalized === 'done' || normalized === 'completed') return 'completed'; - return 'running'; - } - - if (technicalType === 'turn.started') return 'running'; - if ( - technicalType === 'tool.started' || - technicalType === 'external_action.started' - ) { - return 'running'; - } - if ( - technicalType === 'tool.finished' || - technicalType === 'external_action.finished' - ) { - if (normalized === 'failed' || normalized === 'error') return 'failed'; - return 'completed'; - } - if (technicalType === 'text.delta') return 'running'; - if (technicalType === 'turn.completed') return 'completed'; - if ( - technicalType === 'turn.failed' || - technicalType === 'turn.cancelled' - ) { - return 'failed'; - } - return normalized || 'waiting'; -} - -function serializeDisplayedUserActivity(activity: UserActivityEnvelope) { - return JSON.stringify({ - intent: activity.intent, - title_key: activity.title_key, - subtitle_key: activity.subtitle_key, - context: { - keyword: activity.context?.keyword || null, - source_name: activity.context?.source_name || null, - target_type: activity.context?.target_type || null, - scope_label: activity.context?.scope_label || null, - }, - }); -} - -function mergeUserActivityEnvelope( - previous: UserActivityEnvelope, - next: UserActivityEnvelope, -): UserActivityEnvelope { - return { - ...previous, - ...next, - detail_key: next.detail_key ?? previous.detail_key, - context: { - ...(previous.context || {}), - ...(next.context || {}), - }, - }; -} - -function normalizeDisplayedStepStatus(previousStatus: string, nextStatus: string) { - if (nextStatus === 'failed') return 'failed'; - if (nextStatus === 'completed') { - return previousStatus === 'failed' ? 'failed' : 'completed'; - } - if (nextStatus === 'running') { - if (terminalTimelineItemStatuses.has(previousStatus)) { - return previousStatus; - } - return 'running'; - } - if (nextStatus === 'waiting') { - return previousStatus === 'running' ? 'running' : previousStatus || 'waiting'; - } - return nextStatus; -} - -function shouldDisplayUserActivityInTimeline(activity: UserActivityEnvelope) { - return activity.intent !== 'waiting'; -} - -function findDisplayedStepIndex( - entries: OrderedTimelineItem[], - status: string, - activity: UserActivityEnvelope, -) { - const lastIndex = entries.length - 1; - if (lastIndex < 0) return -1; - - const previous = entries[lastIndex]; - if ( - serializeDisplayedUserActivity(previous.userActivity) !== - serializeDisplayedUserActivity(activity) - ) { - return -1; - } - - if ( - terminalTimelineItemStatuses.has(previous.status) && - previous.status !== status - ) { - return -1; - } - - if ( - terminalTimelineItemStatuses.has(previous.status) && - previous.status === status - ) { - return lastIndex; - } - - if ( - !terminalTimelineItemStatuses.has(previous.status) && - ['waiting', 'running', 'completed', 'failed'].includes(status) - ) { - return lastIndex; - } - - return -1; -} - -function closeTimelineItems( - entries: OrderedTimelineItem[], - turnStatus: string, -): OrderedTimelineItem[] { - const normalizedTurnStatus = turnStatus.toUpperCase(); - - if ( - normalizedTurnStatus !== 'COMPLETED' && - normalizedTurnStatus !== 'FAILED' && - normalizedTurnStatus !== 'CANCELLED' - ) { - return entries; - } - - const lastOpenIndex = entries.findLastIndex( - (entry) => - shouldDisplayUserActivityInTimeline(entry.userActivity) && - (entry.status === 'waiting' || entry.status === 'running'), - ); - - const closedEntries = entries - .map((entry, index) => { - if (!shouldDisplayUserActivityInTimeline(entry.userActivity)) return null; - if (entry.status !== 'waiting' && entry.status !== 'running') { - return entry; - } - - if (normalizedTurnStatus === 'COMPLETED') { - return { - ...entry, - status: 'completed', - }; - } - - return { - ...entry, - status: index === lastOpenIndex ? 'failed' : 'completed', - }; - }) - .filter((entry): entry is OrderedTimelineItem => entry != null); - - return closedEntries; -} - -function buildDisplayedTimelineItems( - timeline: AgentTimelineEventEnvelope[], - turnStatus: string, -): OrderedTimelineItem[] { - const orderedEvents = [...timeline].sort((left, right) => { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - return new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(); - }); - const entries: OrderedTimelineItem[] = []; - - for (const event of orderedEvents) { - const userActivity = inferUserActivity(event); - if (!userActivity) continue; - if (!shouldDisplayUserActivityInTimeline(userActivity)) continue; - - const status = normalizeTimelineStatus(event); - const argsPreview = compactPreview(event.data.args, 180); - const resultPreview = compactPreview(event.data.result, 280); - const mergeableIndex = findDisplayedStepIndex( - entries, - status, - userActivity, - ); - - if (mergeableIndex >= 0) { - const previous = entries[mergeableIndex]; - entries[mergeableIndex] = { - ...previous, - userActivity: mergeUserActivityEnvelope( - previous.userActivity, - userActivity, - ), - status: normalizeDisplayedStepStatus(previous.status, status), - timestamp: event.timestamp, - occurrences: (previous.occurrences || 1) + 1, - argsPreview: previous.argsPreview || argsPreview, - resultPreview: resultPreview || previous.resultPreview, - rawType: event.type, - technicalType: event.technical_type || event.type, - }; - continue; - } - - entries.push({ - key: `${event.sequence}-${event.type}`, - status, - rawType: event.type, - technicalType: event.technical_type || event.type, - userActivity, - timestamp: event.timestamp, - argsPreview, - resultPreview, - occurrences: 1, - }); - } - - return closeTimelineItems(entries, turnStatus); -} - -function getAnswerSectionTitle(status: string, hasAnswerText: boolean) { - if (status === 'FAILED') { - return hasAnswerText - ? 'page_chat.answer_section.failure_details' - : 'page_chat.answer_section.run_failed'; - } - if (status === 'CANCELLED') { - return hasAnswerText - ? 'page_chat.answer_section.cancelled_output' - : 'page_chat.answer_section.run_cancelled'; - } - if (status === 'COMPLETED') { - return 'page_chat.answer_section.final_answer'; - } - return hasAnswerText - ? 'page_chat.answer_section.draft_answer' - : 'page_chat.answer_section.answer'; -} - -function describeEmptyAnswerState(status: string) { - if (status === 'FAILED') { - return 'page_chat.answer_section.failed_empty'; - } - if (status === 'CANCELLED') { - return 'page_chat.answer_section.cancelled_empty'; - } - return 'page_chat.answer_section.pending_empty'; -} - -function getTimelineItemStyles(item: OrderedTimelineItem) { - if (item.userActivity.intent === 'error' || item.status === 'failed') { - return { - icon: 'text-destructive', - title: 'text-destructive', - subtitle: 'text-destructive/75', - badge: 'border-destructive/25 bg-destructive/10 text-destructive', - }; - } - - if (item.userActivity.intent === 'completed' || item.status === 'completed') { - return { - icon: 'text-muted-foreground/70', - title: 'text-foreground/70', - subtitle: 'text-muted-foreground', - badge: 'border-border/60 bg-background text-muted-foreground', - }; - } - - if (item.status === 'running') { - return { - icon: 'text-primary', - title: 'text-foreground', - subtitle: 'text-muted-foreground', - badge: 'border-primary/25 bg-accent-soft text-accent-ink', - }; - } - - return { - icon: 'text-muted-foreground', - title: 'text-foreground/90', - subtitle: 'text-muted-foreground', - badge: 'border-border/60 bg-background text-muted-foreground', - }; -} - -function getTimelineItemIcon(item: OrderedTimelineItem) { - switch (item.userActivity.intent) { - case 'thinking': - return BrainCircuit; - case 'searching_knowledge': - return Search; - case 'reading_source': - return BookOpen; - case 'comparing_results': - return Brain; - case 'writing_answer': - return PencilLine; - case 'completed': - return CheckCircle2; - case 'error': - return AlertTriangle; - case 'waiting': - default: - return Clock3; - } -} - -function getActivityTranslationValues( - context?: UserActivityContext | null, - targetTypeLabel?: string, -) { - if (!context) return undefined; - return { - sourceName: context.source_name || undefined, - keyword: context.keyword || undefined, - count: context.count ?? undefined, - targetType: targetTypeLabel || undefined, - scopeLabel: context.scope_label || undefined, - }; -} - -function getStatusTone(status: string): 'default' | 'secondary' | 'destructive' { - if (status === 'COMPLETED') return 'default'; - if (status === 'FAILED' || status === 'CANCELLED') return 'destructive'; - return 'secondary'; -} - -export const AgentTurnCard = ({ - snapshot, - pending, - streamingAnswer, - feedback, - onFeedback, -}: { - snapshot: AgentTurnSnapshot; - pending: boolean; - streamingAnswer: string; - feedback?: Feedback; - onFeedback: (turnId: string, feedback: Feedback) => void; -}) => { - const t = useTranslations(); - const pageChat = useTranslations('page_chat'); - const format = useFormatter(); - - const translateActivityText = ( - key: string | null | undefined, - values?: Record, - ) => { - if (!key) return undefined; - const message = t( - key as never, - values as never, - ); - return message === key ? undefined : message; - }; - - const translatePageChat = ( - key: string, - values?: Record, - ) => { - const message = pageChat( - key as never, - values as never, - ); - return message === key || message === `page_chat.${key}` - ? undefined - : message; - }; - - const getTurnStatusLabel = (statusKey: string) => { - return ( - translatePageChat(`activity_stream.status.${statusKey}`) || - { - queued: 'Queued', - running: 'Running', - completed: 'Completed', - failed: 'Failed', - cancelled: 'Cancelled', - }[statusKey] || - statusKey - ); - }; - - const answerText = useMemo( - () => extractAnswerText(snapshot, streamingAnswer), - [snapshot, streamingAnswer], - ); - const timelineItems = (() => { - const items = buildDisplayedTimelineItems( - snapshot.timeline, - snapshot.turn.status, - ); - if (!answerText) return items; - - return items.map((item) => { - if ( - item.status === 'failed' && - translateActivityText( - item.userActivity.detail_key || item.userActivity.subtitle_key, - getActivityTranslationValues(item.userActivity.context), - )?.trim() === answerText.trim() - ) { - return { - ...item, - userActivity: { - ...item.userActivity, - detail_key: null, - }, - }; - } - return item; - }); - })(); - const references = useMemo( - () => extractReferences(snapshot), - [snapshot], - ); - - const timestamp = snapshot.turn.finished_at || snapshot.turn.started_at; - const displayStatus = terminalStatuses.has(snapshot.turn.status) - ? snapshot.turn.status - : pending - ? 'RUNNING' - : snapshot.turn.status; - const displayStatusKey = displayStatus.toLowerCase(); - const showAnswerSection = Boolean(answerText) || terminalStatuses.has(displayStatus); - const showReferencesTrigger = - references.length > 0 && - !(displayStatus === 'COMPLETED' && Boolean(answerText)); - - const showHeaderStatus = displayStatus !== 'COMPLETED'; - const traceMetaParts: string[] = []; - if (timelineItems.length > 0) { - traceMetaParts.push( - pageChat('activity_stream.meta.steps', { - count: timelineItems.length, - }), - ); - } - if (references.length > 0) { - traceMetaParts.push( - pageChat('activity_stream.meta.sources', { - count: references.length, - }), - ); - } - const traceMeta = traceMetaParts.join(' · '); - - return ( -
-
-
- {pending && ( - - )} - -
-
-
- {showHeaderStatus && ( -
- - {getTurnStatusLabel(displayStatusKey)} - - {timestamp && ( -
- {format.dateTime(new Date(timestamp), 'medium')} -
- )} -
- )} - - - - - - -
- {timelineItems.length === 0 ? ( -
- {pending - ? pageChat('activity_stream.empty') - : t(describeEmptyAnswerState(displayStatus))} -
- ) : ( - timelineItems.map((item) => { - const Icon = getTimelineItemIcon(item); - const styles = getTimelineItemStyles(item); - const targetTypeLabel = - item.userActivity.context?.target_type - ? pageChat( - `activity_stream.target_type.${item.userActivity.context.target_type}`, - ) - : undefined; - const translationValues = getActivityTranslationValues( - item.userActivity.context, - targetTypeLabel, - ); - const title = translateActivityText( - item.userActivity.title_key, - translationValues, - ); - const subtitle = translateActivityText( - item.userActivity.subtitle_key, - translationValues, - ); - const detail = translateActivityText( - item.userActivity.detail_key, - translationValues, - ); - const hasDebugContent = - !!item.argsPreview || - !!item.resultPreview || - !!item.technicalType; - const isRunning = item.status === 'running'; - - return ( -
-
- -
-
-
- - {title} - - {subtitle && ( - - — {subtitle} - - )} -
- {detail && ( -
- {detail} -
- )} - {hasDebugContent && ( - - - - - -
- {item.technicalType && ( -
-
- {pageChat( - 'activity_stream.debug.technical_type', - )} -
-
- {item.technicalType} -
-
- )} - {item.argsPreview && ( -
-
- {pageChat( - 'activity_stream.debug.command_input', - )} -
-
-                                      {item.argsPreview}
-                                    
-
- )} - {item.resultPreview && ( -
-
- {pageChat( - 'activity_stream.debug.result_summary', - )} -
-
-                                      {item.resultPreview}
-                                    
-
- )} -
-
-
- )} -
-
- ); - }) - )} -
-
-
- - {showAnswerSection && - (displayStatus === 'COMPLETED' ? ( -
- {answerText ? ( - {answerText} - ) : ( -
- {t(describeEmptyAnswerState(displayStatus))} -
- )} -
- ) : ( - - -
- {t(getAnswerSectionTitle(displayStatus, Boolean(answerText)))} -
- {answerText ? ( - {answerText} - ) : pending ? ( -
-
- {t(describeEmptyAnswerState(displayStatus))} -
-
-
-
-
-
-
- ) : ( -
- {t(describeEmptyAnswerState(displayStatus))} -
- )} - - - ))} - - - - - - -
-
-
- {pageChat('activity_stream.debug.turn_id')} -
-
- {snapshot.turn.turn_id} -
-
-
-
- {pageChat('activity_stream.debug.request_id')} -
-
- {snapshot.turn.request_id} -
-
-
-
- {pageChat('activity_stream.debug.status')} -
-
{getTurnStatusLabel(displayStatusKey)}
-
- {snapshot.turn.error_code && ( -
-
- {pageChat('activity_stream.debug.error_code')} -
-
{snapshot.turn.error_code}
-
- )} - {snapshot.turn.error_message && ( -
-
- {pageChat('activity_stream.debug.error_message')} -
-
{snapshot.turn.error_message}
-
- )} -
-
-
- -
- {showReferencesTrigger && ( - - - - - - - {pageChat('references')} - -
- {references.map((reference, index) => ( - -
- {index + 1}.{' '} - {String( - reference.metadata?.title || - reference.metadata?.source_id || - reference.text || - 'Reference', - )} -
- {typeof reference.score === 'number' && ( -
- {reference.score.toFixed(2)} -
- )} -
- } - > - {reference.text || ''} - - ))} -
- - - )} - - - - {answerText && ( - - )} -
-
-
- ); -}; diff --git a/web/src/components/chat/agent-turn-renderer.tsx b/web/src/components/chat/agent-turn-renderer.tsx new file mode 100644 index 000000000..b6c2be3e9 --- /dev/null +++ b/web/src/components/chat/agent-turn-renderer.tsx @@ -0,0 +1,819 @@ +'use client'; + +// Phase 8 D8.4b — message-parts renderer. +// +// Consumes the new `useAgentTurnStream` seam (D8.4a, merge commit +// 63a9d522) directly and renders each `AgentMessagePart` by type. The +// previous `AgentTurnCard` + `legacy-snapshot-shim.ts` projection is +// retired here. +// +// Rendering layout (preserved from L1 design + agent-turn-card visual +// baseline so non-technical users see the same affordance): +// +// ┌─ avatar ────┐ ┌─ status badge + timestamp ────────────────┐ +// │ │ ├─ Activity stream collapsible (top) ───────┤ +// │ │ │ tool calls + transient activity badge │ +// │ │ │ inline ConsentSlot / ElicitationSlot │ +// │ │ │ (interactive bodies are #78 territory)│ +// │ │ ├─ Answer section (markdown text parts) ────┤ +// │ │ ├─ Debug collapsible (turn id / req id) ────┤ +// │ │ └─ References + feedback + copy (bottom) ───┘ +// +// Slot props (the only seam crossing into #78 territory): +// * `ConsentSlot` consumes one `AgentToolConsentPart` and the +// `chatId` / `turnId` so it can call `decideToolConsent(...)` +// from `features/agent-runtime/api.ts`. +// * `ElicitationSlot` consumes one `AgentElicitationPart` similarly. +// +// Both slots are optional: when omitted, the renderer falls back to a +// minimal "awaiting decision" placeholder so the parts stream is +// never silently dropped. #78 will provide concrete slot +// implementations. + +import { CopyToClipboard } from '@/components/copy-to-clipboard'; +import { Markdown } from '@/components/markdown'; +import { + type AgentCitationPart, + type AgentElicitationPart, + type AgentMessagePart, + type AgentSourceDocumentPart, + type AgentSourceUrlPart, + type AgentStreamStatus, + type AgentTextPart, + type AgentToolConsentPart, + type AgentToolPart, + type AgentTurnEnvelope, + type ActivityData, + type CitationLocation, +} from '@/features/agent-runtime'; +import type { Feedback, Reference } from '@/features/bot/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, +} from '@/components/ui/card'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; +import { cn } from '@/lib/utils'; +import { + AlertTriangle, + BookOpen, + Brain, + CheckCircle2, + ChevronRight, + Clock3, + CornerDownRight, + HandCoins, + HelpCircle, + LoaderCircle, + Sparkles, + Wrench, + XCircle, +} from 'lucide-react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { useMemo } from 'react'; +import { MessageCollapseContent } from './message-collapse-content'; +import { MessageFeedback } from './message-feedback'; + +// --------------------------------------------------------------------------- +// Public seam props (consumed by chat-messages.tsx + #78 chenyexuan slot +// implementations). + +export type ConsentSlotProps = { + chatId: string; + turnId: string; + part: AgentToolConsentPart; +}; + +export type ElicitationSlotProps = { + chatId: string; + turnId: string; + part: AgentElicitationPart; +}; + +export type AgentTurnRendererProps = { + chatId: string; + turn: AgentTurnEnvelope; + parts: AgentMessagePart[]; + transientActivity: ActivityData | null; + status: AgentStreamStatus; + errorText: string | null; + feedback?: Feedback; + onFeedback: (turnId: string, feedback: Feedback) => Promise | void; + /** Interactive consent UI; provided by #78 chenyexuan. */ + ConsentSlot?: React.ComponentType; + /** Interactive elicitation UI; provided by #78 chenyexuan. */ + ElicitationSlot?: React.ComponentType; +}; + +// --------------------------------------------------------------------------- + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'cancelled', + 'aborted', +]); + +const STATUS_LABEL_KEY: Record = { + idle: 'queued', + connecting: 'running', + streaming: 'running', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + aborted: 'cancelled', +}; + +const STATUS_BADGE_TONE: Record< + AgentStreamStatus, + 'default' | 'secondary' | 'destructive' +> = { + idle: 'secondary', + connecting: 'secondary', + streaming: 'default', + completed: 'default', + failed: 'destructive', + cancelled: 'destructive', + aborted: 'destructive', +}; + +// --------------------------------------------------------------------------- + +function partitionParts(parts: AgentMessagePart[]) { + const text: AgentTextPart[] = []; + const tool: AgentToolPart[] = []; + const sourceUrl: AgentSourceUrlPart[] = []; + const sourceDoc: AgentSourceDocumentPart[] = []; + const citation: AgentCitationPart[] = []; + const consent: AgentToolConsentPart[] = []; + const elicitation: AgentElicitationPart[] = []; + for (const part of parts) { + if (part.type === 'text') text.push(part); + else if (part.type === 'source-url') sourceUrl.push(part); + else if (part.type === 'source-document') sourceDoc.push(part); + else if (part.type === 'data-citation') citation.push(part); + else if (part.type === 'data-tool-consent') consent.push(part); + else if (part.type === 'data-elicitation') elicitation.push(part); + else if (part.type.startsWith('tool-')) tool.push(part as AgentToolPart); + } + return { text, tool, sourceUrl, sourceDoc, citation, consent, elicitation }; +} + +function joinTextParts(parts: AgentTextPart[]): string { + return parts.map((p) => p.text).filter(Boolean).join('\n\n'); +} + +function citationToReference(part: AgentCitationPart): Reference { + const loc = part.data.location; + return { + text: part.data.cited_text, + metadata: { + title: locationTitle(loc), + uri: 'url' in loc ? (loc.url ?? undefined) : undefined, + source_type: loc.type, + }, + }; +} + +function locationTitle(loc: CitationLocation): string | undefined { + if ('title' in loc && loc.title) return loc.title; + if ('doc_title' in loc && loc.doc_title) return loc.doc_title; + if ('url' in loc && loc.url) return loc.url; + return undefined; +} + +function sourceUrlToReference(part: AgentSourceUrlPart): Reference { + return { + text: part.title || part.url, + metadata: { + title: part.title || undefined, + uri: part.url, + source_type: 'source-url', + }, + }; +} + +function sourceDocToReference(part: AgentSourceDocumentPart): Reference { + return { + text: part.title, + metadata: { + title: part.title, + source_type: 'source-document', + uri: part.mediaType, + }, + }; +} + +function toolDisplayName(part: AgentToolPart): string { + return part.toolName || part.type.replace(/^tool-/, '') || 'tool'; +} + +// --------------------------------------------------------------------------- + +function ToolActivityItem({ part }: { part: AgentToolPart }) { + const pageChat = useTranslations('page_chat'); + const tone = toolStateTone(part.state); + const Icon = toolStateIcon(part.state); + const name = toolDisplayName(part); + const inputPreview = previewJson(part.input, 220); + const outputPreview = part.errorText + ? part.errorText + : previewJson(part.output, 280); + const hasDebug = inputPreview != null || outputPreview != null; + + return ( +
+
+ +
+
+
+ + {pageChat('activity_stream.tool.title', { name })} + + + {pageChat(`activity_stream.tool.state.${part.state}`)} + +
+ {hasDebug && ( + + + + + +
+ {inputPreview && ( +
+
+ {pageChat('activity_stream.debug.command_input')} +
+
+                      {inputPreview}
+                    
+
+ )} + {outputPreview && ( +
+
+ {pageChat('activity_stream.debug.result_summary')} +
+
+                      {outputPreview}
+                    
+
+ )} +
+
+
+ )} +
+
+ ); +} + +function toolStateIcon(state: AgentToolPart['state']) { + switch (state) { + case 'input-streaming': + case 'input-available': + return Wrench; + case 'output-available': + return CheckCircle2; + case 'output-error': + return XCircle; + default: + return Wrench; + } +} + +function toolStateTone(state: AgentToolPart['state']) { + if (state === 'output-error') { + return { + icon: 'text-destructive', + title: 'text-destructive', + subtitle: 'text-destructive/75', + }; + } + if (state === 'output-available') { + return { + icon: 'text-muted-foreground/70', + title: 'text-foreground/80', + subtitle: 'text-muted-foreground', + }; + } + return { + icon: 'text-primary', + title: 'text-foreground', + subtitle: 'text-muted-foreground', + }; +} + +// --------------------------------------------------------------------------- + +const TRANSIENT_INTENTS = [ + 'thinking', + 'searching_knowledge', + 'reading_source', + 'comparing_results', + 'writing_answer', + 'waiting', + 'completed', + 'error', +] as const; + +type TransientIntent = (typeof TRANSIENT_INTENTS)[number]; + +function isTransientIntent(value: unknown): value is TransientIntent { + return ( + typeof value === 'string' && + (TRANSIENT_INTENTS as readonly string[]).includes(value) + ); +} + +function ActivityIndicator({ activity }: { activity: ActivityData | null }) { + const pageChat = useTranslations('page_chat'); + if (!activity) return null; + const rawIntent = activity.activity?.intent || activity.intent; + const intent: TransientIntent = isTransientIntent(rawIntent) + ? rawIntent + : 'thinking'; + const Icon = activityIntentIcon(intent); + const label = pageChat(`activity_stream.transient.${intent}` as const); + return ( +
+ + {label} +
+ ); +} + +function activityIntentIcon(intent: string) { + switch (intent) { + case 'searching_knowledge': + return Sparkles; + case 'reading_source': + return BookOpen; + case 'comparing_results': + return Brain; + case 'writing_answer': + return Sparkles; + case 'completed': + return CheckCircle2; + case 'error': + return AlertTriangle; + default: + return Clock3; + } +} + +// --------------------------------------------------------------------------- + +function ConsentPlaceholder({ part }: { part: AgentToolConsentPart }) { + const pageChat = useTranslations('page_chat'); + return ( +
+ +
+
+ {pageChat('activity_stream.consent.placeholder_title', { + name: part.data.toolName, + })} +
+
+ {pageChat('activity_stream.consent.placeholder_state', { + state: part.data.state, + })} +
+
+
+ ); +} + +function ElicitationPlaceholder({ part }: { part: AgentElicitationPart }) { + const pageChat = useTranslations('page_chat'); + return ( +
+ +
+
{part.data.prompt}
+
+ {pageChat('activity_stream.elicitation.placeholder_state', { + state: part.data.state, + })} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- + +export function AgentTurnRenderer({ + chatId, + turn, + parts, + transientActivity, + status, + errorText, + feedback, + onFeedback, + ConsentSlot, + ElicitationSlot, +}: AgentTurnRendererProps) { + const pageChat = useTranslations('page_chat'); + const format = useFormatter(); + + const grouped = useMemo(() => partitionParts(parts), [parts]); + const answerText = useMemo(() => joinTextParts(grouped.text), [grouped.text]); + const references = useMemo(() => { + const fromUrls = grouped.sourceUrl.map(sourceUrlToReference); + const fromDocs = grouped.sourceDoc.map(sourceDocToReference); + const fromCitations = grouped.citation.map(citationToReference); + return [...fromUrls, ...fromDocs, ...fromCitations]; + }, [grouped.citation, grouped.sourceDoc, grouped.sourceUrl]); + + const pending = !TERMINAL_STATUSES.has(status); + const statusKey = STATUS_LABEL_KEY[status]; + const statusTone = STATUS_BADGE_TONE[status]; + const showHeaderStatus = status !== 'completed'; + + const timestamp = turn.finished_at || turn.started_at; + const showAnswerSection = + Boolean(answerText) || TERMINAL_STATUSES.has(status); + const showReferences = + references.length > 0 && !(status === 'completed' && Boolean(answerText)); + + const traceMetaParts: string[] = []; + if (grouped.tool.length > 0) { + traceMetaParts.push( + pageChat('activity_stream.meta.steps', { count: grouped.tool.length }), + ); + } + if (references.length > 0) { + traceMetaParts.push( + pageChat('activity_stream.meta.sources', { count: references.length }), + ); + } + const traceMeta = traceMetaParts.join(' · '); + + const hasActivity = + grouped.tool.length + + grouped.consent.length + + grouped.elicitation.length > + 0; + + return ( +
+
+
+ {pending && ( + + )} + +
+
+
+ {showHeaderStatus && ( +
+ + {pageChat( + `activity_stream.status.${statusKey}` as never, + {} as never, + )} + + {timestamp && ( +
+ {format.dateTime(new Date(timestamp), 'medium')} +
+ )} +
+ )} + + + + + + + +
+ {!hasActivity && ( +
+ {pending + ? pageChat('activity_stream.empty') + : statusKey === 'completed' + ? pageChat('activity_stream.completed_empty' as const) + : pageChat('activity_stream.pending_empty' as const)} +
+ )} + {grouped.tool.map((part) => ( + + ))} + {grouped.consent.map((part) => + ConsentSlot ? ( + + ) : ( + + ), + )} + {grouped.elicitation.map((part) => + ElicitationSlot ? ( + + ) : ( + + ), + )} +
+
+
+ + {showAnswerSection && + (status === 'completed' ? ( +
+ {answerText ? ( + {answerText} + ) : ( +
+ {pageChat('answer_section.completed_empty' as const)} +
+ )} +
+ ) : ( + + +
+ {pageChat( + answerSectionTitleKey(status, Boolean(answerText)) as never, + {} as never, + )} +
+ {answerText ? ( + {answerText} + ) : pending ? ( +
+
+ {pageChat('answer_section.pending_empty')} +
+
+
+
+
+
+
+ ) : ( +
+ {pageChat(emptyAnswerStateKey(status) as never, {} as never)} +
+ )} + {errorText && ( +
+ + {errorText} +
+ )} + + + ))} + + + + + + +
+
+
+ {pageChat('activity_stream.debug.turn_id')} +
+
{turn.turn_id}
+
+
+
+ {pageChat('activity_stream.debug.request_id')} +
+
{turn.request_id}
+
+
+
+ {pageChat('activity_stream.debug.status')} +
+
{statusKey}
+
+ {turn.error_code && ( +
+
+ {pageChat('activity_stream.debug.error_code')} +
+
{turn.error_code}
+
+ )} + {(errorText || turn.error_message) && ( +
+
+ {pageChat('activity_stream.debug.error_message')} +
+
+ {errorText || turn.error_message} +
+
+ )} +
+
+
+ +
+ {showReferences && ( + + + + + + + {pageChat('references')} + +
+ {references.map((reference, index) => ( + +
+ {index + 1}.{' '} + {String( + reference.metadata?.title || + reference.metadata?.uri || + reference.text || + 'Reference', + )} +
+ {typeof reference.score === 'number' && ( +
+ {reference.score.toFixed(2)} +
+ )} +
+ } + > + {reference.text || ''} + + ))} +
+ + + )} + + + + {answerText && ( + + )} +
+
+
+ ); + +} + +function answerSectionTitleKey( + status: AgentStreamStatus, + hasAnswerText: boolean, +): string { + if (status === 'failed') { + return hasAnswerText + ? 'answer_section.failure_details' + : 'answer_section.run_failed'; + } + if (status === 'cancelled' || status === 'aborted') { + return hasAnswerText + ? 'answer_section.cancelled_output' + : 'answer_section.run_cancelled'; + } + if (status === 'completed') { + return 'answer_section.final_answer'; + } + return hasAnswerText + ? 'answer_section.draft_answer' + : 'answer_section.answer'; +} + +function emptyAnswerStateKey(status: AgentStreamStatus): string { + if (status === 'failed') return 'answer_section.failed_empty'; + if (status === 'cancelled' || status === 'aborted') { + return 'answer_section.cancelled_empty'; + } + return 'answer_section.pending_empty'; +} + +function previewJson(value: unknown, maxLength: number): string | undefined { + if (value == null) return undefined; + let raw: string; + if (typeof value === 'string') { + raw = value; + } else { + try { + raw = JSON.stringify(value, null, 2); + } catch { + raw = String(value); + } + } + const normalized = raw.trim(); + if (!normalized) return undefined; + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, maxLength).trimEnd()}...`; +} diff --git a/web/src/components/chat/chat-messages.tsx b/web/src/components/chat/chat-messages.tsx index 3b31bd1f8..d219c8547 100644 --- a/web/src/components/chat/chat-messages.tsx +++ b/web/src/components/chat/chat-messages.tsx @@ -5,7 +5,6 @@ import { cancelAgentTurn, createAgentTurn, getAgentTurnSnapshot, - projectToLegacySnapshot, useAgentTurnStream, type AgentTurnEnvelope, type AgentTurnSnapshotEnvelope, @@ -22,7 +21,7 @@ import { } from 'react'; import { animateScroll as scroll } from 'react-scroll'; import { toast } from 'sonner'; -import { AgentTurnCard } from './agent-turn-card'; +import { AgentTurnRenderer } from './agent-turn-renderer'; import { ChatInput, ChatInputSubmitParams } from './chat-input'; import { MessagePartsAi } from './message-parts-ai'; import { MessagePartsUser } from './message-parts-user'; @@ -477,11 +476,11 @@ function buildStreamUrl(chatId: string | undefined, turnId: string): string | nu } // --------------------------------------------------------------------------- -// AgentTurnStreamCard — child component that owns one live `useAgentTurnStream` -// hook per turn and projects it back into the legacy `AgentTurnSnapshot` shape -// the existing `AgentTurnCard` renders. The shim lives in -// `features/agent-runtime/legacy-snapshot-shim.ts` and is scheduled for deletion -// as part of #77 (parts renderer). +// AgentTurnStreamCard — child component that owns one live +// `useAgentTurnStream` hook per turn and feeds the result straight into +// the new parts renderer. The hook seam is the contract; #78 plugs +// interactive consent / elicitation slots in via the renderer's +// `ConsentSlot` / `ElicitationSlot` props. // --------------------------------------------------------------------------- function AgentTurnStreamCard({ @@ -498,7 +497,8 @@ function AgentTurnStreamCard({ onTerminal: (turnId: string, finalEnvelope: AgentTurnEnvelope) => void; }) { const { envelope, baselineSnapshot, streamUrl } = liveTurn; - const initialSequence = baselineSnapshot?.turn.timeline_cursor || envelope.timeline_cursor || 0; + const initialSequence = + baselineSnapshot?.turn.timeline_cursor || envelope.timeline_cursor || 0; const stream = useAgentTurnStream({ chatId, @@ -507,15 +507,6 @@ function AgentTurnStreamCard({ initialSequence, }); - const projection = projectToLegacySnapshot({ - turn: envelope, - parts: stream.parts, - status: stream.status, - errorText: stream.errorText, - lastSequence: stream.lastSequence, - baselineSnapshot, - }); - const onTerminalRef = useRef(onTerminal); onTerminalRef.current = onTerminal; @@ -526,18 +517,19 @@ function AgentTurnStreamCard({ stream.status === 'cancelled' || stream.status === 'aborted' ) { - onTerminalRef.current(envelope.turn_id, projection.legacySnapshot.turn); + onTerminalRef.current(envelope.turn_id, envelope); } - // Only fire when status transitions; consumers track the latest envelope - // via projection on every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [stream.status, envelope.turn_id]); return ( - diff --git a/web/src/features/agent-runtime/index.ts b/web/src/features/agent-runtime/index.ts index 14844593f..64bcd1a2f 100644 --- a/web/src/features/agent-runtime/index.ts +++ b/web/src/features/agent-runtime/index.ts @@ -49,9 +49,3 @@ export { type UseAgentTurnStreamInput, type UseAgentTurnStreamResult, } from './use-agent-turn-stream'; - -export { - getRunningToolName, - projectToLegacySnapshot, - type LegacySnapshotShim, -} from './legacy-snapshot-shim'; diff --git a/web/src/features/agent-runtime/legacy-snapshot-shim.ts b/web/src/features/agent-runtime/legacy-snapshot-shim.ts deleted file mode 100644 index c35e3938d..000000000 --- a/web/src/features/agent-runtime/legacy-snapshot-shim.ts +++ /dev/null @@ -1,131 +0,0 @@ -'use client'; - -// TODO(#77 dongdong): delete this file when the parts renderer ships. -// -// Narrow projection from the new `AgentMessagePart[]` seam back into -// the legacy `AgentTurnSnapshot { turn, timeline, artifacts }` shape so -// that `AgentTurnCard` keeps rendering during the D8.4a/4b transition. -// Boundary (per architect lock msg=ed98280c + dongdong msg=f33e9039): -// -// * Coverage: `streamingAnswer` (concatenation of every text-block in -// declaration order, joined with `\n\n`); patched turn `status` / -// `error_message` / `timeline_cursor` from the live stream; -// `timeline` and `artifacts` pass-through from `baselineSnapshot` -// only. -// * NOT covered: rich activity inference, debug previews, reference -// bundle items rendered from the new parts stream, error_summary -// translation, timeline merging from new parts. Those belong to -// the D8.4b renderer (#77). -// -// This shim has zero callers outside `chat-messages.tsx`. Do not -// extend its surface — every new field would otherwise become a soft -// requirement for the renderer rewrite. - -import type { - AgentMessagePart, - AgentStreamStatus, - AgentTextPart, - AgentToolPart, -} from './types'; -import type { - AgentTurnEnvelope, - AgentTurnSnapshotEnvelope, -} from './api'; - -export type LegacySnapshotShim = { - /** - * Streaming answer text — concatenation of every text block in - * declaration order. AI SDK v5 spec allows multiple text blocks per - * turn (`text-start{id}` → `text-delta{id}` → `text-end{id}`); we - * group by id and join with `\n\n` so multi-block streams still - * read naturally in the existing card. - */ - streamingAnswer: string; - pending: boolean; - /** Patched envelope with status reflecting the live stream state. */ - legacySnapshot: AgentTurnSnapshotEnvelope; -}; - -const TERMINAL_STATUSES: ReadonlySet = new Set([ - 'completed', - 'failed', - 'cancelled', - 'aborted', -]); - -const STREAM_TO_LEGACY_STATUS: Record = { - idle: 'QUEUED', - connecting: 'RUNNING', - streaming: 'RUNNING', - completed: 'COMPLETED', - failed: 'FAILED', - cancelled: 'CANCELLED', - aborted: 'CANCELLED', -}; - -export function projectToLegacySnapshot(input: { - turn: AgentTurnEnvelope; - parts: AgentMessagePart[]; - status: AgentStreamStatus; - errorText: string | null; - lastSequence: number; - baselineSnapshot?: AgentTurnSnapshotEnvelope; -}): LegacySnapshotShim { - const streamingAnswer = collectStreamingAnswer(input.parts); - - const liveStatus = - input.status === 'idle' - ? input.turn.status - : STREAM_TO_LEGACY_STATUS[input.status]; - - const turn: AgentTurnEnvelope = { - ...input.turn, - status: liveStatus, - error_message: - input.status === 'failed' - ? input.errorText ?? input.turn.error_message ?? null - : input.turn.error_message ?? null, - timeline_cursor: Math.max( - input.turn.timeline_cursor || 0, - input.lastSequence, - ), - }; - - return { - streamingAnswer, - pending: !TERMINAL_STATUSES.has(input.status), - legacySnapshot: { - turn, - timeline: input.baselineSnapshot?.timeline ?? [], - artifacts: input.baselineSnapshot?.artifacts ?? [], - }, - }; -} - -function collectStreamingAnswer(parts: AgentMessagePart[]): string { - const texts: string[] = []; - for (const part of parts) { - if (part.type === 'text') { - const value = (part as AgentTextPart).text; - if (value) texts.push(value); - } - } - return texts.join('\n\n'); -} - -// Re-exported helper: surface the running tool name (if any) so the -// existing card's loading affordance stays meaningful pre-#77. -export function getRunningToolName(parts: AgentMessagePart[]): string | null { - for (let i = parts.length - 1; i >= 0; i -= 1) { - const part = parts[i]; - if (!part.type.startsWith('tool-')) continue; - const tool = part as AgentToolPart; - if ( - tool.state === 'input-streaming' || - tool.state === 'input-available' - ) { - return tool.toolName || null; - } - } - return null; -} diff --git a/web/src/i18n/en-US.json b/web/src/i18n/en-US.json index 7461aceaa..5747368aa 100644 --- a/web/src/i18n/en-US.json +++ b/web/src/i18n/en-US.json @@ -363,6 +363,8 @@ "activity_stream": { "label": "Agent trace", "empty": "Thinking…", + "completed_empty": "This run did not invoke any tools.", + "pending_empty": "This run ended before any answer was produced.", "repeated": "Repeated {count} times while this step stayed active.", "meta": { "steps": "{count, plural, one {# step} other {# steps}}", @@ -387,6 +389,32 @@ "web": "web", "chat_history": "chat history" }, + "tool": { + "title": "Calling tool: {name}", + "state": { + "input-streaming": "Preparing arguments…", + "input-available": "Arguments ready", + "output-available": "Done", + "output-error": "Failed" + } + }, + "transient": { + "thinking": "Thinking…", + "searching_knowledge": "Searching knowledge…", + "reading_source": "Reading source…", + "comparing_results": "Comparing results…", + "writing_answer": "Writing the answer…", + "waiting": "Waiting…", + "completed": "Done", + "error": "Error encountered" + }, + "consent": { + "placeholder_title": "Tool authorization request: {name}", + "placeholder_state": "State: {state} (awaiting #78 interactive UI)" + }, + "elicitation": { + "placeholder_state": "State: {state} (awaiting #78 interactive UI)" + }, "debug": { "title": "Debug details", "technical_type": "Technical event", @@ -409,6 +437,7 @@ "answer": "Answer", "failed_empty": "This run ended before a final answer was produced.", "cancelled_empty": "This run was cancelled before a final answer was produced.", + "completed_empty": "This run completed but produced no text content.", "pending_empty": "The answer will appear here once the activity stream finishes." }, "feedback": { diff --git a/web/src/i18n/en-US/page_chat.json b/web/src/i18n/en-US/page_chat.json index 45a402802..837819854 100644 --- a/web/src/i18n/en-US/page_chat.json +++ b/web/src/i18n/en-US/page_chat.json @@ -21,6 +21,8 @@ "activity_stream": { "label": "Agent trace", "empty": "Thinking…", + "completed_empty": "This run did not invoke any tools.", + "pending_empty": "This run ended before any answer was produced.", "repeated": "Repeated {count} times while this step stayed active.", "meta": { "steps": "{count, plural, one {# step} other {# steps}}", @@ -45,6 +47,32 @@ "web": "web", "chat_history": "chat history" }, + "tool": { + "title": "Calling tool: {name}", + "state": { + "input-streaming": "Preparing arguments…", + "input-available": "Arguments ready", + "output-available": "Done", + "output-error": "Failed" + } + }, + "transient": { + "thinking": "Thinking…", + "searching_knowledge": "Searching knowledge…", + "reading_source": "Reading source…", + "comparing_results": "Comparing results…", + "writing_answer": "Writing the answer…", + "waiting": "Waiting…", + "completed": "Done", + "error": "Error encountered" + }, + "consent": { + "placeholder_title": "Tool authorization request: {name}", + "placeholder_state": "State: {state} (awaiting #78 interactive UI)" + }, + "elicitation": { + "placeholder_state": "State: {state} (awaiting #78 interactive UI)" + }, "debug": { "title": "Debug details", "technical_type": "Technical event", @@ -68,6 +96,7 @@ "answer": "Answer", "failed_empty": "This run ended before a final answer was produced.", "cancelled_empty": "This run was cancelled before a final answer was produced.", + "completed_empty": "This run completed but produced no text content.", "pending_empty": "The answer will appear here once the activity stream finishes." }, diff --git a/web/src/i18n/zh-CN.json b/web/src/i18n/zh-CN.json index f1a0574f4..95714c532 100644 --- a/web/src/i18n/zh-CN.json +++ b/web/src/i18n/zh-CN.json @@ -363,6 +363,8 @@ "activity_stream": { "label": "推理过程", "empty": "正在思考…", + "completed_empty": "本次运行没有产生工具调用。", + "pending_empty": "本次运行在生成回答前就结束了。", "repeated": "该步骤保持激活期间重复了 {count} 次。", "meta": { "steps": "{count} 步", @@ -387,6 +389,32 @@ "web": "网页", "chat_history": "聊天记录" }, + "tool": { + "title": "调用工具:{name}", + "state": { + "input-streaming": "正在准备参数…", + "input-available": "参数就绪", + "output-available": "已完成", + "output-error": "调用失败" + } + }, + "transient": { + "thinking": "正在思考…", + "searching_knowledge": "正在检索知识…", + "reading_source": "正在阅读资料…", + "comparing_results": "正在比对结果…", + "writing_answer": "正在生成回答…", + "waiting": "等待中…", + "completed": "已完成", + "error": "出现错误" + }, + "consent": { + "placeholder_title": "工具授权请求:{name}", + "placeholder_state": "状态:{state}(等待 #78 交互组件接入)" + }, + "elicitation": { + "placeholder_state": "状态:{state}(等待 #78 交互组件接入)" + }, "debug": { "title": "调试详情", "technical_type": "技术事件", @@ -409,6 +437,7 @@ "answer": "回答", "failed_empty": "本次运行在生成最终回答前就结束了。", "cancelled_empty": "本次运行在生成最终回答前被取消了。", + "completed_empty": "本次运行已完成,但未返回文本内容。", "pending_empty": "活动流结束后,回答会显示在这里。" }, "feedback": { diff --git a/web/src/i18n/zh-CN/page_chat.json b/web/src/i18n/zh-CN/page_chat.json index 0cb6c0b07..fb5afac71 100644 --- a/web/src/i18n/zh-CN/page_chat.json +++ b/web/src/i18n/zh-CN/page_chat.json @@ -21,6 +21,8 @@ "activity_stream": { "label": "推理过程", "empty": "正在思考…", + "completed_empty": "本次运行没有产生工具调用。", + "pending_empty": "本次运行在生成回答前就结束了。", "repeated": "该步骤保持激活期间重复了 {count} 次。", "meta": { "steps": "{count} 步", @@ -45,6 +47,32 @@ "web": "网页", "chat_history": "聊天记录" }, + "tool": { + "title": "调用工具:{name}", + "state": { + "input-streaming": "正在准备参数…", + "input-available": "参数就绪", + "output-available": "已完成", + "output-error": "调用失败" + } + }, + "transient": { + "thinking": "正在思考…", + "searching_knowledge": "正在检索知识…", + "reading_source": "正在阅读资料…", + "comparing_results": "正在比对结果…", + "writing_answer": "正在生成回答…", + "waiting": "等待中…", + "completed": "已完成", + "error": "出现错误" + }, + "consent": { + "placeholder_title": "工具授权请求:{name}", + "placeholder_state": "状态:{state}(等待 #78 交互组件接入)" + }, + "elicitation": { + "placeholder_state": "状态:{state}(等待 #78 交互组件接入)" + }, "debug": { "title": "调试详情", "technical_type": "技术事件", @@ -68,6 +96,7 @@ "answer": "回答", "failed_empty": "本次运行在生成最终回答前就结束了。", "cancelled_empty": "本次运行在生成最终回答前被取消了。", + "completed_empty": "本次运行已完成,但未返回文本内容。", "pending_empty": "活动流结束后,回答会显示在这里。" }, From 2effca4a2038858f5feab1afd5f1158cab127938 Mon Sep 17 00:00:00 2001 From: earayu Date: Sat, 25 Apr 2026 23:27:08 +0800 Subject: [PATCH 2/4] fix(phase8 #77 D8.4b): synthesize parts from snapshot for terminal historical reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses dongdong msg=97336fb9 — terminal historical AI turns reloaded through `seedFromSnapshot()` were rendering as empty `idle` cards because `useAgentTurnStream({ streamUrl: null })` keeps `parts: []` and `status: 'idle'`, and the new renderer no longer reads `baselineSnapshot.timeline / .artifacts` directly. Fix scope: read-only synthesis of `AgentMessagePart[]` from the legacy snapshot's artifacts (answer text → one `text` part; reference bundle items → `source-url` + `data-citation` parts) when the hook is dormant for a terminal turn. Backend status is mapped back to the stream-side enum so the renderer's status branching stays consistent. Files: * **NEW** `web/src/features/agent-runtime/snapshot-fallback.ts` — `synthesizePartsFromSnapshot()` + `mapBackendTurnStatus()` + `isTerminalBackendStatus()` helpers. Read-only, never feeds the live reducer; deletes wholesale once the BE snapshot endpoint returns UIMessages. * `chat-messages.tsx` — `AgentTurnStreamCard` falls back to synthesized parts + mapped status when `streamUrl == null` and the live stream has not produced anything. Live turns are unaffected. * `features/agent-runtime/index.ts` — re-exports the fallback helpers. Tool call timeline is intentionally NOT replayed for historical turns — matches the legacy `agent-turn-card` behaviour, which also did not show tool-call activity stream once the answer artifact had landed. Verified: `yarn lint` clean; `tsc --noEmit` clean for touched files; `yarn dev` boots in 2.6s on port 3013; `GET /`, `/auth/signin`, `/workspace/collections`, `/workspace` all return 200. Co-Authored-By: Claude Opus 4.7 --- web/src/components/chat/chat-messages.tsx | 34 +++- web/src/features/agent-runtime/index.ts | 6 + .../agent-runtime/snapshot-fallback.ts | 171 ++++++++++++++++++ 3 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 web/src/features/agent-runtime/snapshot-fallback.ts diff --git a/web/src/components/chat/chat-messages.tsx b/web/src/components/chat/chat-messages.tsx index d219c8547..8750d8316 100644 --- a/web/src/components/chat/chat-messages.tsx +++ b/web/src/components/chat/chat-messages.tsx @@ -5,7 +5,11 @@ import { cancelAgentTurn, createAgentTurn, getAgentTurnSnapshot, + mapBackendTurnStatus, + synthesizePartsFromSnapshot, useAgentTurnStream, + type AgentMessagePart, + type AgentStreamStatus, type AgentTurnEnvelope, type AgentTurnSnapshotEnvelope, } from '@/features/agent-runtime'; @@ -507,6 +511,30 @@ function AgentTurnStreamCard({ initialSequence, }); + // dongdong msg=97336fb9 — when the hook is dormant for a terminal + // historical turn (`streamUrl: null`, no live frames), synthesize + // parts + status from the snapshot's legacy artifacts so the + // renderer shows the completed answer + references instead of an + // empty `idle` state. Read-only fallback; deletes when the BE + // snapshot endpoint returns UIMessages. + const useFallback = + streamUrl == null && stream.parts.length === 0 && Boolean(baselineSnapshot); + const fallbackParts = useMemo( + () => + useFallback && baselineSnapshot + ? synthesizePartsFromSnapshot(baselineSnapshot) + : [], + [useFallback, baselineSnapshot], + ); + const displayParts = useFallback ? fallbackParts : stream.parts; + const displayStatus: AgentStreamStatus = useFallback + ? mapBackendTurnStatus(envelope.status) + : stream.status; + const displayErrorText = + displayStatus === 'failed' + ? (stream.errorText ?? envelope.error_message ?? null) + : stream.errorText; + const onTerminalRef = useRef(onTerminal); onTerminalRef.current = onTerminal; @@ -526,10 +554,10 @@ function AgentTurnStreamCard({ diff --git a/web/src/features/agent-runtime/index.ts b/web/src/features/agent-runtime/index.ts index 64bcd1a2f..40e42f7d1 100644 --- a/web/src/features/agent-runtime/index.ts +++ b/web/src/features/agent-runtime/index.ts @@ -49,3 +49,9 @@ export { type UseAgentTurnStreamInput, type UseAgentTurnStreamResult, } from './use-agent-turn-stream'; + +export { + isTerminalBackendStatus, + mapBackendTurnStatus, + synthesizePartsFromSnapshot, +} from './snapshot-fallback'; diff --git a/web/src/features/agent-runtime/snapshot-fallback.ts b/web/src/features/agent-runtime/snapshot-fallback.ts new file mode 100644 index 000000000..1f9b6db42 --- /dev/null +++ b/web/src/features/agent-runtime/snapshot-fallback.ts @@ -0,0 +1,171 @@ +'use client'; + +// Reload-path fallback for terminal historical turns. +// +// The agent runtime snapshot endpoint +// (`GET /agent/chats/{cid}/turns/{tid}`) still returns the legacy +// `{turn, timeline, artifacts}` envelope; D8.2 (#74) added at-rest +// UIMessage storage but the read path that the FE calls on reload +// has not yet been migrated to expose UIMessage parts. Until that BE +// change lands, terminal historical turns reload with an empty live +// stream (`streamUrl: null` ⇒ hook never connects ⇒ `parts: []` and +// `status: 'idle'`), which would render as an empty queued card — +// the regression dongdong called out (msg=97336fb9). +// +// Fix: when the hook is dormant for a terminal turn, synthesize a +// minimal `AgentMessagePart[]` from the snapshot's legacy artifacts +// (answer text + reference bundle items) so the renderer shows the +// completed answer + references instead of an empty idle state. +// The synthesis is read-only and never feeds back into the live +// reducer; once the BE snapshot endpoint returns UIMessages, this +// file is a one-line delete. + +import type { AgentMessagePart, AgentStreamStatus } from './types'; +import type { AgentArtifactEnvelope, AgentTurnSnapshotEnvelope } from './api'; + +type ReferenceBundleItem = { + source_id?: string | null; + title?: string | null; + snippet?: string | null; + uri?: string | null; + score?: number | null; + metadata?: Record; +}; + +const ANSWER_ARTIFACT_TYPE = 'answer'; +const REFERENCE_BUNDLE_ARTIFACT_TYPE = 'reference_bundle'; + +function findArtifact( + artifacts: AgentArtifactEnvelope[], + artifactType: string, +): AgentArtifactEnvelope | undefined { + return artifacts.find((a) => a.artifact_type === artifactType); +} + +function extractAnswerText(artifact: AgentArtifactEnvelope): string { + const payload = artifact.payload || {}; + if (typeof payload.text === 'string') return payload.text; + if (typeof payload.content === 'string') return payload.content; + return ''; +} + +function extractReferenceItems( + artifact: AgentArtifactEnvelope, +): ReferenceBundleItem[] { + const items = artifact.payload?.items; + if (!Array.isArray(items)) return []; + return items.filter( + (item): item is ReferenceBundleItem => + typeof item === 'object' && item !== null, + ); +} + +/** + * Build a minimal `AgentMessagePart[]` from a terminal turn's legacy + * snapshot. Currently emits at most: + * * one `text` part (from the `answer` artifact's payload.text / + * .content) + * * one `source-url` per reference bundle item with a usable URL + * * one `data-citation` per reference bundle item with a snippet + * + * Tool call timeline is intentionally NOT replayed — historical tool + * calls would need lifecycle reconstruction the snapshot endpoint + * doesn't provide cheaply. Renderer falls back to an empty activity + * stream for these turns, which matches the legacy `agent-turn-card` + * behaviour (it also did not show tool calls for past turns once the + * answer artifact had landed). + */ +export function synthesizePartsFromSnapshot( + snapshot: AgentTurnSnapshotEnvelope, +): AgentMessagePart[] { + const parts: AgentMessagePart[] = []; + const turnId = snapshot.turn.turn_id; + + const answerArtifact = findArtifact(snapshot.artifacts, ANSWER_ARTIFACT_TYPE); + if (answerArtifact) { + const text = extractAnswerText(answerArtifact); + if (text) { + parts.push({ + type: 'text', + id: turnId, + text, + state: 'done', + }); + } + } + + const referenceArtifact = findArtifact( + snapshot.artifacts, + REFERENCE_BUNDLE_ARTIFACT_TYPE, + ); + if (referenceArtifact) { + const items = extractReferenceItems(referenceArtifact); + items.forEach((item, index) => { + const sourceId = + (item.source_id ? String(item.source_id) : '') || + `${turnId}-ref-${index}`; + const title = item.title ? String(item.title) : undefined; + const url = item.uri ? String(item.uri) : undefined; + if (url) { + parts.push({ + type: 'source-url', + sourceId, + url, + title, + }); + } + const snippet = item.snippet ? String(item.snippet) : ''; + if (snippet) { + parts.push({ + type: 'data-citation', + id: `${sourceId}-citation`, + data: { + cited_text: snippet, + location: { + type: 'url_citation', + url: url ?? '', + title, + }, + }, + }); + } + }); + } + + return parts; +} + +const TERMINAL_BACKEND_STATUSES = new Set([ + 'COMPLETED', + 'FAILED', + 'CANCELLED', +]); + +export function isTerminalBackendStatus(status: string | undefined): boolean { + if (!status) return false; + return TERMINAL_BACKEND_STATUSES.has(status.toUpperCase()); +} + +/** + * Map a backend `AgentTurnEnvelope.status` string back into the + * stream-side `AgentStreamStatus` enum so the renderer's status + * branching (badge, answer section heading, etc.) stays consistent + * with live-stream turns. + */ +export function mapBackendTurnStatus(status: string): AgentStreamStatus { + switch (status.toUpperCase()) { + case 'COMPLETED': + return 'completed'; + case 'FAILED': + return 'failed'; + case 'CANCELLED': + return 'cancelled'; + case 'RUNNING': + case 'QUEUED': + // For non-terminal turns the live stream should be active and + // drive status; this is a defensive fallback only. + return 'streaming'; + default: + return 'idle'; + } +} From b115c47b135006045cbfdca9152c92527887c4de Mon Sep 17 00:00:00 2001 From: earayu Date: Sat, 25 Apr 2026 23:33:02 +0800 Subject: [PATCH 3/4] refine(phase8 #77 D8.4b): pin TODO(#90) on snapshot-fallback + error_summary handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per architect msg=711f8c2f review of the prior `2effca4a` fix: * File header now explicitly references task **#90 (D8.4d)** as the removal trigger — `Bryce` claimed #90 (msg=00230183) to migrate the snapshot endpoint to canonical UIMessage parts, after which this whole module deletes wholesale. * Adds `extractErrorTextFromSnapshot()` covering the `error_summary` artifact, mapping its payload (`message` / `text` / `summary` / artifact-level summary) back into the renderer's `errorText` channel. The wire/at-rest contract treats `error` as a lifecycle marker (status + errorText), not a part, so this stays out of `AgentMessagePart[]`. * `chat-messages.tsx` `AgentTurnStreamCard` chains `extractErrorTextFromSnapshot` ahead of `envelope.error_message` in the fallback path so historical FAILED turns surface the richer artifact text when present. Verified: `yarn lint` clean; `tsc --noEmit` clean for the touched files. Co-Authored-By: Claude Opus 4.7 --- web/src/components/chat/chat-messages.tsx | 13 ++++- web/src/features/agent-runtime/index.ts | 1 + .../agent-runtime/snapshot-fallback.ts | 51 +++++++++++++++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/web/src/components/chat/chat-messages.tsx b/web/src/components/chat/chat-messages.tsx index 8750d8316..b535924da 100644 --- a/web/src/components/chat/chat-messages.tsx +++ b/web/src/components/chat/chat-messages.tsx @@ -4,6 +4,7 @@ import type { ChatDetails, ChatMessage, Feedback } from '@/features/bot/types'; import { cancelAgentTurn, createAgentTurn, + extractErrorTextFromSnapshot, getAgentTurnSnapshot, mapBackendTurnStatus, synthesizePartsFromSnapshot, @@ -530,9 +531,19 @@ function AgentTurnStreamCard({ const displayStatus: AgentStreamStatus = useFallback ? mapBackendTurnStatus(envelope.status) : stream.status; + const fallbackErrorText = useMemo( + () => + useFallback && baselineSnapshot + ? extractErrorTextFromSnapshot(baselineSnapshot) + : null, + [useFallback, baselineSnapshot], + ); const displayErrorText = displayStatus === 'failed' - ? (stream.errorText ?? envelope.error_message ?? null) + ? (stream.errorText ?? + fallbackErrorText ?? + envelope.error_message ?? + null) : stream.errorText; const onTerminalRef = useRef(onTerminal); diff --git a/web/src/features/agent-runtime/index.ts b/web/src/features/agent-runtime/index.ts index 40e42f7d1..0f28e1f47 100644 --- a/web/src/features/agent-runtime/index.ts +++ b/web/src/features/agent-runtime/index.ts @@ -51,6 +51,7 @@ export { } from './use-agent-turn-stream'; export { + extractErrorTextFromSnapshot, isTerminalBackendStatus, mapBackendTurnStatus, synthesizePartsFromSnapshot, diff --git a/web/src/features/agent-runtime/snapshot-fallback.ts b/web/src/features/agent-runtime/snapshot-fallback.ts index 1f9b6db42..3e1b44d1c 100644 --- a/web/src/features/agent-runtime/snapshot-fallback.ts +++ b/web/src/features/agent-runtime/snapshot-fallback.ts @@ -1,24 +1,34 @@ 'use client'; +// TRANSITIONAL — TODO(#90 D8.4d): delete this file once the BE +// snapshot endpoint returns UIMessage parts directly. +// // Reload-path fallback for terminal historical turns. // // The agent runtime snapshot endpoint // (`GET /agent/chats/{cid}/turns/{tid}`) still returns the legacy // `{turn, timeline, artifacts}` envelope; D8.2 (#74) added at-rest // UIMessage storage but the read path that the FE calls on reload -// has not yet been migrated to expose UIMessage parts. Until that BE -// change lands, terminal historical turns reload with an empty live +// has not yet been migrated to expose UIMessage parts. Until task +// #90 (D8.4d backend snapshot endpoint UIMessage-parts return) +// lands, terminal historical turns reload with an empty live // stream (`streamUrl: null` ⇒ hook never connects ⇒ `parts: []` and // `status: 'idle'`), which would render as an empty queued card — -// the regression dongdong called out (msg=97336fb9). +// the regression dongdong (msg=97336fb9) and Weston (msg=f8d3f102) +// called out on PR #1703 first-cut. // // Fix: when the hook is dormant for a terminal turn, synthesize a // minimal `AgentMessagePart[]` from the snapshot's legacy artifacts // (answer text + reference bundle items) so the renderer shows the // completed answer + references instead of an empty idle state. +// `error_summary` artifacts are pulled into the renderer's +// `errorText` channel (since the wire/at-rest contract treats +// errors as status + errorText, not as a persisted part). +// // The synthesis is read-only and never feeds back into the live -// reducer; once the BE snapshot endpoint returns UIMessages, this -// file is a one-line delete. +// reducer; once #90 lands, this file is a wholesale delete and +// `chat-messages.tsx` switches to consuming the BE-returned parts +// directly. import type { AgentMessagePart, AgentStreamStatus } from './types'; import type { AgentArtifactEnvelope, AgentTurnSnapshotEnvelope } from './api'; @@ -34,6 +44,7 @@ type ReferenceBundleItem = { const ANSWER_ARTIFACT_TYPE = 'answer'; const REFERENCE_BUNDLE_ARTIFACT_TYPE = 'reference_bundle'; +const ERROR_SUMMARY_ARTIFACT_TYPE = 'error_summary'; function findArtifact( artifacts: AgentArtifactEnvelope[], @@ -135,6 +146,36 @@ export function synthesizePartsFromSnapshot( return parts; } +/** + * Extract a renderer-friendly `errorText` from a snapshot's + * `error_summary` artifact, if any. Falls back to `null` so the + * caller can chain to `turn.error_message`. + * + * Per architect msg=711f8c2f: error_summary maps to the renderer's + * status/errorText channel rather than to a part — `error` in the + * wire/at-rest contract is a lifecycle marker (it sets + * `status='failed'` in the reducer), not a persisted message part. + */ +export function extractErrorTextFromSnapshot( + snapshot: AgentTurnSnapshotEnvelope, +): string | null { + const artifact = findArtifact(snapshot.artifacts, ERROR_SUMMARY_ARTIFACT_TYPE); + if (!artifact) return null; + const payload = artifact.payload || {}; + const candidate = + typeof payload.message === 'string' + ? payload.message + : typeof payload.text === 'string' + ? payload.text + : typeof payload.summary === 'string' + ? payload.summary + : artifact.summary || null; + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + return null; +} + const TERMINAL_BACKEND_STATUSES = new Set([ 'COMPLETED', 'FAILED', From 61972088fe18b295ab9f8217e067305d61b9fa42 Mon Sep 17 00:00:00 2001 From: earayu Date: Sat, 25 Apr 2026 23:44:26 +0800 Subject: [PATCH 4/4] fix(phase8 #77 D8.4b): update Phase 1b batch 6 contract test for renderer rename CI lint-and-unit failed because `tests/unit_test/test_web_typed_api_contract.py` hardcoded a path to `web/src/components/chat/agent-turn-card.tsx`, which #77 deleted in favor of the new `agent-turn-renderer.tsx`. Swap the path; the same `@/api` / legacy-SDK / FeedbackTagEnum ban-list applies to the new renderer (which only reaches `@/features/agent-runtime` + `@/features/bot/types`). Co-Authored-By: Claude Opus 4.7 --- tests/unit_test/test_web_typed_api_contract.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_test/test_web_typed_api_contract.py b/tests/unit_test/test_web_typed_api_contract.py index c74a7b549..a8555ba5b 100644 --- a/tests/unit_test/test_web_typed_api_contract.py +++ b/tests/unit_test/test_web_typed_api_contract.py @@ -501,8 +501,10 @@ def test_bot_feature_uses_v2_typed_api_boundary(): # classes are banned across this scope. The `chat-input.tsx` caller is # **deliberately out of scope** (deferred to the chat/document boundary # batch). + # Phase 8 D8.4b (#77) replaced `agent-turn-card.tsx` with + # `agent-turn-renderer.tsx`; the contract guard moves with it. batch6_chat_paths = [ - REPO_ROOT / "web/src/components/chat/agent-turn-card.tsx", + REPO_ROOT / "web/src/components/chat/agent-turn-renderer.tsx", REPO_ROOT / "web/src/components/chat/chat-messages.tsx", REPO_ROOT / "web/src/components/chat/message-feedback.tsx", REPO_ROOT / "web/src/components/chat/message-part-ai.tsx",