fix(codex): stream final answer partials (#88730)

This commit is contained in:
Peter Steinberger
2026-05-31 19:00:44 +01:00
committed by GitHub
parent beb499b4d1
commit 3bac0bcbfb
2 changed files with 36 additions and 1 deletions

View File

@@ -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({

View File

@@ -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 (