Skip to content

Commit 3bac0bc

Browse files
authored
fix(codex): stream final answer partials (#88730)
1 parent beb499b commit 3bac0bc

2 files changed

Lines changed: 36 additions & 1 deletion

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,29 @@ describe("CodexAppServerEventProjector", () => {
316316
expect(result.replayMetadata.replaySafe).toBe(true);
317317
});
318318

319+
it("streams final-answer assistant deltas into partial replies", async () => {
320+
const { onPartialReply, projector } = await createProjectorWithAssistantHooks();
321+
322+
await projector.handleNotification(
323+
forCurrentTurn("item/started", {
324+
item: {
325+
type: "agentMessage",
326+
id: "msg-final",
327+
phase: "final_answer",
328+
text: "",
329+
},
330+
}),
331+
);
332+
await projector.handleNotification(agentMessageDelta("hel", "msg-final"));
333+
await projector.handleNotification(agentMessageDelta("lo", "msg-final"));
334+
335+
expect(onPartialReply).toHaveBeenCalledTimes(2);
336+
expect(onPartialReply.mock.calls.map((call) => call[0])).toEqual([
337+
{ text: "hel", delta: "hel" },
338+
{ text: "hello", delta: "lo" },
339+
]);
340+
});
341+
319342
it("suppresses mirrored user prompt when the inbound message was already persisted", async () => {
320343
const params = await createParams();
321344
const projector = await createProjector({

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,11 @@ export class CodexAppServerEventProjector {
447447
if (!delta) {
448448
return;
449449
}
450+
this.rememberAssistantPhase(readItem(params.item));
451+
const phase = readString(params, "phase");
452+
if (phase) {
453+
this.assistantPhaseByItem.set(itemId, phase);
454+
}
450455
if (!this.assistantStarted) {
451456
this.assistantStarted = true;
452457
await this.params.onAssistantMessageStart?.();
@@ -456,10 +461,13 @@ export class CodexAppServerEventProjector {
456461
this.assistantTextByItem.set(itemId, text);
457462
if (this.isCommentaryAssistantItem(itemId)) {
458463
this.emitCommentaryProgress({ itemId, text });
464+
} else if (this.shouldStreamAssistantPartial(itemId)) {
465+
await this.params.onPartialReply?.({ text, delta });
459466
}
460467
// Codex app-server can emit multiple agentMessage items per turn, including
461468
// intermediate coordination/progress prose. Keep those deltas internal until
462-
// turn completion chooses the last assistant item as the user-visible reply.
469+
// their phase identifies terminal answer text or turn completion chooses the
470+
// last assistant item as the user-visible reply.
463471
}
464472

465473
private async handleReasoningDelta(
@@ -970,6 +978,10 @@ export class CodexAppServerEventProjector {
970978
return this.assistantPhaseByItem.get(itemId) === "commentary";
971979
}
972980

981+
private shouldStreamAssistantPartial(itemId: string): boolean {
982+
return this.assistantPhaseByItem.get(itemId) === "final_answer";
983+
}
984+
973985
private emitCommentaryProgress(params: { itemId: string; text: string }): void {
974986
const progressText = params.text.replace(/\s+/g, " ").trim();
975987
if (

0 commit comments

Comments
 (0)