Studio: render Codex parallel-calls synthesis AFTER the per-tab blocks

End-to-end probe of a 2-way parallel-calls Codex turn revealed the
visible assistant message looked like:

  <synthesis text>

  [Codex tab 1/2]
  <tab 1 text>

  [Codex tab 2/2]
  <tab 2 text>

  --- Synthesis ---

The synthesis came first (no label), then the tabs, then a trailing
"--- Synthesis ---" line with nothing below it. The header comment in
chat-adapter.ts said the intent was "[tabs] ... [synthesis]" but the
implementation prepended cumulativeText (which carries the synthesis
deltas the backend emits after codex_gather) before the tabs.

Fix: split renderCodexBuffer into renderCodexTabsBlock (pure per-tab
rendering, no trailing divider) and reorder renderFullContent so when
Codex fan-out is active the output is:

  [Codex tab 1/N]
  <tab 1>

  [Codex tab N/N]
  <tab N>

  --- Synthesis ---

  <synthesis text from cumulativeText>

The non-Codex case still returns cumulativeText unchanged.
This commit is contained in:
Daniel Han 2026-05-25 14:06:19 +00:00
commit b7862388fa

View file

@ -1143,8 +1143,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let codexTotalTabs = 0;
let codexGatherEmitted = false;
function renderCodexBuffer(): string {
if (codexTabBuffers.size === 0 && !codexGatherEmitted) return "";
function renderCodexTabsBlock(): string {
if (codexTabBuffers.size === 0) return "";
const lines: string[] = [];
const ids = [...codexTabBuffers.keys()].sort((a, b) => a - b);
for (const id of ids) {
@ -1159,21 +1159,28 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
lines.push("\n");
}
}
if (codexGatherEmitted) {
lines.push("\n--- Synthesis ---\n");
}
return lines.join("");
}
// All chat-content yields go through this so the Codex per-tab
// output is always concatenated with the normal SSE text. The
// synthesis is emitted by the backend BOTH as a `codex_gather`
// tool event AND as a normal content delta; rendering both
// would duplicate it. Render the tabs above (header / tab text)
// separately from cumulativeText (which carries the synthesis
// content delta) so the user sees `[tabs] ... [synthesis]`.
// Codex parallel-calls fan-out renders the labeled tab outputs
// first, then a "--- Synthesis ---" divider, then the synthesis
// text the backend streams as plain content deltas after the
// `codex_gather` event. Earlier the synthesis came BEFORE the
// tabs (since cumulativeText was prepended) which left the
// trailing "--- Synthesis ---" line orphaned at the bottom with
// no synthesis text under it, confusing users. When there is no
// Codex fan-out (single-tab Codex turn or any other provider)
// the function falls back to the plain cumulativeText.
function renderFullContent(): string {
return cumulativeText + renderCodexBuffer();
const tabsBlock = renderCodexTabsBlock();
if (!tabsBlock && !codexGatherEmitted) {
return cumulativeText;
}
const parts: string[] = [];
if (tabsBlock) parts.push(tabsBlock);
if (codexGatherEmitted) parts.push("\n\n--- Synthesis ---\n\n");
if (cumulativeText) parts.push(cumulativeText);
return parts.join("");
}
// Tracks whether we are currently inside a `<think>` block opened by
// a `delta.reasoning_content` chunk. Kimi (kimi-k2.6, kimi-k2-thinking)