fix(codex): stabilize goal status UI events (#652)

This commit is contained in:
SmallSpider
2026-05-20 17:26:10 +08:00
committed by GitHub
parent 25631d971c
commit 74e40b8a1a
11 changed files with 523 additions and 50 deletions
+124
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import { reduceChatBlocks } from './reducer'
import { normalizeDecryptedMessage } from './normalize'
import type { NormalizedMessage } from './types'
import type { DecryptedMessage } from '@/types/api'
import type { ThreadGoal, ThreadGoalStatus } from '@/types/api'
function userMessage(id: string, text: string, createdAt: number): NormalizedMessage {
@@ -53,6 +55,30 @@ function goalClearedMessage(id: string, createdAt: number): NormalizedMessage {
}
}
function eventMessage(id: string, message: string, createdAt: number): NormalizedMessage {
return {
id,
localId: null,
createdAt,
role: 'event',
content: {
type: 'message',
message
},
isSidechain: false
}
}
function decryptedMessage(id: string, content: unknown, createdAt: number): DecryptedMessage {
return {
id,
seq: 1,
localId: null,
content,
createdAt
}
}
describe('reduceChatBlocks', () => {
it('ignores child agent usage when calculating parent latest usage', () => {
const messages: NormalizedMessage[] = [
@@ -127,6 +153,21 @@ describe('reduceChatBlocks', () => {
expect(reduced.latestGoal).toBeNull()
})
it('can clear completed goal state using messages hidden from the rendered timeline', () => {
const renderedMessages = [
goalMessage('goal-complete', 'complete', 1)
]
const goalStateMessages = [
...renderedMessages,
userMessage('queued-user-later', 'start a new task', 2)
]
const reduced = reduceChatBlocks(renderedMessages, null, { goalStateMessages })
expect(reduced.blocks).toHaveLength(0)
expect(reduced.latestGoal).toBeNull()
})
it('does not treat later goal slash commands as non-goal activity', () => {
const reduced = reduceChatBlocks([
goalMessage('goal-complete', 'complete', 1),
@@ -155,4 +196,87 @@ describe('reduceChatBlocks', () => {
expect(reduced.latestGoal).toBeNull()
})
it('uses goal events for latest goal state without rendering timeline prompts', () => {
const reduced = reduceChatBlocks([
goalMessage('goal-active', 'active', 1)
], null)
expect(reduced.blocks).toHaveLength(0)
expect(reduced.latestGoal).toMatchObject({
threadId: 'thread-1',
objective: 'ship goal support',
status: 'active'
})
})
it('uses goal clear events to clear latest goal without rendering timeline prompts', () => {
const reduced = reduceChatBlocks([
goalMessage('goal-active', 'active', 1),
goalClearedMessage('goal-cleared', 2)
], null)
expect(reduced.blocks).toHaveLength(0)
expect(reduced.latestGoal).toBeNull()
})
it('hides redundant goal status messages but keeps actionable goal messages', () => {
const reduced = reduceChatBlocks([
eventMessage('goal-active-message', 'Goal active', 1),
eventMessage('goal-active-usage-message', 'Goal active · 181737 tokens', 2),
eventMessage('goal-complete-message', 'Goal complete', 3),
eventMessage('goal-cleared-message', 'Goal cleared', 4),
eventMessage('goal-actionable-message', 'No goal to clear', 5)
], null)
expect(reduced.blocks).toHaveLength(1)
expect(reduced.blocks[0]).toMatchObject({
kind: 'agent-event',
event: { type: 'message', message: 'No goal to clear' }
})
})
it('hides persisted goal status event envelopes alongside structured goal events', () => {
const goal: ThreadGoal = {
threadId: 'thread-1',
objective: 'ship goal support',
status: 'active',
tokenBudget: null,
tokensUsed: 8016,
timeUsedSeconds: 10,
createdAt: 1,
updatedAt: 2
}
const normalized = [
decryptedMessage('goal-status-envelope', {
role: 'agent',
content: {
id: 'event-1',
type: 'event',
data: { type: 'message', message: 'Goal active · 8016 tokens' }
}
}, 1),
decryptedMessage('goal-structured-envelope', {
role: 'agent',
content: {
type: 'codex',
data: {
type: 'thread_goal_updated',
thread_id: 'thread-1',
goal
}
}
}, 2)
].map(message => normalizeDecryptedMessage(message))
.filter((message): message is NormalizedMessage => message !== null)
const reduced = reduceChatBlocks(normalized, null)
expect(reduced.blocks).toHaveLength(0)
expect(reduced.latestGoal).toMatchObject({
threadId: 'thread-1',
status: 'active',
tokensUsed: 8016
})
})
})
+41 -3
View File
@@ -5,6 +5,7 @@ import { traceMessages, type TracedMessage } from '@/chat/tracer'
import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents'
import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools'
import { reduceTimeline } from '@/chat/reducerTimeline'
import { isRedundantGoalStatusMessageText } from '@hapi/protocol/messages'
// Calculate context size from usage data
function calculateContextSize(usage: UsageData): number {
@@ -28,6 +29,10 @@ export type LatestUsage = {
timestamp: number
}
export type ReduceChatBlocksOptions = {
goalStateMessages?: NormalizedMessage[]
}
function getLatestThreadGoal(normalized: NormalizedMessage[]): ThreadGoal | null {
let sawNewerNonGoalUserMessage = false
for (let i = normalized.length - 1; i >= 0; i--) {
@@ -52,9 +57,42 @@ function getLatestThreadGoal(normalized: NormalizedMessage[]): ThreadGoal | null
return null
}
function isRedundantGoalStatusMessage(event: AgentEvent): boolean {
if (event.type !== 'message') return false
return isRedundantGoalStatusMessageText(event.message)
}
function isSilentGoalEventBlock(block: ChatBlock): boolean {
return block.kind === 'agent-event'
&& (
block.event.type === 'thread-goal-updated'
|| block.event.type === 'thread-goal-cleared'
|| isRedundantGoalStatusMessage(block.event)
)
}
function filterSilentGoalBlocks(blocks: ChatBlock[]): ChatBlock[] {
const filtered: ChatBlock[] = []
for (const block of blocks) {
if (isSilentGoalEventBlock(block)) continue
if (block.kind === 'tool-call' && block.children.length > 0) {
filtered.push({
...block,
children: filterSilentGoalBlocks(block.children)
})
continue
}
filtered.push(block)
}
return filtered
}
export function reduceChatBlocks(
normalized: NormalizedMessage[],
agentState: AgentState | null | undefined
agentState: AgentState | null | undefined,
options: ReduceChatBlocksOptions = {}
): { blocks: ChatBlock[]; hasReadyEvent: boolean; latestUsage: LatestUsage | null; latestGoal: ThreadGoal | null } {
const permissionsById = getPermissions(agentState)
const toolIdsInMessages = collectToolIdsFromMessages(normalized)
@@ -142,9 +180,9 @@ export function reduceChatBlocks(
}
return {
blocks: dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks)),
blocks: filterSilentGoalBlocks(dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks))),
hasReadyEvent,
latestUsage,
latestGoal: getLatestThreadGoal(normalized)
latestGoal: getLatestThreadGoal(options.goalStateMessages ?? normalized)
}
}
+81 -1
View File
@@ -1,6 +1,31 @@
import { describe, expect, it } from 'vitest'
import { shouldAutoClearPendingSchedule } from './SessionChat'
import { buildGoalStateMessages, shouldAutoClearPendingSchedule } from './SessionChat'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import type { DecryptedMessage } from '@/types/api'
function userMessage(props: {
id: string
createdAt: number
localId?: string | null
invokedAt?: number | null
scheduledAt?: number | null
}): DecryptedMessage {
return {
id: props.id,
seq: null,
localId: props.localId ?? null,
content: {
role: 'user',
content: {
type: 'text',
text: 'hello'
}
},
createdAt: props.createdAt,
invokedAt: props.invokedAt,
scheduledAt: props.scheduledAt
}
}
/**
* Unit tests for shouldAutoClearPendingSchedule.
@@ -41,3 +66,58 @@ describe('shouldAutoClearPendingSchedule', () => {
expect(shouldAutoClearPendingSchedule(expired)).toBe(true)
})
})
describe('buildGoalStateMessages', () => {
it('keeps immediate queued user messages so completed goal status can clear before timeline render', () => {
const now = 1_700_000_000_000
const messages = [
userMessage({
id: 'local-immediate',
localId: 'local-immediate',
createdAt: now,
invokedAt: null
})
]
expect(buildGoalStateMessages(messages).map((message) => message.id))
.toEqual(['local-immediate'])
})
it('includes pending messages that are outside the visible timeline window', () => {
const now = 1_700_000_000_000
const visible = [
userMessage({ id: 'visible', createdAt: now - 10 })
]
const pending = [
userMessage({ id: 'pending', createdAt: now })
]
expect(buildGoalStateMessages(visible, pending).map((message) => message.id))
.toEqual(['visible', 'pending'])
})
it('ignores uninvoked scheduled messages, including mature prompts, until they are invoked', () => {
const now = 1_700_000_000_000
const futureQueued = userMessage({
id: 'future',
createdAt: now,
invokedAt: null,
scheduledAt: now + 60_000
})
const matureQueued = userMessage({
id: 'mature',
createdAt: now + 1,
invokedAt: null,
scheduledAt: now - 60_000
})
const invokedScheduled = userMessage({
id: 'invoked',
createdAt: now + 2,
invokedAt: now + 30_000,
scheduledAt: now - 60_000
})
expect(buildGoalStateMessages([futureQueued, matureQueued, invokedScheduled]).map((message) => message.id))
.toEqual(['invoked'])
})
})
+35 -3
View File
@@ -17,7 +17,7 @@ import { reduceChatBlocks } from '@/chat/reducer'
import { reconcileChatBlocks } from '@/chat/reconcile'
import { buildConversationOutline } from '@/chat/outline'
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
import { isQueuedForInvocation } from '@/lib/messages'
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
@@ -49,6 +49,21 @@ export function shouldAutoClearPendingSchedule(pending: PendingSchedule | null):
return pending !== null && pending.type === 'absolute'
}
function isUninvokedScheduledMessage(message: DecryptedMessage): boolean {
return message.invokedAt == null && message.scheduledAt != null
}
export function buildGoalStateMessages(
messages: DecryptedMessage[],
pendingMessages: DecryptedMessage[] = []
): DecryptedMessage[] {
const eligibleMessages = messages.filter((message) => !isUninvokedScheduledMessage(message))
const eligiblePendingMessages = pendingMessages.filter((message) => !isUninvokedScheduledMessage(message))
return eligiblePendingMessages.length > 0
? mergeMessages(eligibleMessages, eligiblePendingMessages)
: eligibleMessages
}
function getOutlineTitle(session: Session): string {
if (session.metadata?.name) {
return session.metadata.name
@@ -83,6 +98,7 @@ export function SessionChat(props: {
api: ApiClient
session: Session
messages: DecryptedMessage[]
pendingMessages?: DecryptedMessage[]
messagesWarning: string | null
hasMoreMessages: boolean
isLoadingMessages: boolean
@@ -306,9 +322,25 @@ export function SessionChat(props: {
return normalized
}, [visibleMessages])
const goalStateSourceMessages = useMemo(
() => buildGoalStateMessages(props.messages, props.pendingMessages ?? []),
[props.messages, props.pendingMessages]
)
const normalizedGoalStateMessages: NormalizedMessage[] = useMemo(() => {
const normalized: NormalizedMessage[] = []
for (const message of goalStateSourceMessages) {
const next = normalizeDecryptedMessage(message)
if (next) normalized.push(next)
}
return normalized
}, [goalStateSourceMessages])
const reduced = useMemo(
() => reduceChatBlocks(normalizedMessages, props.session.agentState),
[normalizedMessages, props.session.agentState]
() => reduceChatBlocks(normalizedMessages, props.session.agentState, {
goalStateMessages: normalizedGoalStateMessages
}),
[normalizedMessages, normalizedGoalStateMessages, props.session.agentState]
)
const reconciled = useMemo(
() => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current),
+2
View File
@@ -28,6 +28,7 @@ export const EMPTY_STATE: MessageWindowState = {
export function useMessages(api: ApiClient | null, sessionId: string | null): {
messages: DecryptedMessage[]
pendingMessages: DecryptedMessage[]
warning: string | null
isLoading: boolean
isLoadingMore: boolean
@@ -88,6 +89,7 @@ export function useMessages(api: ApiClient | null, sessionId: string | null): {
return {
messages: state.messages,
pendingMessages: state.pending,
warning: state.warning,
isLoading: state.isLoading,
isLoadingMore: state.isLoadingMore,
+2
View File
@@ -259,6 +259,7 @@ function SessionPage() {
} = useSession(api, sessionId)
const {
messages,
pendingMessages,
warning: messagesWarning,
isLoading: messagesLoading,
isLoadingMore: messagesLoadingMore,
@@ -371,6 +372,7 @@ function SessionPage() {
api={api}
session={session}
messages={messages}
pendingMessages={pendingMessages}
messagesWarning={messagesWarning}
hasMoreMessages={messagesHasMore}
isLoadingMessages={messagesLoading}