Studio: real Codex parallel-call tab UI + in-process SDK spoof
Two paired changes that finally make the Codex parallel-calls fan-out visible as actual clickable tabs in the chat surface, plus a credit- free spoof that lets the whole pipeline run in dev / CI without ever touching the upstream API. 1. Real tab UI (frontend). The chat-adapter used to render the per-worker outputs as inline `[Codex tab 1/N] ...` text blocks in the assistant message body, which collapsed into one big run-on block once more than a handful of tokens had streamed. Now each `codex_*` SSE event is folded into `codexParallelState` and re-published as the `args.state` of a single tool-call part with `toolName === "codex_parallel"`. The assistant-ui surface dispatches that to the new `CodexParallelToolUI` wrapper, which mounts the existing `CodexParallelTabs` component -- one tab per worker, one Synthesis tab, click to switch. The stable `toolCallId` keeps assistant-ui updating the SAME card across stream yields rather than spawning new cards. `renderCodexTabsBlock` now returns the empty string so the message body no longer contains the labelled-text fallback (kept the function name so the rest of the adapter's `renderFullContent` / pin-signature paths are untouched). 2. Credit-free Codex SDK spoof (backend). New `studio/backend/core/inference/codex_spoof.py` exposes a drop-in subset of the upstream `openai_codex` surface (`AsyncCodex`, `AppServerConfig`, `ApprovalMode.deny_all`, `SandboxMode.read_only`, thread with `turn().stream()` + `run_streaming()` + `run()`) and emits deterministic per-tab streaming events tagged with the worker index, so flipping between tabs in the UI shows visibly distinct text. Activated by `UNSLOTH_CODEX_SPOOF=1`; `_import_codex` installs the spoof into `sys.modules` under both `openai_codex` and `codex_app_server` and the rest of the provider keeps running unchanged. OFF by default; production is unaffected. Six new tests cover the spoof itself (module install, env-flag gating, delta + completion event shape, per-tab tagging, provider import path, safety-kwargs resolution against the spoof). 69/69 tests pass with and without the flag; TypeScript clean.
This commit is contained in:
parent
ecf8bc767d
commit
f01011e4dd
6 changed files with 533 additions and 60 deletions
|
|
@ -358,7 +358,16 @@ def _import_codex() -> Any:
|
|||
name resolves, so this branch is reached only when (a) the user
|
||||
explicitly forces the provider via a stale stored config or (b)
|
||||
the install state changes between status probe and chat submit.
|
||||
|
||||
When ``UNSLOTH_CODEX_SPOOF=1`` is set we install the in-process
|
||||
spoof under ``openai_codex`` so the rest of the provider runs
|
||||
end-to-end (with deterministic per-tab replies and no upstream
|
||||
credit usage). The flag is OFF by default in production.
|
||||
"""
|
||||
from core.inference import codex_spoof
|
||||
|
||||
if codex_spoof.is_spoof_enabled():
|
||||
codex_spoof.install_as_openai_codex()
|
||||
for name in _SDK_MODULE_NAMES:
|
||||
if importlib.util.find_spec(name) is not None:
|
||||
return importlib.import_module(name)
|
||||
|
|
|
|||
269
studio/backend/core/inference/codex_spoof.py
Normal file
269
studio/backend/core/inference/codex_spoof.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
"""Local-only Codex SDK spoof for credit-free dev / CI runs.
|
||||
|
||||
Activated by ``UNSLOTH_CODEX_SPOOF=1`` -- the gate in ``codex_provider``
|
||||
swaps in this module's symbols for ``openai_codex`` so the rest of the
|
||||
provider can run end-to-end (thread_start, turn().stream(), run_streaming,
|
||||
run(), AppServerConfig, ApprovalMode.deny_all, SandboxMode.read_only)
|
||||
without ever touching the real CLI or upstream API.
|
||||
|
||||
The fake stream emits one ``message.delta`` per visible token plus a
|
||||
trailing ``ItemCompletedNotification(item=agentMessage)`` so both the
|
||||
delta path and the completion-only fallback in
|
||||
``_stream_thread_run`` exercise their real branches.
|
||||
|
||||
The replies are deterministic and tagged with the model + tab index so
|
||||
the parallel-calls fan-out shows visibly distinct text per tab, which
|
||||
is the point of the tab UI demo. The spoof intentionally does NOT
|
||||
emit command / file / tool deltas -- those would be denylisted by
|
||||
``_coerce_text`` and never reach the user, and we want the demo to
|
||||
show the same shape Codex normally streams: pure agent text.
|
||||
|
||||
This file is import-safe: it has no side effects on import. It MUST
|
||||
never be selected unless the env flag is set explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
|
||||
SPOOF_ENV_VAR = "UNSLOTH_CODEX_SPOOF"
|
||||
|
||||
|
||||
def is_spoof_enabled() -> bool:
|
||||
"""Return True when the env flag is set to an explicit truthy value."""
|
||||
return os.environ.get(SPOOF_ENV_VAR, "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
class ApprovalMode(str, Enum):
|
||||
deny_all = "deny_all"
|
||||
auto_review = "auto_review"
|
||||
|
||||
|
||||
class SandboxMode(str, Enum):
|
||||
read_only = "read_only"
|
||||
workspace_write = "workspace_write"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppServerConfig:
|
||||
env: Optional[dict[str, str]] = None
|
||||
codex_bin: Optional[str] = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AgentMessage:
|
||||
type: str = "agentMessage"
|
||||
text: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ItemRoot:
|
||||
root: _AgentMessage
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ItemCompletedNotification:
|
||||
"""Mirrors openai_codex.api.ItemCompletedNotification shape.
|
||||
|
||||
The class name is matched verbatim by ``_completed_agent_message_text``
|
||||
so the completion-only fallback in ``_stream_thread_run`` recognises
|
||||
these payloads.
|
||||
"""
|
||||
|
||||
item: _ItemRoot
|
||||
type: str = "ItemCompletedNotification"
|
||||
|
||||
|
||||
def _tab_id_from_system(system: Optional[str]) -> int:
|
||||
"""Pull the synthetic ``[tab N]`` marker the provider prepends to
|
||||
each parallel worker's system prompt (when present), else 0."""
|
||||
if not system:
|
||||
return 0
|
||||
for line in system.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("[tab ") and line.endswith("]"):
|
||||
try:
|
||||
return int(line[len("[tab "): -1].split("/")[0])
|
||||
except ValueError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _spoof_response_text(model: str, prompt: str, tab_id: int) -> str:
|
||||
"""Deterministic but visibly per-tab response.
|
||||
|
||||
Format keeps each parallel worker's reply distinct so when the user
|
||||
clicks between tabs they see different text -- the whole point of
|
||||
the tab UI demo.
|
||||
"""
|
||||
prompt_clean = (prompt or "").strip().replace("\n", " ")
|
||||
if len(prompt_clean) > 120:
|
||||
prompt_clean = prompt_clean[:117] + "..."
|
||||
tab_suffix = f" (worker {tab_id})" if tab_id else ""
|
||||
return (
|
||||
f"[spoof reply from {model}{tab_suffix}] "
|
||||
f"You said: {prompt_clean!r}. "
|
||||
f"This response is generated by the local Codex spoof "
|
||||
f"(UNSLOTH_CODEX_SPOOF=1) -- no upstream tokens were used."
|
||||
)
|
||||
|
||||
|
||||
class _TurnStream:
|
||||
"""Async iterator returned by ``Turn.stream()``.
|
||||
|
||||
Emits a sequence of dict-shaped ``message.delta`` events (one word at
|
||||
a time, so the chat-adapter's streaming surface gets exercised) and
|
||||
closes with an ``ItemCompletedNotification`` carrying the same final
|
||||
text. Matches the dual delta + completion shape the real upstream
|
||||
SDK emits.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str, delay_s: float = 0.01) -> None:
|
||||
self._text = text
|
||||
self._delay_s = delay_s
|
||||
self._iter: Optional[AsyncIterator[Any]] = None
|
||||
|
||||
def __aiter__(self) -> "_TurnStream":
|
||||
return self
|
||||
|
||||
async def _generate(self) -> AsyncIterator[Any]:
|
||||
# One word at a time gives a visible streaming effect in the UI
|
||||
# without flooding the SSE channel.
|
||||
words = self._text.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
chunk = (" " + word) if i > 0 else word
|
||||
yield {"type": "message.delta", "delta": chunk}
|
||||
if self._delay_s > 0:
|
||||
await asyncio.sleep(self._delay_s)
|
||||
# Final completion event -- the canonical SDK always emits this,
|
||||
# and ``_stream_thread_run`` uses it as its fallback when no
|
||||
# deltas arrived (so worth keeping even when deltas did stream).
|
||||
yield _ItemCompletedNotification(item=_ItemRoot(root=_AgentMessage(text=self._text)))
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
if self._iter is None:
|
||||
self._iter = self._generate()
|
||||
return await self._iter.__anext__()
|
||||
|
||||
# Some SDK revs let callers ``async with stream:``. Treat as a no-op.
|
||||
async def __aenter__(self) -> "_TurnStream":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Turn:
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def stream(self) -> _TurnStream:
|
||||
return _TurnStream(self._text)
|
||||
|
||||
|
||||
class _Thread:
|
||||
def __init__(self, model: str, system: Optional[str]) -> None:
|
||||
self._model = model
|
||||
self._system = system
|
||||
self._tab_id = _tab_id_from_system(system)
|
||||
|
||||
# Canonical path: ``thread.turn(prompt).stream()``.
|
||||
def turn(self, prompt: str) -> _Turn:
|
||||
text = _spoof_response_text(self._model, prompt, self._tab_id)
|
||||
return _Turn(text)
|
||||
|
||||
# Legacy path: ``async for event in thread.run_streaming(prompt)``.
|
||||
def run_streaming(self, prompt: str) -> _TurnStream:
|
||||
text = _spoof_response_text(self._model, prompt, self._tab_id)
|
||||
return _TurnStream(text)
|
||||
|
||||
# Buffered fallback: ``await thread.run(prompt)`` returning a result
|
||||
# whose ``.text`` (or ``.final_response``) is the answer.
|
||||
async def run(self, prompt: str) -> Any:
|
||||
from types import SimpleNamespace
|
||||
|
||||
text = _spoof_response_text(self._model, prompt, self._tab_id)
|
||||
await asyncio.sleep(0)
|
||||
return SimpleNamespace(text=text, final_response=text)
|
||||
|
||||
|
||||
class AsyncCodex:
|
||||
"""Spoof drop-in for ``openai_codex.AsyncCodex``.
|
||||
|
||||
Accepts the same ``config=AppServerConfig(...)`` constructor signature
|
||||
Studio passes through. ``thread_start`` returns a ``_Thread`` whose
|
||||
turn / run / run_streaming methods emit deterministic streams.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[AppServerConfig] = None, **_kw: Any) -> None:
|
||||
self._config = config or AppServerConfig()
|
||||
self._started_at = time.time()
|
||||
|
||||
async def thread_start(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
base_instructions: Optional[str] = None,
|
||||
system: Optional[str] = None,
|
||||
approval_mode: Optional[ApprovalMode] = None,
|
||||
sandbox: Optional[SandboxMode] = None,
|
||||
**_extra: Any,
|
||||
) -> _Thread:
|
||||
await asyncio.sleep(0)
|
||||
# Either kwarg path is accepted -- the real provider tries
|
||||
# ``base_instructions`` first then falls back to ``system``.
|
||||
sys_text = base_instructions if base_instructions is not None else system
|
||||
return _Thread(model=model, system=sys_text)
|
||||
|
||||
|
||||
def install_as_openai_codex() -> None:
|
||||
"""Insert this module into ``sys.modules`` under the names the real
|
||||
SDK would use, so ``importlib.util.find_spec`` succeeds and the
|
||||
provider's existing import path picks it up unchanged.
|
||||
|
||||
Idempotent: a second call is a no-op. Called from ``codex_provider``
|
||||
inside ``_import_codex`` when the env flag is set.
|
||||
"""
|
||||
import sys
|
||||
|
||||
for name in ("openai_codex", "codex_app_server"):
|
||||
if name in sys.modules:
|
||||
continue
|
||||
sys.modules[name] = _build_module_alias(name)
|
||||
|
||||
|
||||
def _build_module_alias(name: str) -> Any:
|
||||
"""Build a module-like object exposing the same public symbols as
|
||||
this file, under the requested import name. Using a fresh module
|
||||
object (rather than aliasing ``codex_spoof`` directly) means the
|
||||
SDK's ``__name__`` lookups (e.g. for ``ImportError`` messages) get
|
||||
the real upstream-style name.
|
||||
"""
|
||||
import types
|
||||
import importlib.machinery
|
||||
|
||||
mod = types.ModuleType(name)
|
||||
# ``importlib.util.find_spec(name)`` walks ``sys.modules[name].__spec__``
|
||||
# first, so an empty spec is required for the provider's existing
|
||||
# availability probe to recognise the spoof.
|
||||
mod.__spec__ = importlib.machinery.ModuleSpec(name, loader=None)
|
||||
mod.AsyncCodex = AsyncCodex # type: ignore[attr-defined]
|
||||
mod.AppServerConfig = AppServerConfig # type: ignore[attr-defined]
|
||||
mod.ApprovalMode = ApprovalMode # type: ignore[attr-defined]
|
||||
mod.SandboxMode = SandboxMode # type: ignore[attr-defined]
|
||||
mod.__spoof__ = True # marker -- tests can assert this
|
||||
return mod
|
||||
|
|
@ -2353,3 +2353,118 @@ class TestCodexDoneSentinelExactMatch:
|
|||
assert 'if "[DONE]" in line' not in stripped, (
|
||||
"Codex SSE wrapper still uses substring [DONE] check: " + stripped
|
||||
)
|
||||
|
||||
|
||||
class TestCodexSpoofModule:
|
||||
"""Ensure the in-process Codex SDK spoof installs cleanly under the
|
||||
env flag, satisfies _import_codex, exposes the SDK surface the
|
||||
provider uses, and streams deterministic per-tab text. The spoof
|
||||
is the credit-free path that the rest of the test suite (and the
|
||||
UI demo) rides on, so a regression here would silently break
|
||||
every downstream consumer.
|
||||
"""
|
||||
|
||||
def test_install_swaps_in_module_when_flag_set(self, monkeypatch):
|
||||
# Ensure clean import state.
|
||||
import sys
|
||||
for name in ("openai_codex", "codex_app_server"):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
from core.inference import codex_spoof
|
||||
monkeypatch.setenv(codex_spoof.SPOOF_ENV_VAR, "1")
|
||||
assert codex_spoof.is_spoof_enabled()
|
||||
codex_spoof.install_as_openai_codex()
|
||||
|
||||
import openai_codex # type: ignore[import-not-found]
|
||||
assert getattr(openai_codex, "__spoof__", False) is True
|
||||
assert hasattr(openai_codex, "AsyncCodex")
|
||||
assert hasattr(openai_codex, "AppServerConfig")
|
||||
assert openai_codex.ApprovalMode.deny_all
|
||||
assert openai_codex.SandboxMode.read_only
|
||||
|
||||
def test_flag_disabled_does_not_install(self, monkeypatch):
|
||||
import sys
|
||||
for name in ("openai_codex", "codex_app_server"):
|
||||
sys.modules.pop(name, None)
|
||||
from core.inference import codex_spoof
|
||||
monkeypatch.delenv(codex_spoof.SPOOF_ENV_VAR, raising=False)
|
||||
assert not codex_spoof.is_spoof_enabled()
|
||||
|
||||
def test_spoof_stream_emits_deltas_and_completion(self):
|
||||
import asyncio
|
||||
from core.inference import codex_spoof
|
||||
|
||||
async def run():
|
||||
codex = codex_spoof.AsyncCodex(
|
||||
config=codex_spoof.AppServerConfig(env={})
|
||||
)
|
||||
thread = await codex.thread_start(
|
||||
model="gpt-5.4-mini",
|
||||
base_instructions=None,
|
||||
approval_mode=codex_spoof.ApprovalMode.deny_all,
|
||||
sandbox=codex_spoof.SandboxMode.read_only,
|
||||
)
|
||||
events = []
|
||||
async for ev in thread.turn("hello").stream():
|
||||
events.append(ev)
|
||||
return events
|
||||
|
||||
events = asyncio.run(run())
|
||||
deltas = [e for e in events if isinstance(e, dict) and e.get("type") == "message.delta"]
|
||||
assert len(deltas) >= 3, "spoof should stream multiple deltas"
|
||||
last = events[-1]
|
||||
assert type(last).__name__ == "_ItemCompletedNotification"
|
||||
# Must carry the worker tag when no [tab N] marker is in the system prompt.
|
||||
full_text = last.item.root.text
|
||||
assert "spoof reply from gpt-5.4-mini" in full_text
|
||||
assert "no upstream tokens" in full_text
|
||||
|
||||
def test_spoof_tags_per_tab(self):
|
||||
import asyncio
|
||||
from core.inference import codex_spoof
|
||||
|
||||
async def reply_for_tab(idx: int) -> str:
|
||||
codex = codex_spoof.AsyncCodex()
|
||||
thread = await codex.thread_start(
|
||||
model="gpt-5.4-mini",
|
||||
base_instructions=f"[tab {idx}/3]",
|
||||
)
|
||||
result = await thread.run("explain LoRA")
|
||||
return result.text
|
||||
|
||||
async def _gather():
|
||||
return await asyncio.gather(
|
||||
reply_for_tab(1),
|
||||
reply_for_tab(2),
|
||||
reply_for_tab(3),
|
||||
)
|
||||
|
||||
tab_replies = asyncio.run(_gather())
|
||||
# Each tab must mention its own worker index, so the UI tabs
|
||||
# show visibly distinct text when clicked.
|
||||
for i, reply in enumerate(tab_replies, start=1):
|
||||
assert f"worker {i}" in reply, f"tab {i} missing its tag: {reply}"
|
||||
|
||||
def test_provider_picks_up_spoof_via_import(self, monkeypatch):
|
||||
import sys
|
||||
for name in ("openai_codex", "codex_app_server"):
|
||||
sys.modules.pop(name, None)
|
||||
from core.inference import codex_provider, codex_spoof
|
||||
monkeypatch.setenv(codex_spoof.SPOOF_ENV_VAR, "1")
|
||||
mod = codex_provider._import_codex()
|
||||
assert getattr(mod, "__spoof__", False) is True
|
||||
|
||||
def test_safety_kwargs_resolve_against_spoof(self, monkeypatch):
|
||||
import sys
|
||||
for name in ("openai_codex", "codex_app_server"):
|
||||
sys.modules.pop(name, None)
|
||||
from core.inference import codex_provider, codex_spoof
|
||||
monkeypatch.setenv(codex_spoof.SPOOF_ENV_VAR, "1")
|
||||
codex_provider._import_codex() # ensures install
|
||||
kwargs = codex_provider._safe_thread_safety_kwargs()
|
||||
# Spoof exports ApprovalMode.deny_all + SandboxMode.read_only,
|
||||
# so the provider must be able to pin both without falling
|
||||
# through to the unsafe-defaults gate.
|
||||
assert kwargs, "safety kwargs missing -- provider would fail closed"
|
||||
assert str(kwargs["approval_mode"]).endswith("deny_all")
|
||||
assert str(kwargs["sandbox"]).endswith("read_only")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { ToolGroup } from "@/components/assistant-ui/tool-group";
|
||||
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
|
||||
import { CodexParallelToolUI } from "@/components/assistant-ui/tool-ui-codex-parallel";
|
||||
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
|
||||
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
||||
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
||||
|
|
@ -1315,6 +1316,7 @@ const AssistantMessage: FC = () => {
|
|||
python: PythonToolUI,
|
||||
terminal: TerminalToolUI,
|
||||
code_execution: CodeExecutionToolUI,
|
||||
codex_parallel: CodexParallelToolUI,
|
||||
image_generation: ImageGenerationToolUI,
|
||||
},
|
||||
Fallback: ToolFallback,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Tool-call renderer for Codex parallel-calls fan-out.
|
||||
*
|
||||
* Driven by the chat-adapter pushing a tool-call part with
|
||||
* ``toolName === "codex_parallel"`` whose ``args.state`` is a
|
||||
* ``CodexParallelState`` value. We just unpack the state and hand it
|
||||
* to the existing ``CodexParallelTabs`` component. Mounted via the
|
||||
* ``tools.by_name`` map on ``MessagePrimitive.Parts`` in
|
||||
* ``thread.tsx`` so it renders inline above the assistant's prose.
|
||||
*/
|
||||
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { memo } from "react";
|
||||
import {
|
||||
CodexParallelTabs,
|
||||
EMPTY_CODEX_PARALLEL_STATE,
|
||||
type CodexParallelState,
|
||||
} from "@/features/chat/components/codex-parallel-tabs";
|
||||
|
||||
const CodexParallelToolUIImpl: ToolCallMessagePartComponent = ({ args }) => {
|
||||
const state = (args as { state?: CodexParallelState } | undefined)?.state;
|
||||
return <CodexParallelTabs state={state ?? EMPTY_CODEX_PARALLEL_STATE} />;
|
||||
};
|
||||
|
||||
export const CodexParallelToolUI = memo(
|
||||
CodexParallelToolUIImpl,
|
||||
) as ToolCallMessagePartComponent;
|
||||
CodexParallelToolUI.displayName = "CodexParallelToolUI";
|
||||
|
|
@ -58,6 +58,13 @@ import {
|
|||
hasClosedThinkTag,
|
||||
parseAssistantContent,
|
||||
} from "../utils/parse-assistant-content";
|
||||
import {
|
||||
EMPTY_CODEX_PARALLEL_STATE,
|
||||
hasCodexParallelContent,
|
||||
reduceCodexParallelState,
|
||||
type CodexParallelEvent,
|
||||
type CodexParallelState,
|
||||
} from "../components/codex-parallel-tabs";
|
||||
import {
|
||||
generateAudio,
|
||||
listCachedGguf,
|
||||
|
|
@ -1714,29 +1721,47 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// dict keyed by tab_id and re-assembling cumulativeText from
|
||||
// scratch on every codex event puts each tab's text under its
|
||||
// own header regardless of arrival interleaving.
|
||||
const codexTabBuffers = new Map<number, string>();
|
||||
const codexTabClosed = new Set<number>();
|
||||
const codexTabError = new Map<number, string>();
|
||||
let codexTotalTabs = 0;
|
||||
// Per-tab Codex fan-out state. Each codex_* SSE event is folded
|
||||
// into ``codexParallelState`` via the pure reducer in
|
||||
// ``components/codex-parallel-tabs``. The state is re-published
|
||||
// on every yield as the ``args`` of a single tool-call part with
|
||||
// ``toolName === "codex_parallel"`` so the assistant-ui surface
|
||||
// can render real clickable tabs (one per worker plus a
|
||||
// Synthesis tab) instead of inline ``[Codex tab N]`` headings.
|
||||
// The stable toolCallId keeps assistant-ui updating the same
|
||||
// part across stream yields rather than spawning new cards.
|
||||
let codexParallelState: CodexParallelState = EMPTY_CODEX_PARALLEL_STATE;
|
||||
let codexGatherEmitted = false;
|
||||
const CODEX_PARALLEL_TOOL_ID = "codex_parallel_main";
|
||||
|
||||
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) {
|
||||
const header = codexTotalTabs
|
||||
? `[Codex tab ${id}/${codexTotalTabs}]`
|
||||
: `[Codex tab ${id}]`;
|
||||
lines.push(`\n\n${header}\n${codexTabBuffers.get(id) ?? ""}`);
|
||||
if (codexTabError.has(id)) {
|
||||
lines.push(`\n[Codex tab ${id} error: ${codexTabError.get(id)}]\n`);
|
||||
}
|
||||
if (codexTabClosed.has(id)) {
|
||||
lines.push("\n");
|
||||
}
|
||||
function upsertCodexParallelToolPart(): void {
|
||||
if (!hasCodexParallelContent(codexParallelState)) return;
|
||||
const args = { state: codexParallelState };
|
||||
const argsText = "";
|
||||
const idx = toolCallParts.findIndex(
|
||||
(p) => p.toolCallId === CODEX_PARALLEL_TOOL_ID,
|
||||
);
|
||||
const part: ToolCallMessagePart = {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: CODEX_PARALLEL_TOOL_ID,
|
||||
toolName: "codex_parallel",
|
||||
argsText,
|
||||
args: args as unknown as ToolCallMessagePart["args"],
|
||||
};
|
||||
if (idx === -1) {
|
||||
toolCallParts.push(part);
|
||||
} else {
|
||||
toolCallParts[idx] = part;
|
||||
}
|
||||
return lines.join("");
|
||||
}
|
||||
|
||||
// No inline `[Codex tab N]` block in the message body any more --
|
||||
// the tab UI is mounted as a tool-call part above. The function
|
||||
// is kept (returning the empty string) so the rest of the
|
||||
// adapter's renderFullContent() / pin signature paths are
|
||||
// unchanged across the file.
|
||||
function renderCodexTabsBlock(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Codex parallel-calls fan-out renders the labeled tab outputs
|
||||
|
|
@ -2222,50 +2247,70 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
chunk as unknown as { _toolEvent?: Record<string, unknown> }
|
||||
)._toolEvent;
|
||||
if (toolEvent !== undefined) {
|
||||
// Codex parallel-calls fan-out events: route chunks
|
||||
// into per-tab buffers keyed by tab_id, then render
|
||||
// the whole codex block from scratch each event so
|
||||
// concurrent tabs cannot interleave under the wrong
|
||||
// header. `codex_gather` carries the synthesis
|
||||
// payload which the backend also emits as a normal
|
||||
// content delta later in the same SSE stream, so we
|
||||
// only render a divider here to avoid duplicating
|
||||
// the synthesis text.
|
||||
// Codex parallel-calls fan-out events. Each event is
|
||||
// folded into ``codexParallelState`` and re-published
|
||||
// as the ``args.state`` of the ``codex_parallel`` tool-
|
||||
// call part, so the assistant-ui surface renders one
|
||||
// tab per worker plus a Synthesis tab the user can
|
||||
// click between. ``codex_gather`` flips the flag so
|
||||
// ``renderFullContent`` knows the synthesis stream is
|
||||
// about to arrive on the regular content-delta path.
|
||||
if (typeof toolEvent.type === "string" && toolEvent.type.startsWith("codex_")) {
|
||||
if (toolEvent.type === "codex_tab_open") {
|
||||
const tabId = Number(toolEvent.tab_id);
|
||||
const evType = toolEvent.type;
|
||||
const tabId = Number(toolEvent.tab_id);
|
||||
let reduced: CodexParallelEvent | null = null;
|
||||
if (evType === "codex_tab_open" && Number.isFinite(tabId)) {
|
||||
const total = Number(toolEvent.total_tabs);
|
||||
if (Number.isFinite(tabId)) {
|
||||
if (!codexTabBuffers.has(tabId)) {
|
||||
codexTabBuffers.set(tabId, "");
|
||||
}
|
||||
if (Number.isFinite(total) && total > codexTotalTabs) {
|
||||
codexTotalTabs = total;
|
||||
}
|
||||
reduced = {
|
||||
type: "codex_tab_open",
|
||||
tab_id: tabId,
|
||||
query:
|
||||
typeof toolEvent.query === "string"
|
||||
? toolEvent.query
|
||||
: undefined,
|
||||
total_tabs: Number.isFinite(total) ? total : undefined,
|
||||
};
|
||||
} else if (evType === "codex_tab_chunk" && Number.isFinite(tabId)) {
|
||||
const text =
|
||||
typeof toolEvent.text === "string" ? toolEvent.text : "";
|
||||
if (text) {
|
||||
reduced = {
|
||||
type: "codex_tab_chunk",
|
||||
tab_id: tabId,
|
||||
text,
|
||||
};
|
||||
}
|
||||
} else if (toolEvent.type === "codex_tab_chunk") {
|
||||
const tabId = Number(toolEvent.tab_id);
|
||||
const text = typeof toolEvent.text === "string" ? toolEvent.text : "";
|
||||
if (Number.isFinite(tabId) && text) {
|
||||
const prev = codexTabBuffers.get(tabId) ?? "";
|
||||
codexTabBuffers.set(tabId, prev + text);
|
||||
}
|
||||
} else if (toolEvent.type === "codex_tab_error") {
|
||||
const tabId = Number(toolEvent.tab_id);
|
||||
const err = typeof toolEvent.error === "string" ? toolEvent.error : "error";
|
||||
if (Number.isFinite(tabId)) {
|
||||
codexTabError.set(tabId, err);
|
||||
if (!codexTabBuffers.has(tabId)) {
|
||||
codexTabBuffers.set(tabId, "");
|
||||
}
|
||||
}
|
||||
} else if (toolEvent.type === "codex_tab_close") {
|
||||
const tabId = Number(toolEvent.tab_id);
|
||||
if (Number.isFinite(tabId)) {
|
||||
codexTabClosed.add(tabId);
|
||||
}
|
||||
} else if (toolEvent.type === "codex_gather") {
|
||||
} else if (evType === "codex_tab_error" && Number.isFinite(tabId)) {
|
||||
reduced = {
|
||||
type: "codex_tab_error",
|
||||
tab_id: tabId,
|
||||
error:
|
||||
typeof toolEvent.error === "string"
|
||||
? toolEvent.error
|
||||
: "error",
|
||||
};
|
||||
} else if (evType === "codex_tab_close" && Number.isFinite(tabId)) {
|
||||
reduced = { type: "codex_tab_close", tab_id: tabId };
|
||||
} else if (evType === "codex_gather") {
|
||||
codexGatherEmitted = true;
|
||||
reduced = {
|
||||
type: "codex_gather",
|
||||
summary:
|
||||
typeof toolEvent.summary === "string"
|
||||
? toolEvent.summary
|
||||
: undefined,
|
||||
tab_count:
|
||||
typeof toolEvent.tab_count === "number"
|
||||
? toolEvent.tab_count
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (reduced) {
|
||||
codexParallelState = reduceCodexParallelState(
|
||||
codexParallelState,
|
||||
reduced,
|
||||
);
|
||||
upsertCodexParallelToolPart();
|
||||
}
|
||||
const codexParts = parseAssistantContent(renderFullContent());
|
||||
yield {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue