@@ -5,10 +5,12 @@ import {
55 createAssistantMirrorMessage as buildAssistantMirrorMessage ,
66 type AssistantMessageOptions ,
77} from "./event-projector-assistant-message.js" ;
8+ import { shouldClearTerminalPresentationForNativeItem } from "./event-projector-items.js" ;
89import { extractRawAssistantText , readItemString , readString } from "./event-projector-values.js" ;
910import type { CodexThreadItem , JsonObject } from "./protocol.js" ;
1011
1112type AgentEvent = Parameters < NonNullable < EmbeddedRunAttemptParams [ "onAgentEvent" ] > > [ 0 ] ;
13+ type AnswerCandidateStatus = "candidate" | "superseded" | "selected" ;
1214
1315export class CodexAssistantProjection {
1416 private readonly assistantTextByItem = new Map < string , string > ( ) ;
@@ -22,6 +24,8 @@ export class CodexAssistantProjection {
2224 private terminalAssistantCandidateEarlierActiveItemIds = new Set < string > ( ) ;
2325 private pendingRawTerminalAssistantEchoItemId : string | undefined ;
2426 private readonly lastCommentaryProgressTextByItem = new Map < string , string > ( ) ;
27+ private readonly lastAnswerCandidateEventByItem = new Map < string , string > ( ) ;
28+ private visibleAnswerCandidateItemId : string | undefined ;
2529 // Codex emits each typed item completion before its matching raw response item.
2630 // Pair by protocol order because contributors may rewrite only the typed text.
2731 private pendingRawCommentaryEchoes = 0 ;
@@ -103,6 +107,9 @@ export class CodexAssistantProjection {
103107 this . emitCommentaryProgress ( { itemId, text } ) ;
104108 return ;
105109 }
110+ if ( this . isFinalAnswerAssistantItem ( itemId ) ) {
111+ this . emitAnswerCandidate ( itemId , "candidate" ) ;
112+ }
106113 const knownFinalAnswer = this . shouldStreamAssistantPartial ( itemId ) ;
107114 const replace =
108115 this . streamedPartialAssistantItemId !== undefined &&
@@ -189,6 +196,8 @@ export class CodexAssistantProjection {
189196 if ( item . text && this . isCommentaryAssistantItem ( item . id ) ) {
190197 this . emitCommentaryProgress ( { itemId : item . id , text : item . text } ) ;
191198 this . pendingRawCommentaryEchoes += 1 ;
199+ } else if ( item . text && this . isFinalAnswerAssistantItem ( item . id ) ) {
200+ this . emitAnswerCandidate ( item . id , "candidate" ) ;
192201 }
193202 }
194203 }
@@ -275,6 +284,42 @@ export class CodexAssistantProjection {
275284 return finalText ? [ finalText ] : [ ] ;
276285 }
277286
287+ finalizeAnswerCandidate ( turn : { status ?: string ; items ?: CodexThreadItem [ ] } ) : void {
288+ if ( turn . status !== "completed" ) {
289+ this . supersedeVisibleAnswerCandidate ( ) ;
290+ return ;
291+ }
292+ const turnItems = turn . items ?? [ ] ;
293+ const authoritativeIndex = turnItems . findLastIndex (
294+ ( item ) =>
295+ item . type === "agentMessage" &&
296+ readItemString ( item , "phase" ) === "final_answer" &&
297+ typeof item . text === "string" &&
298+ item . text . trim ( ) . length > 0 ,
299+ ) ;
300+ const authoritative = authoritativeIndex >= 0 ? turnItems [ authoritativeIndex ] : undefined ;
301+ const invalidatedByLaterTool = turnItems
302+ . slice ( authoritativeIndex + 1 )
303+ . some ( shouldClearTerminalPresentationForNativeItem ) ;
304+ if (
305+ invalidatedByLaterTool ||
306+ ( authoritative ?. id === this . latestTerminalAssistantCandidateItemId &&
307+ this . latestTerminalAssistantCandidateSuperseded )
308+ ) {
309+ this . supersedeVisibleAnswerCandidate ( ) ;
310+ return ;
311+ }
312+ const itemId = authoritative ?. id ?? this . visibleAnswerCandidateItemId ;
313+ if ( ! itemId ) {
314+ return ;
315+ }
316+ if ( itemId !== this . visibleAnswerCandidateItemId ) {
317+ this . supersedeVisibleAnswerCandidate ( ) ;
318+ this . visibleAnswerCandidateItemId = itemId ;
319+ }
320+ this . emitAnswerCandidate ( itemId , "selected" ) ;
321+ }
322+
278323 hasAssistantItemTextForSynthesis ( ) : boolean {
279324 for ( let i = this . assistantItemOrder . length - 1 ; i >= 0 ; i -= 1 ) {
280325 const itemId = this . assistantItemOrder [ i ] ;
@@ -333,6 +378,10 @@ export class CodexAssistantProjection {
333378 return this . assistantPhaseByItem . get ( itemId ) === "commentary" ;
334379 }
335380
381+ private isFinalAnswerAssistantItem ( itemId : string ) : boolean {
382+ return this . assistantPhaseByItem . get ( itemId ) === "final_answer" ;
383+ }
384+
336385 private shouldStreamAssistantPartial ( itemId : string ) : boolean {
337386 return this . assistantPhaseByItem . get ( itemId ) === "final_answer" ;
338387 }
@@ -359,6 +408,45 @@ export class CodexAssistantProjection {
359408 } ) ;
360409 }
361410
411+ private emitAnswerCandidate ( itemId : string , status : AnswerCandidateStatus ) : void {
412+ const text = this . assistantTextByItem . get ( itemId ) ?. trim ( ) ;
413+ if ( ! text ) {
414+ return ;
415+ }
416+ if ( status === "candidate" && this . visibleAnswerCandidateItemId !== itemId ) {
417+ this . supersedeVisibleAnswerCandidate ( ) ;
418+ this . visibleAnswerCandidateItemId = itemId ;
419+ }
420+ const signature = `${ status } \0${ text } ` ;
421+ if ( this . lastAnswerCandidateEventByItem . get ( itemId ) === signature ) {
422+ return ;
423+ }
424+ this . lastAnswerCandidateEventByItem . set ( itemId , signature ) ;
425+ this . emitAgentEvent ( {
426+ stream : "item" ,
427+ data : {
428+ itemId,
429+ kind : "answer_candidate" ,
430+ title : "Answer candidate" ,
431+ phase : "update" ,
432+ status,
433+ progressText : text ,
434+ source : "codex-app-server" ,
435+ // Activity consumes this event directly; channel progress must never render it.
436+ hideFromChannelProgress : true ,
437+ } ,
438+ } ) ;
439+ }
440+
441+ private supersedeVisibleAnswerCandidate ( ) : void {
442+ const itemId = this . visibleAnswerCandidateItemId ;
443+ if ( ! itemId ) {
444+ return ;
445+ }
446+ this . emitAnswerCandidate ( itemId , "superseded" ) ;
447+ this . visibleAnswerCandidateItemId = undefined ;
448+ }
449+
362450 private markLatestTerminalAssistantCandidate (
363451 itemId : string ,
364452 activeItemIds : ReadonlySet < string > ,
@@ -389,6 +477,7 @@ export class CodexAssistantProjection {
389477 this . latestTerminalAssistantCandidateSuperseded = true ;
390478 this . latestTerminalAssistantCandidateCanReleaseAfterToolHandoff = false ;
391479 this . terminalAssistantCandidateEarlierActiveItemIds . clear ( ) ;
480+ this . supersedeVisibleAnswerCandidate ( ) ;
392481 }
393482
394483 private resolveFinalAssistantTextItem ( ) : { itemId : string ; text : string } | undefined {
0 commit comments