Skip to content

Commit 7e6cf8d

Browse files
steipeteooiuuii
andcommitted
feat(codex): show answer candidates in Activity
Co-authored-by: luyifan <[email protected]>
1 parent 2030ac7 commit 7e6cf8d

11 files changed

Lines changed: 576 additions & 16 deletions

File tree

extensions/codex/src/app-server/event-projector-assistant.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
89
import { extractRawAssistantText, readItemString, readString } from "./event-projector-values.js";
910
import type { CodexThreadItem, JsonObject } from "./protocol.js";
1011

1112
type AgentEvent = Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>>[0];
13+
type AnswerCandidateStatus = "candidate" | "superseded" | "selected";
1214

1315
export 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 {

extensions/codex/src/app-server/event-projector.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,183 @@ describe("CodexAppServerEventProjector", () => {
376376
expect(result.replayMetadata.replaySafe).toBe(true);
377377
});
378378

379+
it("keeps reopened final answers as Activity candidates until turn completion selects one", async () => {
380+
const onAgentEvent = vi.fn();
381+
const projector = await createProjector({
382+
...(await createParams()),
383+
onAgentEvent,
384+
});
385+
386+
await projector.handleNotification(
387+
forCurrentTurn("item/started", {
388+
item: { type: "agentMessage", id: "answer-1", phase: "final_answer", text: "" },
389+
}),
390+
);
391+
await projector.handleNotification(agentMessageDelta("First candidate", "answer-1"));
392+
await projector.handleNotification(
393+
forCurrentTurn("item/completed", {
394+
item: {
395+
type: "agentMessage",
396+
id: "answer-1",
397+
phase: "final_answer",
398+
text: "First candidate",
399+
},
400+
}),
401+
);
402+
403+
const lateTool = {
404+
type: "commandExecution",
405+
id: "late-tool",
406+
command: "/bin/bash -lc 'printf late'",
407+
cwd: "/workspace",
408+
processId: null,
409+
source: "agent",
410+
status: "completed",
411+
commandActions: [],
412+
aggregatedOutput: "late",
413+
exitCode: 0,
414+
durationMs: 1,
415+
};
416+
await projector.handleNotification(
417+
forCurrentTurn("item/started", {
418+
item: { ...lateTool, status: "inProgress", aggregatedOutput: null, exitCode: null },
419+
}),
420+
);
421+
await projector.handleNotification(forCurrentTurn("item/completed", { item: lateTool }));
422+
423+
await projector.handleNotification(
424+
forCurrentTurn("item/started", {
425+
item: { type: "agentMessage", id: "answer-2", phase: "final_answer", text: "" },
426+
}),
427+
);
428+
await projector.handleNotification(agentMessageDelta("Second candidate", "answer-2"));
429+
await projector.handleNotification(
430+
forCurrentTurn("item/completed", {
431+
item: {
432+
type: "agentMessage",
433+
id: "answer-2",
434+
phase: "final_answer",
435+
text: "Second candidate",
436+
},
437+
}),
438+
);
439+
await projector.handleNotification(
440+
turnCompleted([
441+
{
442+
type: "agentMessage",
443+
id: "answer-1",
444+
phase: "final_answer",
445+
text: "First candidate",
446+
},
447+
lateTool,
448+
{
449+
type: "agentMessage",
450+
id: "answer-2",
451+
phase: "final_answer",
452+
text: "Second candidate",
453+
},
454+
]),
455+
);
456+
457+
const candidateEvents = onAgentEvent.mock.calls
458+
.map((call) => call[0])
459+
.filter((event) => event.stream === "item" && event.data.kind === "answer_candidate")
460+
.map((event) => event.data);
461+
expect(candidateEvents).toEqual([
462+
expect.objectContaining({
463+
itemId: "answer-1",
464+
status: "candidate",
465+
progressText: "First candidate",
466+
hideFromChannelProgress: true,
467+
}),
468+
expect.objectContaining({
469+
itemId: "answer-1",
470+
status: "superseded",
471+
progressText: "First candidate",
472+
hideFromChannelProgress: true,
473+
}),
474+
expect.objectContaining({
475+
itemId: "answer-2",
476+
status: "candidate",
477+
progressText: "Second candidate",
478+
hideFromChannelProgress: true,
479+
}),
480+
expect.objectContaining({
481+
itemId: "answer-2",
482+
status: "selected",
483+
progressText: "Second candidate",
484+
hideFromChannelProgress: true,
485+
}),
486+
]);
487+
488+
const result = projector.buildResult(buildEmptyToolTelemetry());
489+
expect(result.assistantTexts).toEqual(["Second candidate"]);
490+
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("First candidate");
491+
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("answer_candidate");
492+
});
493+
494+
it("does not reselect a final answer superseded by late tool work", async () => {
495+
const onAgentEvent = vi.fn();
496+
const projector = await createProjector({
497+
...(await createParams()),
498+
onAgentEvent,
499+
});
500+
501+
await projector.handleNotification(
502+
forCurrentTurn("item/started", {
503+
item: { type: "agentMessage", id: "answer-1", phase: "final_answer", text: "" },
504+
}),
505+
);
506+
await projector.handleNotification(agentMessageDelta("First candidate", "answer-1"));
507+
await projector.handleNotification(
508+
forCurrentTurn("item/completed", {
509+
item: {
510+
type: "agentMessage",
511+
id: "answer-1",
512+
phase: "final_answer",
513+
text: "First candidate",
514+
},
515+
}),
516+
);
517+
518+
const lateTool = {
519+
type: "commandExecution",
520+
id: "late-tool",
521+
command: "/bin/bash -lc 'printf late'",
522+
cwd: "/workspace",
523+
processId: null,
524+
source: "agent",
525+
status: "completed",
526+
commandActions: [],
527+
aggregatedOutput: "late",
528+
exitCode: 0,
529+
durationMs: 1,
530+
};
531+
await projector.handleNotification(
532+
forCurrentTurn("item/started", {
533+
item: { ...lateTool, status: "inProgress", aggregatedOutput: null, exitCode: null },
534+
}),
535+
);
536+
await projector.handleNotification(forCurrentTurn("item/completed", { item: lateTool }));
537+
await projector.handleNotification(
538+
turnCompleted([
539+
{
540+
type: "agentMessage",
541+
id: "answer-1",
542+
phase: "final_answer",
543+
text: "First candidate",
544+
},
545+
lateTool,
546+
]),
547+
);
548+
549+
const candidateStatuses = onAgentEvent.mock.calls
550+
.map((call) => call[0])
551+
.filter((event) => event.stream === "item" && event.data.kind === "answer_candidate")
552+
.map((event) => event.data.status);
553+
expect(candidateStatuses).toEqual(["candidate", "superseded"]);
554+
});
555+
379556
it("streams final-answer assistant deltas into partial replies", async () => {
380557
const onAgentEvent = vi.fn();
381558
const onPartialReply = vi.fn();

extensions/codex/src/app-server/event-projector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ export class CodexAppServerEventProjector {
638638
this.toolProgressProjection.emitToolResultSummary(item);
639639
this.toolProgressProjection.emitToolResultOutput(item);
640640
}
641+
this.assistantProjection.finalizeAnswerCandidate(turn);
641642
this.activeCompactionItemIds.clear();
642643
await this.reasoningProjection.maybeEndReasoning();
643644
}

0 commit comments

Comments
 (0)