Render Codex review messages

This commit is contained in:
weishu
2026-05-17 23:11:36 +08:00
parent c5e80e9a66
commit 0e594da84d
11 changed files with 530 additions and 7 deletions
+96
View File
@@ -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',
+82 -1
View File
@@ -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,
+13
View File
@@ -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
}
+18 -3
View File
@@ -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',
+37 -1
View File
@@ -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
@@ -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<HappyChatMessageMetadata> | undefined
return custom?.kind === 'cli-output'
})
const codexReview = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.kind === 'codex-review' ? custom.review : undefined
})
const cliText = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
if (custom?.kind !== 'cli-output') return ''
@@ -102,6 +107,51 @@ export function HappyAssistantMessage() {
)
}
if (codexReview) {
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
>
<div className="flex items-start gap-2">
<div
className={hasMetadata ? 'min-w-0 flex-1 cursor-pointer' : 'min-w-0 flex-1'}
onClick={hasMetadata ? toggleMetadata : undefined}
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
role={hasMetadata ? 'button' : undefined}
tabIndex={hasMetadata ? 0 : undefined}
aria-expanded={hasMetadata ? showMetadata : undefined}
>
<CodexReviewCard review={codexReview} />
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
durationMs={durationMs}
usage={usage}
model={messageModel ?? null}
className="mt-1"
/>
)}
</div>
{copyText ? (
<div className="happy-message-actions-first-line hidden sm:flex shrink-0 opacity-0 group-hover/msg:opacity-100 transition-opacity">
<button
type="button"
title="Copy"
className="p-0.5 rounded hover:bg-[var(--app-subtle-bg)] transition-colors"
onClick={() => copy(copyText)}
>
{copied
? <CheckIcon className="h-3.5 w-3.5 text-green-500" />
: <CopyIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
</button>
</div>
) : null}
</div>
</MessagePrimitive.Root>
)
}
if (toolOnly) {
return (
<MessagePrimitive.Root
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
import type { CodexReview } from '@/chat/types'
function renderCard(review: CodexReview) {
return render(
<I18nProvider>
<CodexReviewCard review={review} />
</I18nProvider>
)
}
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()
})
})
@@ -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 (
<span className={cn('inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[11px] font-medium leading-4', props.className)}>
{props.children}
</span>
)
}
function FindingItem(props: { finding: CodexReviewFinding }) {
const { t } = useTranslation()
const confidence = formatPercent(props.finding.confidenceScore)
const location = formatLocation(props.finding)
return (
<li className="rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] p-3">
<div className="flex min-w-0 flex-wrap items-start gap-2">
{props.finding.priority !== null ? (
<ReviewBadge className={getPriorityClassName(props.finding.priority)}>
{`P${props.finding.priority}`}
</ReviewBadge>
) : null}
<div className="min-w-0 flex-1 text-sm font-semibold leading-6 text-[var(--app-fg)]">
{props.finding.title}
</div>
</div>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--app-fg)]">
{props.finding.body}
</p>
<div className="mt-2 flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs leading-5 text-[var(--app-hint)]">
{location ? (
<span className="min-w-0 break-all font-mono">{location}</span>
) : (
<span>{t('codexReview.location.missing')}</span>
)}
{confidence ? (
<span>{t('codexReview.confidence', { value: confidence })}</span>
) : null}
</div>
</li>
)
}
export function CodexReviewCard(props: { review: CodexReview }) {
const { t } = useTranslation()
const confidence = formatPercent(props.review.overallConfidenceScore)
const findingCount = props.review.findings.length
return (
<section className="my-1 max-w-full overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-subtle-bg)]">
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b border-[var(--app-divider)] px-3 py-2">
<div className="min-w-0 flex-1 text-sm font-semibold text-[var(--app-fg)]">
{t('codexReview.title')}
</div>
{props.review.overallCorrectness ? (
<ReviewBadge className="border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)]">
{props.review.overallCorrectness}
</ReviewBadge>
) : null}
{confidence ? (
<ReviewBadge className="border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-hint)]">
{confidence}
</ReviewBadge>
) : null}
</div>
<div className="px-3 py-3">
{props.review.overallExplanation ? (
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--app-fg)]">
{props.review.overallExplanation}
</p>
) : null}
<div className={cn('text-xs font-medium text-[var(--app-hint)]', props.review.overallExplanation ? 'mt-3' : '')}>
{t('codexReview.findings', { count: findingCount })}
</div>
{findingCount > 0 ? (
<ol className="mt-2 space-y-2">
{props.review.findings.map((finding, index) => (
<FindingItem key={`${finding.title}:${index}`} finding={finding} />
))}
</ol>
) : null}
</div>
</section>
)
}
+45 -2
View File
@@ -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 {
+6
View File
@@ -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',
+6
View File
@@ -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': '命令消息',