diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index c065e4f1459..f95ea8a8176 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -316,6 +316,29 @@ describe("CodexAppServerEventProjector", () => { expect(result.replayMetadata.replaySafe).toBe(true); }); + it("streams final-answer assistant deltas into partial replies", async () => { + const { onPartialReply, projector } = await createProjectorWithAssistantHooks(); + + await projector.handleNotification( + forCurrentTurn("item/started", { + item: { + type: "agentMessage", + id: "msg-final", + phase: "final_answer", + text: "", + }, + }), + ); + await projector.handleNotification(agentMessageDelta("hel", "msg-final")); + await projector.handleNotification(agentMessageDelta("lo", "msg-final")); + + expect(onPartialReply).toHaveBeenCalledTimes(2); + expect(onPartialReply.mock.calls.map((call) => call[0])).toEqual([ + { text: "hel", delta: "hel" }, + { text: "hello", delta: "lo" }, + ]); + }); + it("suppresses mirrored user prompt when the inbound message was already persisted", async () => { const params = await createParams(); const projector = await createProjector({ diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 0f38033a695..e73132bd953 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -447,6 +447,11 @@ export class CodexAppServerEventProjector { if (!delta) { return; } + this.rememberAssistantPhase(readItem(params.item)); + const phase = readString(params, "phase"); + if (phase) { + this.assistantPhaseByItem.set(itemId, phase); + } if (!this.assistantStarted) { this.assistantStarted = true; await this.params.onAssistantMessageStart?.(); @@ -456,10 +461,13 @@ export class CodexAppServerEventProjector { this.assistantTextByItem.set(itemId, text); if (this.isCommentaryAssistantItem(itemId)) { this.emitCommentaryProgress({ itemId, text }); + } else if (this.shouldStreamAssistantPartial(itemId)) { + await this.params.onPartialReply?.({ text, delta }); } // Codex app-server can emit multiple agentMessage items per turn, including // intermediate coordination/progress prose. Keep those deltas internal until - // turn completion chooses the last assistant item as the user-visible reply. + // their phase identifies terminal answer text or turn completion chooses the + // last assistant item as the user-visible reply. } private async handleReasoningDelta( @@ -970,6 +978,10 @@ export class CodexAppServerEventProjector { return this.assistantPhaseByItem.get(itemId) === "commentary"; } + private shouldStreamAssistantPartial(itemId: string): boolean { + return this.assistantPhaseByItem.get(itemId) === "final_answer"; + } + private emitCommentaryProgress(params: { itemId: string; text: string }): void { const progressText = params.text.replace(/\s+/g, " ").trim(); if (