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