From 0e594da84d1f4732ba4a372fffa08282b61b7969 Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 17 May 2026 23:08:47 +0800 Subject: [PATCH] Render Codex review messages --- web/src/chat/normalize.test.ts | 96 +++++++++++++++ web/src/chat/normalizeAgent.ts | 83 ++++++++++++- web/src/chat/reconcile.ts | 13 ++ web/src/chat/reducerTimeline.ts | 21 +++- web/src/chat/types.ts | 38 +++++- .../messages/AssistantMessage.tsx | 50 ++++++++ .../messages/CodexReviewCard.test.tsx | 65 ++++++++++ .../messages/CodexReviewCard.tsx | 112 ++++++++++++++++++ web/src/lib/assistant-runtime.ts | 47 +++++++- web/src/lib/locales/en.ts | 6 + web/src/lib/locales/zh-CN.ts | 6 + 11 files changed, 530 insertions(+), 7 deletions(-) create mode 100644 web/src/components/AssistantChat/messages/CodexReviewCard.test.tsx create mode 100644 web/src/components/AssistantChat/messages/CodexReviewCard.tsx diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 172a2a7d..982cd58a 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -403,6 +403,102 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('normalizes Codex review JSON messages as structured review content', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: JSON.stringify({ + findings: [{ + title: '[P2] Remove retained sessions when sockets disconnect', + body: 'Retained sockets survive disconnects.', + confidence_score: 0.82, + priority: 2, + code_location: { + absolute_file_path: '/data/dz/wapair-ts/src/pairing/manager.ts', + line_range: { start: 1614, end: 1619 } + } + }], + overall_correctness: 'patch is incorrect', + overall_explanation: 'The message-sending feature retains long-lived sockets but does not fully manage their lifecycle.', + overall_confidence_score: 0.8 + }) + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [{ + type: 'codex-review', + review: { + overallCorrectness: 'patch is incorrect', + overallExplanation: 'The message-sending feature retains long-lived sockets but does not fully manage their lifecycle.', + overallConfidenceScore: 0.8, + findings: [{ + title: '[P2] Remove retained sessions when sockets disconnect', + body: 'Retained sockets survive disconnects.', + priority: 2, + confidenceScore: 0.82, + filePath: '/data/dz/wapair-ts/src/pairing/manager.ts', + lineStart: 1614, + lineEnd: 1619 + }] + } + }] + }) + }) + + it('keeps non-review Codex JSON messages as text', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: JSON.stringify({ status: 'ok', message: 'plain JSON' }) + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [{ + type: 'text', + text: '{"status":"ok","message":"plain JSON"}' + }] + }) + }) + + it('keeps malformed Codex review-looking messages as text', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: '{"findings": [' + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [{ + type: 'text', + text: '{"findings": [' + }] + }) + }) + it('normalizes Codex plan updates as completed update_plan snapshots', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 78cd6eea..e5512346 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -1,4 +1,4 @@ -import type { AgentEvent, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types' +import type { AgentEvent, CodexReview, CodexReviewFinding, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types' import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject } from '@hapi/protocol' import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages' @@ -142,6 +142,75 @@ function normalizePlanEntries(value: unknown): Array<{ step: string; status: 'pe return plan } +function normalizeCodexReviewFinding(value: unknown): CodexReviewFinding | null { + if (!isObject(value)) return null + const title = asString(value.title) + const body = asString(value.body) + if (!title || !body) return null + + const codeLocation = isObject(value.code_location) + ? value.code_location + : isObject(value.codeLocation) + ? value.codeLocation + : null + const lineRange = codeLocation && isObject(codeLocation.line_range) + ? codeLocation.line_range + : codeLocation && isObject(codeLocation.lineRange) + ? codeLocation.lineRange + : null + + return { + title, + body, + priority: asNumber(value.priority), + confidenceScore: asNumber(value.confidence_score ?? value.confidenceScore), + filePath: codeLocation ? asString(codeLocation.absolute_file_path ?? codeLocation.absoluteFilePath ?? codeLocation.path) : null, + lineStart: lineRange ? asNumber(lineRange.start) : null, + lineEnd: lineRange ? asNumber(lineRange.end) : null + } +} + +function normalizeCodexReviewJson(value: unknown): CodexReview | null { + if (!isObject(value)) return null + const hasReviewMarker = Array.isArray(value.findings) + || 'overall_correctness' in value + || 'overallCorrectness' in value + || 'overall_explanation' in value + || 'overallExplanation' in value + if (!hasReviewMarker) return null + + const findings = Array.isArray(value.findings) + ? value.findings + .map(normalizeCodexReviewFinding) + .filter((finding): finding is CodexReviewFinding => finding !== null) + : [] + + const overallCorrectness = asString(value.overall_correctness ?? value.overallCorrectness) + const overallExplanation = asString(value.overall_explanation ?? value.overallExplanation) + const overallConfidenceScore = asNumber(value.overall_confidence_score ?? value.overallConfidenceScore) + + if (findings.length === 0 && !overallCorrectness && !overallExplanation && overallConfidenceScore === null) { + return null + } + + return { + findings, + overallCorrectness, + overallExplanation, + overallConfidenceScore + } +} + +function parseCodexReviewMessage(message: string): CodexReview | null { + const trimmed = message.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null + try { + return normalizeCodexReviewJson(JSON.parse(trimmed) as unknown) + } catch { + return null + } +} + function normalizeAssistantOutput( messageId: string, localId: string | null, @@ -503,6 +572,18 @@ export function normalizeAgentRecord( } if (data.type === 'message' && typeof data.message === 'string') { + const review = parseCodexReviewMessage(data.message) + if (review) { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'codex-review', review, uuid: messageId, parentUUID: null }], + meta + } + } return { id: messageId, localId, diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 321475a0..e4d9a480 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -6,6 +6,7 @@ import type { ChatBlock, GeneratedImageBlock, CliOutputBlock, + CodexReviewBlock, ToolCallBlock, ToolPermission, UserTextBlock, @@ -145,6 +146,13 @@ function areGeneratedImageBlocksEqual(left: GeneratedImageBlock, right: Generate && left.meta === right.meta } +function areCodexReviewBlocksEqual(left: CodexReviewBlock, right: CodexReviewBlock): boolean { + return left.review === right.review + && left.localId === right.localId + && left.createdAt === right.createdAt + && left.meta === right.meta +} + function areAgentEventBlocksEqual(left: AgentEventBlock, right: AgentEventBlock): boolean { return left.createdAt === right.createdAt && left.meta === right.meta @@ -228,6 +236,11 @@ function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock { return areGeneratedImageBlocksEqual(prevBlock, block) ? prevBlock : block } + if (block.kind === 'codex-review') { + const prevBlock = prev as CodexReviewBlock + return areCodexReviewBlocksEqual(prevBlock, block) ? prevBlock : block + } + const prevBlock = prev as AgentEventBlock return areAgentEventBlocksEqual(prevBlock, block) ? prevBlock : block } diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 52dc6d2d..8efae80f 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -1,4 +1,4 @@ -import type { AgentReasoningBlock, AgentTextBlock, ChatBlock, CliOutputBlock, ToolCallBlock, ToolPermission } from '@/chat/types' +import type { AgentReasoningBlock, AgentTextBlock, ChatBlock, CliOutputBlock, CodexReviewBlock, ToolCallBlock, ToolPermission } from '@/chat/types' import type { TracedMessage } from '@/chat/tracer' import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/chat/reducerCliOutput' import { parseMessageAsEvent } from '@/chat/reducerEvents' @@ -437,8 +437,8 @@ export function reduceTimeline( const targetId = msg.content.targetMessageId const durationMs = msg.content.durationMs as number type DurationBearingBlock = AgentTextBlock | AgentReasoningBlock | CliOutputBlock | ToolCallBlock - const isDurationTarget = (b: ChatBlock): b is DurationBearingBlock => - b.kind === 'agent-text' || b.kind === 'agent-reasoning' || b.kind === 'cli-output' || b.kind === 'tool-call' + const isDurationTarget = (b: ChatBlock): b is DurationBearingBlock | CodexReviewBlock => + b.kind === 'agent-text' || b.kind === 'agent-reasoning' || b.kind === 'codex-review' || b.kind === 'cli-output' || b.kind === 'tool-call' let foundIndex = -1 if (targetId) { @@ -760,6 +760,21 @@ export function reduceTimeline( continue } + if (c.type === 'codex-review') { + blocks.push({ + kind: 'codex-review', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, + review: c.review, + meta: msg.meta + }) + continue + } + if (c.type === 'summary') { blocks.push({ kind: 'agent-event', diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 1d9d0f7d..c66ed3e5 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -65,6 +65,23 @@ export type GeneratedImageContent = { parentUUID: string | null } +export type CodexReviewFinding = { + title: string + body: string + priority: number | null + confidenceScore: number | null + filePath: string | null + lineStart: number | null + lineEnd: number | null +} + +export type CodexReview = { + findings: CodexReviewFinding[] + overallCorrectness: string | null + overallExplanation: string | null + overallConfidenceScore: number | null +} + export type NormalizedAgentContent = | { type: 'text' @@ -81,6 +98,12 @@ export type NormalizedAgentContent = | ToolUse | ToolResult | GeneratedImageContent + | { + type: 'codex-review' + review: CodexReview + uuid: string + parentUUID: string | null + } | { type: 'summary'; summary: string } | { type: 'sidechain'; uuid: string; parentUUID: string | null; prompt: string } @@ -171,6 +194,19 @@ export type AgentReasoningBlock = { meta?: unknown } +export type CodexReviewBlock = { + kind: 'codex-review' + id: string + localId: string | null + createdAt: number + invokedAt?: number | null + durationMs?: number + usage?: UsageData + model?: string | null + review: CodexReview + meta?: unknown +} + export type CliOutputBlock = { kind: 'cli-output' id: string @@ -221,4 +257,4 @@ export type ToolCallBlock = { meta?: unknown } -export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CliOutputBlock | ToolCallBlock | GeneratedImageBlock | AgentEventBlock +export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CodexReviewBlock | CliOutputBlock | ToolCallBlock | GeneratedImageBlock | AgentEventBlock diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 5f9e6d05..0b42ff66 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -11,6 +11,7 @@ import { getAssistantCopyText } from '@/components/AssistantChat/messages/assist import { getConversationMessageAnchorId } from '@/chat/outline' import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata' import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle' +import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard' const TOOL_COMPONENTS = { Fallback: HappyToolMessage @@ -35,6 +36,10 @@ export function HappyAssistantMessage() { const custom = message.metadata.custom as Partial | undefined return custom?.kind === 'cli-output' }) + const codexReview = useAssistantState(({ message }) => { + const custom = message.metadata.custom as Partial | undefined + return custom?.kind === 'codex-review' ? custom.review : undefined + }) const cliText = useAssistantState(({ message }) => { const custom = message.metadata.custom as Partial | undefined if (custom?.kind !== 'cli-output') return '' @@ -102,6 +107,51 @@ export function HappyAssistantMessage() { ) } + if (codexReview) { + return ( + +
+
+ + {showMetadata && ( + + )} +
+ {copyText ? ( +
+ +
+ ) : null} +
+
+ ) + } + if (toolOnly) { return ( + + + ) +} + +describe('CodexReviewCard', () => { + it('renders overall review details and finding locations', () => { + renderCard({ + overallCorrectness: 'patch is incorrect', + overallExplanation: 'Retained sessions can survive socket disconnects.', + overallConfidenceScore: 0.8, + findings: [{ + title: '[P2] Remove retained sessions when sockets disconnect', + body: 'This entry remains in onlineMessageSessions.', + priority: 2, + confidenceScore: 0.82, + filePath: '/data/dz/wapair-ts/src/pairing/manager.ts', + lineStart: 1614, + lineEnd: 1619 + }] + }) + + expect(screen.getByText('Codex review')).toBeInTheDocument() + expect(screen.getByText('patch is incorrect')).toBeInTheDocument() + expect(screen.getByText('80%')).toBeInTheDocument() + expect(screen.getByText('Retained sessions can survive socket disconnects.')).toBeInTheDocument() + expect(screen.getByText('1 findings')).toBeInTheDocument() + expect(screen.getByText('P2')).toBeInTheDocument() + expect(screen.getByText('[P2] Remove retained sessions when sockets disconnect')).toBeInTheDocument() + expect(screen.getByText('This entry remains in onlineMessageSessions.')).toBeInTheDocument() + expect(screen.getByText('/data/dz/wapair-ts/src/pairing/manager.ts:1614-1619')).toBeInTheDocument() + expect(screen.getByText('Confidence 82%')).toBeInTheDocument() + }) + + it('omits optional values without rendering nullish text', () => { + renderCard({ + overallCorrectness: null, + overallExplanation: null, + overallConfidenceScore: null, + findings: [{ + title: 'Finding without metadata', + body: 'Body only.', + priority: null, + confidenceScore: null, + filePath: null, + lineStart: null, + lineEnd: null + }] + }) + + expect(screen.getByText('Finding without metadata')).toBeInTheDocument() + expect(screen.getByText('Body only.')).toBeInTheDocument() + expect(screen.getByText('No location')).toBeInTheDocument() + expect(screen.queryByText(/undefined|null/)).not.toBeInTheDocument() + }) +}) diff --git a/web/src/components/AssistantChat/messages/CodexReviewCard.tsx b/web/src/components/AssistantChat/messages/CodexReviewCard.tsx new file mode 100644 index 00000000..b3299a29 --- /dev/null +++ b/web/src/components/AssistantChat/messages/CodexReviewCard.tsx @@ -0,0 +1,112 @@ +import type { CodexReview, CodexReviewFinding } from '@/chat/types' +import { useTranslation } from '@/lib/use-translation' +import { cn } from '@/lib/utils' + +function formatPercent(value: number | null): string | null { + if (value === null || !Number.isFinite(value)) return null + return `${Math.round(value * 100)}%` +} + +function formatLocation(finding: CodexReviewFinding): string | null { + if (!finding.filePath) return null + if (finding.lineStart === null) return finding.filePath + if (finding.lineEnd !== null && finding.lineEnd !== finding.lineStart) { + return `${finding.filePath}:${finding.lineStart}-${finding.lineEnd}` + } + return `${finding.filePath}:${finding.lineStart}` +} + +function getPriorityClassName(priority: number | null): string { + if (priority === 0 || priority === 1) { + return 'border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)] text-[var(--app-badge-error-text)]' + } + if (priority === 2) { + return 'border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)] text-[var(--app-badge-warning-text)]' + } + return 'border-[var(--app-border)] bg-[var(--app-subtle-bg)] text-[var(--app-hint)]' +} + +function ReviewBadge(props: { children: string; className?: string }) { + return ( + + {props.children} + + ) +} + +function FindingItem(props: { finding: CodexReviewFinding }) { + const { t } = useTranslation() + const confidence = formatPercent(props.finding.confidenceScore) + const location = formatLocation(props.finding) + + return ( +
  • +
    + {props.finding.priority !== null ? ( + + {`P${props.finding.priority}`} + + ) : null} +
    + {props.finding.title} +
    +
    +

    + {props.finding.body} +

    +
    + {location ? ( + {location} + ) : ( + {t('codexReview.location.missing')} + )} + {confidence ? ( + {t('codexReview.confidence', { value: confidence })} + ) : null} +
    +
  • + ) +} + +export function CodexReviewCard(props: { review: CodexReview }) { + const { t } = useTranslation() + const confidence = formatPercent(props.review.overallConfidenceScore) + const findingCount = props.review.findings.length + + return ( +
    +
    +
    + {t('codexReview.title')} +
    + {props.review.overallCorrectness ? ( + + {props.review.overallCorrectness} + + ) : null} + {confidence ? ( + + {confidence} + + ) : null} +
    +
    + {props.review.overallExplanation ? ( +

    + {props.review.overallExplanation} +

    + ) : null} +
    + {t('codexReview.findings', { count: findingCount })} +
    + {findingCount > 0 ? ( +
      + {props.review.findings.map((finding, index) => ( + + ))} +
    + ) : null} +
    +
    + ) +} diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index 2ed3eee5..8fbc3ca6 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -3,13 +3,13 @@ import type { AppendMessage, AttachmentAdapter, ThreadMessageLike } from '@assis import { useExternalMessageConverter, useExternalStoreRuntime } from '@assistant-ui/react' import { safeStringify } from '@hapi/protocol' import { renderEventLabel } from '@/chat/presentation' -import type { ChatBlock, CliOutputBlock, UsageData } from '@/chat/types' +import type { ChatBlock, CliOutputBlock, CodexReview, UsageData } from '@/chat/types' import type { AgentEvent, ToolCallBlock } from '@/chat/types' import type { ToolGroupBlock, VisibleChatBlock } from '@/chat/toolGroups' import type { AttachmentMetadata, MessageStatus as HappyMessageStatus, Session } from '@/types/api' export type HappyChatMessageMetadata = { - kind: 'user' | 'assistant' | 'tool' | 'event' | 'cli-output' + kind: 'user' | 'assistant' | 'tool' | 'event' | 'cli-output' | 'codex-review' status?: HappyMessageStatus localId?: string | null originalText?: string @@ -21,6 +21,29 @@ export type HappyChatMessageMetadata = { durationMs?: number usage?: UsageData model?: string | null + review?: CodexReview +} + +function formatCodexReviewText(review: CodexReview): string { + const lines = ['Codex review'] + if (review.overallCorrectness) { + lines.push(`Overall: ${review.overallCorrectness}`) + } + if (review.overallExplanation) { + lines.push('', review.overallExplanation) + } + if (review.findings.length > 0) { + lines.push('', 'Findings:') + for (const finding of review.findings) { + const priority = finding.priority === null ? '' : `[P${finding.priority}] ` + const location = finding.filePath + ? ` (${finding.filePath}${finding.lineStart === null ? '' : `:${finding.lineStart}${finding.lineEnd !== null && finding.lineEnd !== finding.lineStart ? `-${finding.lineEnd}` : ''}`})` + : '' + lines.push(`- ${priority}${finding.title}${location}`) + lines.push(` ${finding.body}`) + } + } + return lines.join('\n') } function toThreadMessageLike(block: VisibleChatBlock): ThreadMessageLike { @@ -104,6 +127,26 @@ function toThreadMessageLike(block: VisibleChatBlock): ThreadMessageLike { } } + if (block.kind === 'codex-review') { + const messageId = `review:${block.id}` + return { + role: 'assistant', + id: messageId, + createdAt: new Date(block.createdAt), + content: [{ type: 'text', text: formatCodexReviewText(block.review) }], + metadata: { + custom: { + kind: 'codex-review', + invokedAt: block.invokedAt, + durationMs: block.durationMs, + usage: block.usage, + model: block.model, + review: block.review + } satisfies HappyChatMessageMetadata + } + } + } + if (block.kind === 'agent-event') { const messageId = `event:${block.id}` return { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index c0bf00ce..4bf5e906 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -163,6 +163,12 @@ export default { 'chat.terminal': 'Terminal', 'chat.switchRemote': 'Switch to remote mode', + // Codex review + 'codexReview.title': 'Codex review', + 'codexReview.findings': '{count} findings', + 'codexReview.confidence': 'Confidence {value}', + 'codexReview.location.missing': 'No location', + // Terminal 'terminal.commandName': 'Command', 'terminal.commandMessage': 'Command message', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 1b767676..df8b291a 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -165,6 +165,12 @@ export default { 'chat.terminal': '终端', 'chat.switchRemote': '切换到远程模式', + // Codex review + 'codexReview.title': 'Codex review', + 'codexReview.findings': '{count} 条发现', + 'codexReview.confidence': '置信度 {value}', + 'codexReview.location.missing': '无位置', + // Terminal 'terminal.commandName': '命令', 'terminal.commandMessage': '命令消息',