diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md
index bf01104632d..072a5dcb00a 100644
--- a/docs-site/src/content/docs/fr/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md
@@ -16,6 +16,9 @@ ocx init
1. **Choix d’un fournisseur** — sélectionnez l’un des 107 préréglages intégrés au registre, ou `custom` pour saisir une
URL de base et un adaptateur.
2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`.
+
+ **TRAE CN** utilise le CLI officiel déjà connecté et ne nécessite pas de clé API. Installez le CLI, connectez-vous et laissez le champ de clé vide dans l’assistant. Consultez la [configuration de TRAE CN (en anglais)](/guides/providers/#trae-cn-official-acp).
+
3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle.
4. **Port proxy** — la valeur par défaut est `10100`.
5. **Injecter dans Codex ?** — avec une configuration de bouclage normale, opencodex ajoute une valeur racine `openai_base_url` à
diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md
index dbc026ea5bc..14d74530d82 100644
--- a/docs-site/src/content/docs/fr/guides/providers.md
+++ b/docs-site/src/content/docs/fr/guides/providers.md
@@ -310,6 +310,8 @@ existante n'est pas concernée.
## 3. Catalogue des clés API
+**TRAE CN** est un adaptateur ACP expérimental à configurer manuellement. Le répertoire contient uniquement une référence non vérifiée ; aucun préréglage intégré n’est activé. Consultez la [configuration de TRAE CN (en anglais)](/guides/providers/#trae-cn-official-acp).
+
opencodex fournit 107 préréglages intégrés : 86 à clé, 17 OAuth, trois locaux et un préréglage par défaut de
transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du
fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables :
diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md
index cfb968ec901..56972da1afc 100644
--- a/docs-site/src/content/docs/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/getting-started/quickstart.md
@@ -23,6 +23,8 @@ ocx init
1. **Pick a provider** — choose one of the 107 built-in registry presets or `custom` to type a base
URL and adapter.
2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`.
+ For **TRAE CN**, press Enter to leave the wizard’s key prompt empty: the signed-in official
+ CLI owns authentication. Install and sign in to the CLI first; see [TRAE CN setup](/guides/providers/#trae-cn-official-acp).
3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id.
4. **Proxy port** — defaults to `10100`.
5. **Inject into Codex?** — on a normal loopback setup, opencodex adds a root `openai_base_url` to
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 678dba19638..3c0b2dbc4f7 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -785,9 +785,7 @@ On upgrade, startup adds Pro to a saved model list only when it exactly matches
## 3. API-key catalog
-opencodex ships 107 built-in presets: 86 key-based, 17 OAuth, three local, and one default
-ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard,
-validates the key, and stores it; validation is provider-specific. Notable entries:
+opencodex ships 107 built-in presets: 86 in the key-based registry category, 17 OAuth, three local, and one default ChatGPT-forward preset. Keyless CLI adapters use their official CLI for authentication; experimental TRAE CN is available through manual configuration only. For API-key providers, **Add provider** opens the provider’s dashboard, validates the key, and stores it; validation is provider-specific. Notable entries:
**ClinePass** uses a Cline API key with the [official subscription catalog](https://docs.cline.bot/getting-started/clinepass)
and [Chat Completions endpoint](https://docs.cline.bot/api/chat-completions), operated by Cline Bot Inc. under
@@ -1486,6 +1484,8 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C
- **Governance Status:** Whether routing this vendor automation surface behind a proxy for a third-party agent satisfies CodeBuddy's acceptable-use terms is an open question flagged for maintainer security review (see the governance note in the provider registry entry). Treat this provider as pending that review, and keep the tool bridge's ownership boundary in mind: the nested CLI advertises tools but never executes them, and approval, sandboxing, and execution remain with the external Codex client.
- **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement.
+### [TRAE CN (official ACP)](/guides/trae-cn/)
+
### Official Qoder CLI (Global & CN)
OpenCodex provides official adapter support for Qoder through the `qoder` (Global) and `qoder-cn` (China) presets. Both use a user-supplied Personal Access Token and the vendor's headless CLI; OpenCodex never reads Qoder Desktop sessions, browser cookies, refresh tokens, or private console APIs.
diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md
new file mode 100644
index 00000000000..74a1d963d1b
--- /dev/null
+++ b/docs-site/src/content/docs/guides/trae-cn.md
@@ -0,0 +1,70 @@
+---
+title: TRAE CN (ACP)
+description: Connect OpenCodex to the signed-in official TRAE CN CLI.
+---
+
+The experimental `trae-cn` adapter can be configured manually through `custom`. Its directory
+entry is reference-only; canonical promotion awaits a maintenance owner, vendor discovery and
+forwarding evidence, and security review. It uses the signed-in official CLI through `traecli acp serve`.
+OpenCodex does not read desktop tokens, private IDE endpoints, or Ferry.
+Non-text parts in system or developer instructions are refused before CLI startup, including images, videos, audio and file references in Responses, Chat and combo requests.
+File and audio references in native agent messages are refused too. A routing policy or combo may choose another eligible provider after a pre-dispatch refusal; an already dispatched TRAE prompt is never replayed for fallback.
+
+```json
+{
+ "providers": {
+ "trae-cn": {
+ "adapter": "trae-cn",
+ "baseUrl": "https://www.trae.cn",
+ "keyOptional": true
+ }
+ }
+}
+```
+
+- **Prerequisites:** Install and sign in to [TRAE CN](https://www.trae.cn/) and its official CLI
+ on the host running the OpenCodex proxy. A remote dashboard uses that host's CLI session.
+ The CLI owns authentication and token refresh; the provider needs no API key.
+ OpenCodex preserves the default `~/.trae` CLI directory while isolating HOME. Set `TRAE_HOME`
+ before starting OpenCodex to select a different signed-in CLI directory.
+- **Model discovery:** The tested signed-in CLI returned model IDs through `session/new` →
+ `models.availableModels[].modelId`. This is observed CLI behavior. The [vendor ACP documentation](https://docs.trae.cn/cli_agent-client-protocol)
+ does not document these discovery fields or guarantee their account scope.
+ Successful discovery is cached by the normal provider catalog mechanism, with the last good
+ catalog and explicitly configured models as degraded fallbacks.
+ Read-only catalog gathers and management admission use cached evidence; they never start the CLI or refresh its sign-in.
+ The automatic refresh worker prepares CLI evidence before its next catalog convergence, so a slow CLI does not delay unrelated management responses.
+ Set `liveModels: false` for a static allowlist; **Test connection** then returns not applicable.
+ After switching CLI accounts, enable live discovery and run **Test connection**. It
+ replaces the roster and refreshes Codex and connected clients. It reports pending or failed
+ publication separately; restart running Codex sessions after publication.
+ The probed roster stays available while publishing under memory pressure. If it cannot fit
+ the memory budget, the probe reports pending retention; retry after active requests finish.
+ A probe overtaken before updating the model cache asks you to retry. Disk-only edits require
+ reloading provider configuration and testing again.
+- **Transport:** Each turn opens a fresh ACP session for the selected model and streams text and reasoning.
+ Conversation history is replayed as labelled text. The limit is 1,000,000 UTF-16 code units
+ (JavaScript string length); an emoji may use two units. Oversized replay is rejected.
+- **Capability boundary:** This initial provider supports text conversations only. It rejects
+ client tools, tool replay, images, audio, video and attached documents. Model capability overrides
+ cannot enable native image input or verbosity. Images require the vision sidecar to describe them first.
+ Permission requests fail with `trae_client_request_unsupported`; internal tool notifications fail
+ with `trae_tool_call_unsupported`. An empty `end_turn` fails with `trae_empty_response`; `max_tokens` without text stays incomplete.
+ These completions and refusals retain usage. OpenCodex does not replay a dispatched TRAE prompt on another provider.
+ Client cancellation emits no provider error and preserves already-received usage for accounting.
+ Cancellation initiated by the CLI returns an incomplete turn.
+ Unsafe local spend-ledger files produce the shared `spend_ledger_storage_unavailable`/503
+ diagnostic; follow its local-storage recovery instructions before retrying.
+ Requests with an output-token limit fail with `unsupported_max_output_tokens` because this
+ adapter has no verified ACP field for that limit. ACP `max_turn_requests` returns an incomplete turn with that reason.
+- **CLI process:** The CLI uses an empty temporary workspace, no session MCP/skills and a finite tool denylist.
+ Shutdown targets the owned POSIX process group or Windows Job Object (Windows 10+, PowerShell 5.1). POSIX descendants
+ that detach into another group can survive; this mechanism does not contain untrusted hooks or tools.
+ Use a trusted CLI and configuration: its hooks and unlisted tools are outside OpenCodex's
+ client-capability controls. Temporary directories and tool denial do not provide an OS sandbox.
+- **Destination:** `https://www.trae.cn` identifies this provider; OpenCodex does not call it
+ directly. A custom destination is refused. This row is separate from TRAE International and
+ from a custom OpenAI-compatible provider named `trae`.
+- **Network routing:** The official CLI owns its network route. Provider `proxy` and `noProxy`
+ overrides, including `direct` and `null`, are rejected before CLI startup. OpenCodex does not
+ change system proxy settings.
diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md
index cfd4f95bca9..f64410f9611 100644
--- a/docs-site/src/content/docs/ja/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md
@@ -20,6 +20,9 @@ ocx init
1. **プロバイダーを選択してください** — 107 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。
2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。
+
+ **TRAE CN** はログイン済みの公式 CLI を使用するため、API キーは不要です。CLI をインストールしてログインし、ウィザードのキー入力欄は空のまま進めてください。[TRAE CN の設定手順(英語)](/guides/providers/#trae-cn-official-acp)を参照してください。
+
3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。
4. **プロキシ ポート** — デフォルトは `10100` です。
5. **Codex に挿入しますか?** - 通常のループバック設定では、opencodex はルート `openai_base_url` を
diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md
index 1d68dbc3b9c..1724a5123fd 100644
--- a/docs-site/src/content/docs/ja/guides/providers.md
+++ b/docs-site/src/content/docs/ja/guides/providers.md
@@ -210,6 +210,8 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht
## 3. API キーカタログ
+**TRAE CN** は手動設定用の実験的 ACP アダプターです。ディレクトリには未検証の参照項目のみがあり、組み込みプリセットは有効になっていません。[TRAE CN の設定手順(英語)](/guides/providers/#trae-cn-official-acp)を参照してください。
+
opencodex には組み込みプリセットが 107 個含まれています。キー方式 86、OAuth 17、ローカル 3、
デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、
入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです:
diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md
index bceda554cfe..382df986144 100644
--- a/docs-site/src/content/docs/ko/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md
@@ -20,6 +20,9 @@ ocx init
1. **프로바이더 선택** — 내장 레지스트리 프리셋 107개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다.
2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다.
+
+ **TRAE CN**은 로그인된 공식 CLI를 사용하므로 API 키가 필요하지 않습니다. CLI를 설치하고 로그인한 후 마법사의 키 입력란은 비워 두세요. 자세한 내용은 [TRAE CN 설정 안내(영문)](/guides/providers/#trae-cn-official-acp)를 참고하세요.
+
3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다.
4. **프록시 포트** — 기본값은 `10100`입니다.
5. **Codex에 주입할까요?** — 일반적인 루프백 구성에서는 opencodex가 `$CODEX_HOME/config.toml`의 루트(`~/.codex/config.toml`이 기본값)에 `openai_base_url`을 추가해 Codex의 내장 `openai` 프로바이더가 프록시를 바라보게 합니다. 원격/LAN 바인딩에서는 API 인증 헤더를 포함한 전용 프로바이더 항목을 대신 사용합니다.
diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md
index e51b6e70f0b..8ef0ee96249 100644
--- a/docs-site/src/content/docs/ko/guides/providers.md
+++ b/docs-site/src/content/docs/ko/guides/providers.md
@@ -215,6 +215,8 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http
## 3. API 키 카탈로그
+**TRAE CN**은 수동으로 구성하는 실험적 ACP 어댑터입니다. 디렉터리에는 미검증 참고 항목만 있으며 내장 프리셋은 활성화되어 있지 않습니다. [TRAE CN 설정 안내(영문)](/guides/providers/#trae-cn-official-acp)를 참고하세요.
+
opencodex에는 빌트인 프리셋이 107개 들어 있습니다. 키 방식 86개, OAuth 17개, 로컬 3개,
기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고,
입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다:
diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md
index 33aae9d93b5..608674d0cb9 100644
--- a/docs-site/src/content/docs/ru/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md
@@ -21,6 +21,9 @@ ocx init
1. **Выбор провайдера** — выберите один из 107 встроенных пресетов реестра или `custom`, чтобы
ввести базовый URL и адаптер вручную.
2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`.
+
+ **TRAE CN** использует официальный CLI с выполненным входом и не требует ключа API. Установите CLI, войдите в аккаунт и оставьте поле ключа в мастере пустым. Подробнее: [настройка TRAE CN (на английском)](/guides/providers/#trae-cn-official-acp).
+
3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из
пресета или введите id модели.
4. **Порт прокси** — по умолчанию `10100`.
diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md
index e3f8880e7d6..7d110561114 100644
--- a/docs-site/src/content/docs/ru/guides/providers.md
+++ b/docs-site/src/content/docs/ru/guides/providers.md
@@ -222,6 +222,8 @@ Inline JSON и лишние позиционные аргументы откло
## 3. Каталог API-ключей
+**TRAE CN** предоставляет экспериментальный ACP-адаптер для ручной настройки. В каталоге есть только непроверенная справочная запись; встроенный пресет не включён. Подробнее: [настройка TRAE CN (на английском)](/guides/providers/#trae-cn-official-acp).
+
opencodex поставляется с 107 встроенными пресетами: 86 на основе ключей, 17 OAuth, три локальных и
один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу
выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера.
diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md
index e346dcca33a..97df34cf112 100644
--- a/docs-site/src/content/docs/tr/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md
@@ -17,6 +17,9 @@ ocx init
veya bir temel URL ile adaptör yazmak için `custom` seçeneğini belirleyin.
2. **API anahtarı** — bir anahtar yapıştırın veya `${ANTHROPIC_API_KEY}` gibi
bir ortam değişkenine başvurun.
+
+ **TRAE CN**, oturum açılmış resmi CLI uygulamasını kullanır; API anahtarı gerekmez. CLI uygulamasını yükleyip oturum açın ve sihirbazdaki anahtar alanını boş bırakın. Ayrıntılar için [TRAE CN kurulumu (İngilizce)](/guides/providers/#trae-cn-official-acp) bölümüne bakın.
+
3. **Varsayılan model** — anahtar, yerel ve özel sağlayıcılar için önayarı kabul
edin veya bir model kimliği girin.
4. **Proxy portu** — varsayılan olarak `10100`.
diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md
index a6518b27291..711af1b2059 100644
--- a/docs-site/src/content/docs/tr/guides/providers.md
+++ b/docs-site/src/content/docs/tr/guides/providers.md
@@ -342,6 +342,8 @@ olmayan bir makineden oturum açmak bundan etkilenmez.
## 3. API anahtarı kataloğu
+**TRAE CN**, elle yapılandırılan deneysel bir ACP bağdaştırıcısıdır. Dizinde yalnızca doğrulanmamış bir başvuru kaydı bulunur; yerleşik önayar etkin değildir. [TRAE CN kurulumu (İngilizce)](/guides/providers/#trae-cn-official-acp) bölümüne bakın.
+
opencodex 107 yerleşik önayar ile birlikte gelir: 86 anahtar tabanlı, 17
OAuth, üç yerel ve bir varsayılan ChatGPT iletme önayarı. Kontrol panelinin
**Sağlayıcı ekle** seçicisi bir anahtar sağlayıcısının kontrol panelini açar,
diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md
index dd1dc17cced..72a96d17fed 100644
--- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md
@@ -20,6 +20,9 @@ ocx init
1. **选择 provider** — 从内置 registry 的 107 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。
2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。
+
+ **TRAE CN** 使用已登录的官方 CLI,无需 API 密钥。请先安装 CLI 并登录;向导若提示输入密钥,可直接留空。详见 [TRAE CN 配置说明(英文)](/guides/providers/#trae-cn-official-acp)。
+
3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。
4. **代理端口** — 默认为 `10100`。
5. **注入到 Codex?** — 在常规 loopback 配置下,opencodex 会在 `$CODEX_HOME/config.toml`(默认 `~/.codex/config.toml`)根级添加 `openai_base_url`,让 Codex 内置的 `openai` provider 指向代理。远程/LAN 绑定则改用带 API 认证 header 的专用 provider 条目。
diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md
index 629e47937ba..d84c5b537ab 100644
--- a/docs-site/src/content/docs/zh-cn/guides/providers.md
+++ b/docs-site/src/content/docs/zh-cn/guides/providers.md
@@ -222,6 +222,8 @@ API Key 提供商,也不负责让 WorkBuddy 桌面客户端使用 OpenCodex
## 3. API 密钥目录
+**TRAE CN** 提供需手工配置的实验性 ACP adapter。目录中仅有未经验证的参考项,内置预设尚未启用。详见 [TRAE CN 配置说明(英文)](/guides/providers/#trae-cn-official-acp)。
+
opencodex 内置 107 个预设:86 个密钥预设、17 个 OAuth 预设、3 个本地预设,以及 1 个默认的
ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。
验证因提供商而异。主要条目包括:
diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md
index 1b97eb24aaf..e7c08189f4f 100644
--- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md
@@ -16,6 +16,9 @@ ocx init
1. **選擇 provider** —— 從內建 registry 的 107 個預設中選擇一個,或選擇 `custom` 手動輸入
base URL 和 adapter。
2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。
+
+ **TRAE CN** 使用已登入的官方 CLI,無需 API 金鑰。請先安裝 CLI 並登入;精靈若要求輸入金鑰,可直接留空。詳見 [TRAE CN 設定說明(英文)](/guides/providers/#trae-cn-official-acp)。
+
3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。
4. **代理埠** —— 預設為 `10100`。
5. **注入到 Codex?** —— 在通常的迴環地址設定中,opencodex 會在
diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md
index 97fca9a701b..1b9ea9eec7d 100644
--- a/docs-site/src/content/docs/zh-tw/guides/providers.md
+++ b/docs-site/src/content/docs/zh-tw/guides/providers.md
@@ -264,6 +264,8 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s
## 3. API 金鑰目錄
+**TRAE CN** 提供需手動設定的實驗性 ACP adapter。目錄中僅有未經驗證的參考項,內建預設尚未啟用。詳見 [TRAE CN 設定說明(英文)](/guides/providers/#trae-cn-official-acp)。
+
opencodex 內建 107 個 preset:86 個 key-based、17 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward
preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式
依 provider 而異。主要條目如下。
diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx
index 4da3375fa26..0bf2d3cc3c6 100644
--- a/gui/src/components/add-provider-form-pane.tsx
+++ b/gui/src/components/add-provider-form-pane.tsx
@@ -150,7 +150,9 @@ export function AddProviderFormPane({
{t("modal.subscriptionCliTitle")}
-
+ {form.adapter === "trae-cn"
+ ?
+ : }
) : preset.keyOptional ? (
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 9050da24c50..11074bb3606 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -2994,6 +2994,7 @@ export const de: Record = {
"modal.freeTierTitle": "Kostenloser Tarif",
"modal.subscriptionCliTitle": "Abo-CLI, kein API-Schlüssel",
"modal.subscriptionCliWarning": "Dieser Anbieter verwendet keinen API-Schlüssel. Er steuert die offizielle, auf diesem Rechner angemeldete Claude Code CLI ({cmd}) und rechnet über dieses Claude-Abonnement ab. OpenCodex speichert dafür keinen Schlüssel, und ein hier eingegebener Schlüssel wird nie verwendet. Für die Abrechnung per API-Schlüssel füge stattdessen {apiProvider} hinzu.",
+ "modal.traeCnCliWarning": "Verwendet die angemeldete offizielle TRAE CN CLI ({cmd}). Installieren Sie zuerst die CLI auf dem Proxy-Host und melden Sie sich dort an; ein API-Schlüssel ist nicht erforderlich. Text- und Reasoning-Streams werden unterstützt, Client-Tools, Bilder und Audio hingegen nicht.",
"modal.freeTierDefault": "Kein API-Schlüssel nötig. Funktioniert sofort.",
"modal.tab.accounts": "Konten",
"modal.tab.free": "Kostenlos",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 67d2610e2a1..3f1609cfe8c 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1843,6 +1843,7 @@ export const en = {
"modal.freeTierTitle": "Free tier",
"modal.subscriptionCliTitle": "Subscription CLI, not an API key",
"modal.subscriptionCliWarning": "This provider does not use an API key. It drives the official Claude Code CLI signed in on this machine ({cmd}) and bills that Claude subscription account. OpenCodex stores no key for it, and a key entered here is never used. For API-key billing, add {apiProvider} instead.",
+ "modal.traeCnCliWarning": "Uses the signed-in official TRAE CN CLI ({cmd}). Install the CLI and sign in on the proxy host first; no API key is needed. Supports text and reasoning streams; client tools, images, and audio are not supported.",
"modal.freeTierDefault": "No API key required. Works out of the box.",
"modal.tab.accounts": "Accounts",
"modal.tab.free": "Free",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 2edc3abb1d8..d43f3d5b8e3 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -1816,6 +1816,7 @@ export const fr: Record = {
"modal.freeTierTitle": "Offre gratuite",
"modal.subscriptionCliTitle": "CLI d'abonnement, pas une clé API",
"modal.subscriptionCliWarning": "Ce fournisseur n'utilise pas de clé API. Il pilote la CLI officielle Claude Code connectée sur cette machine ({cmd}) et facture ce compte d'abonnement Claude. OpenCodex ne stocke aucune clé pour lui, et une clé saisie ici n'est jamais utilisée. Pour une facturation par clé API, ajoutez plutôt {apiProvider}.",
+ "modal.traeCnCliWarning": "Utilise le CLI officiel TRAE CN déjà connecté ({cmd}). Installez le CLI et connectez-vous sur la machine qui héberge le proxy ; aucune clé API n’est nécessaire. Le texte et le raisonnement en continu sont pris en charge, mais pas les outils clients, les images ni l’audio.",
"modal.freeTierDefault": "Aucune clé API requise. Fonctionne immédiatement.",
"modal.tab.accounts": "Comptes",
"modal.tab.free": "Gratuit",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 6418b0c70c2..6c16aafeee7 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -1726,6 +1726,7 @@ export const ja: Record = {
"modal.freeTierTitle": "無料枠",
"modal.subscriptionCliTitle": "サブスクリプション CLI(API キーではありません)",
"modal.subscriptionCliWarning": "このプロバイダーは API キーを使用しません。このマシンでサインイン済みの公式 Claude Code CLI({cmd})を実行し、その Claude サブスクリプションアカウントに課金されます。OpenCodex はキーを保存せず、ここに入力したキーが使われることもありません。API キーで課金する場合は、代わりに {apiProvider} を追加してください。",
+ "modal.traeCnCliWarning": "ログイン済みの公式 TRAE CN CLI({cmd})を使用します。先にプロキシを実行するホストで CLI をインストールし、ログインしてください。API キーは不要です。テキストと推論のストリーミングに対応し、クライアントツール、画像、音声には対応していません。",
"modal.freeTierDefault": "API キー不要です。そのまま利用できます。",
"modal.tab.accounts": "アカウント",
"modal.tab.free": "無料",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index fa314a2435a..64c6246fe04 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -3041,6 +3041,7 @@ export const ko: Record = {
"modal.freeTierTitle": "무료 티어",
"modal.subscriptionCliTitle": "API 키가 아닌 구독 CLI 방식",
"modal.subscriptionCliWarning": "이 프로바이더는 API 키를 쓰지 않습니다. 이 컴퓨터에 로그인된 공식 Claude Code CLI({cmd})를 실행하고, 요금은 그 Claude 구독 계정에 청구됩니다. OpenCodex는 이 프로바이더의 키를 저장하지 않으며, 여기에 입력한 키는 쓰이지 않습니다. API 키로 과금하려면 대신 {apiProvider}를 추가하세요.",
+ "modal.traeCnCliWarning": "로그인된 공식 TRAE CN CLI({cmd})를 사용합니다. 먼저 프록시 호스트에 CLI를 설치하고 해당 호스트에서 로그인하세요. API 키는 필요하지 않습니다. 텍스트 및 추론 스트리밍을 지원하며 클라이언트 도구, 이미지, 오디오는 지원하지 않습니다.",
"modal.freeTierDefault": "API 키가 필요 없습니다. 바로 사용할 수 있습니다.",
"modal.tab.accounts": "계정",
"modal.tab.free": "무료",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index 20e090f5d27..c82cde263b9 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -1827,6 +1827,7 @@ export const pt: Record = {
"modal.freeTierTitle": "Plano gratuito",
"modal.subscriptionCliTitle": "CLI por assinatura, não chave de API",
"modal.subscriptionCliWarning": "Este provedor não usa chave de API. Ele aciona a CLI oficial do Claude Code autenticada nesta máquina ({cmd}) e cobra da conta de assinatura do Claude. O OpenCodex não armazena nenhuma chave para ele, e uma chave informada aqui nunca é usada. Para cobrança por chave de API, adicione {apiProvider}.",
+ "modal.traeCnCliWarning": "Usa a CLI oficial do TRAE CN com sessão iniciada ({cmd}). Instale a CLI e inicie sessão no servidor do proxy primeiro; não é necessária uma chave de API. Suporta fluxos de texto e raciocínio, mas não ferramentas do cliente, imagens ou áudio.",
"modal.freeTierDefault": "Não requer chave de API. Funciona de imediato.",
"modal.tab.accounts": "Contas",
"modal.tab.free": "Grátis",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index d1ef2b8d62e..cffdfcd71af 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1802,6 +1802,7 @@ export const ru: Record = {
"modal.freeTierTitle": "Бесплатный тариф",
"modal.subscriptionCliTitle": "CLI по подписке, а не API-ключ",
"modal.subscriptionCliWarning": "Этот провайдер не использует API-ключ. Он запускает официальный Claude Code CLI, в котором выполнен вход на этом компьютере ({cmd}), и списывает оплату с этой подписки Claude. OpenCodex не хранит для него ключ, а введённый здесь ключ никогда не используется. Для оплаты по API-ключу добавьте вместо него {apiProvider}.",
+ "modal.traeCnCliWarning": "Использует официальный CLI TRAE CN с выполненным входом ({cmd}). Сначала установите CLI и войдите в аккаунт на хосте прокси; ключ API не требуется. Поддерживаются потоки текста и рассуждений, но не клиентские инструменты, изображения или аудио.",
"modal.freeTierDefault": "API-ключ не нужен. Работает из коробки.",
"modal.tab.accounts": "Аккаунты",
"modal.tab.free": "Бесплатные",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index 654579c5dc5..61b5d1b8034 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -1829,6 +1829,7 @@ export const tr: Record = {
"modal.freeTierTitle": "Ücretsiz katman",
"modal.subscriptionCliTitle": "Abonelik CLI'si, API anahtarı değil",
"modal.subscriptionCliWarning": "Bu sağlayıcı API anahtarı kullanmaz. Bu makinede oturum açılmış resmi Claude Code CLI'sini ({cmd}) çalıştırır ve ücreti o Claude abonelik hesabına yansıtır. OpenCodex bunun için anahtar saklamaz ve buraya girilen bir anahtar asla kullanılmaz. API anahtarıyla faturalandırma için bunun yerine {apiProvider} ekleyin.",
+ "modal.traeCnCliWarning": "Oturum açılmış resmi TRAE CN CLI uygulamasını ({cmd}) kullanır. Önce CLI uygulamasını proxy sunucusuna yükleyip o sunucuda oturum açın; API anahtarı gerekmez. Metin ve akıl yürütme akışlarını destekler; istemci araçları, görseller ve ses desteklenmez.",
"modal.freeTierDefault": "API anahtarı gerekmez. Doğrudan çalışır.",
"modal.tab.accounts": "Hesaplar",
"modal.tab.free": "Ücretsiz",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index 38744fc10a1..b2665214992 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -1815,6 +1815,7 @@ export const vi: Record = {
"modal.freeTierTitle": "Gói miễn phí",
"modal.subscriptionCliTitle": "CLI theo gói đăng ký, không phải API key",
"modal.subscriptionCliWarning": "Nhà cung cấp này không dùng API key. Nó chạy Claude Code CLI chính thức đã đăng nhập trên máy này ({cmd}) và tính phí vào tài khoản gói đăng ký Claude đó. OpenCodex không lưu key nào cho nó, và key nhập ở đây sẽ không bao giờ được dùng. Để tính phí theo API key, hãy thêm {apiProvider} thay thế.",
+ "modal.traeCnCliWarning": "Sử dụng CLI TRAE CN chính thức đã đăng nhập ({cmd}). Hãy cài đặt CLI và đăng nhập trên máy chủ proxy trước; không cần khóa API. Hỗ trợ truyền văn bản và suy luận, nhưng chưa hỗ trợ công cụ phía máy khách, hình ảnh hoặc âm thanh.",
"modal.freeTierDefault": "Không cần API key. Có thể sử dụng ngay.",
"modal.tab.accounts": "Tài khoản",
"modal.tab.free": "Miễn phí",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index 9fecfe67d03..f7c44798976 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -1506,6 +1506,7 @@ export const zhTW: Record = {
"modal.freeTierTitle": "免費層級",
"modal.subscriptionCliTitle": "訂閱 CLI,而非 API 金鑰",
"modal.subscriptionCliWarning": "此供應商不使用 API 金鑰。它會呼叫本機已登入的官方 Claude Code CLI({cmd}),並向該 Claude 訂閱帳戶計費。OpenCodex 不會為其儲存任何金鑰,在此輸入的金鑰也永遠不會被使用。如需以 API 金鑰計費,請改為新增 {apiProvider}。",
+ "modal.traeCnCliWarning": "透過已登入的官方 TRAE CN CLI({cmd})連線。請先在代理主機上安裝 CLI 並登入,無需 API 金鑰。支援文字與推理串流,暫不支援用戶端工具、圖片及音訊。",
"modal.freeTierDefault": "無需 API 金鑰,開箱即用。",
"modal.tab.accounts": "帳號",
"modal.tab.free": "免費",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index fae014bc7b6..d7f03f63d10 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -3014,6 +3014,7 @@ export const zh: Record = {
"modal.freeTierTitle": "免费层级",
"modal.subscriptionCliTitle": "订阅 CLI,而非 API 密钥",
"modal.subscriptionCliWarning": "此提供商不使用 API 密钥。它调用本机已登录的官方 Claude Code CLI({cmd}),并向该 Claude 订阅账户计费。OpenCodex 不为其存储任何密钥,在此输入的密钥也永远不会被使用。如需按 API 密钥计费,请改为添加 {apiProvider}。",
+ "modal.traeCnCliWarning": "通过已登录的官方 TRAE CN CLI({cmd})连接。请先在代理主机上安装 CLI 并登录,无需 API 密钥。支持文本和推理流,暂不支持客户端工具、图片和音频。",
"modal.freeTierDefault": "无需 API 密钥,开箱即用。",
"modal.tab.accounts": "账户",
"modal.tab.free": "免费",
diff --git a/gui/src/provider-workspace/subscription-cli.ts b/gui/src/provider-workspace/subscription-cli.ts
index 6178cc3741f..dada0fdc411 100644
--- a/gui/src/provider-workspace/subscription-cli.ts
+++ b/gui/src/provider-workspace/subscription-cli.ts
@@ -2,17 +2,16 @@
* provider-workspace/subscription-cli.ts
*
* A subscription CLI row drives a locally signed-in vendor CLI instead of calling an API with a
- * key. Today that is only the `claude-cli` adapter: it runs the official Claude Code CLI
- * headlessly (`claude -p`), the CLI signs in and bills the user's Claude subscription, and the
+ * key. These adapters run an official vendor CLI whose login and account entitlement it owns, and the
* proxy stores, reads and injects no key. The registry marks the row `keyOptional` because no
* key is required, which is also the flag the dashboard reads as "free" — so every surface that
* groups or labels by pricing asks this helper first.
*
* Keyed on the adapter, not the provider id: the adapter is the transport identity
* (src/adapters/registry.ts builds the CLI adapter for any provider that names it), so a
- * custom row with `adapter: "claude-cli"` spends the same subscription and gets the same label.
+ * custom row using one of these adapters spends the same account entitlement and gets the same label.
*/
-const SUBSCRIPTION_CLI_ADAPTERS: ReadonlySet = new Set(["claude-cli"]);
+const SUBSCRIPTION_CLI_ADAPTERS: ReadonlySet = new Set(["claude-cli", "trae-cn"]);
/** The Anthropic key-billing preset to point at instead of a subscription CLI row. */
export const SUBSCRIPTION_CLI_API_KEY_ALTERNATIVE = "anthropic-apikey";
diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts
index f4c1f3e9222..282b0893116 100644
--- a/src/adapters/openai-chat/messages.ts
+++ b/src/adapters/openai-chat/messages.ts
@@ -232,9 +232,10 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
const hasImages = parts?.some(p => p.type === "image") ?? false;
const hasAudio = parts?.some(p => p.type === "audio") ?? false;
+ const hasDocuments = parts?.some(p => p.type === "document") ?? false;
// A document has a structured counterpart on this wire, so it needs the parts array for
// the same reason an image does: flattening it to a string would drop the bytes.
- const hasStructured = hasImages || hasAudio || (parts?.some(p => p.type === "document") ?? false);
+ const hasStructured = hasImages || hasAudio || hasDocuments;
let chatMsg: Record;
if (msg.role === "developer" && !hasStructured) {
const text = typeof msg.content === "string"
@@ -286,21 +287,39 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv
if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER };
return { type: "text", text: (p as OcxTextContent).text };
});
- // A developer message with images keeps the user-compatible shape it has always had on
- // this wire. One carrying only a document has no such precedent, and demoting it would
- // undo the role this adapter just finished preserving.
- chatMsg = {
- role: msg.role === "developer" && !hasImages && !hasAudio ? developerWireRole : "user",
- content: chatParts,
- };
- if (provider.singleLeadingSystemMessage === true && chatMsg.role !== "user") {
- // Keep document bytes in a user-compatible slot; only their accompanying text is
- // instruction content. Use the same tool barrier so instruction order is retained.
- const instruction = { role: developerWireRole, content: chatParts.filter(p => p.type === "text") };
- if (instruction.content.length > 0) {
- (pendingToolCalls.length > 0 ? deferredBarrierMessages : out).push(instruction);
+ chatMsg = { role: "user", content: chatParts };
+ if (msg.role === "developer") {
+ const destination = pendingToolCalls.length > 0 ? deferredBarrierMessages : out;
+ if (leadingSystemTemplate) {
+ // This template accepts rich user content in place, but rejects every late system
+ // or developer message. Keep text and attachments together in the original slot.
+ destination.push(chatMsg);
+ } else if (provider.singleLeadingSystemMessage === true) {
+ const text = chatParts.filter(part => part.type === "text");
+ const attachments = chatParts.filter(part => part.type !== "text");
+ if (text.length > 0) destination.push({ role: developerWireRole, content: text });
+ if (attachments.length > 0) destination.push({ role: "user", content: attachments });
+ } else if (hasImages || hasDocuments) {
+ // Keep rich instruction text attached to the image/file it describes. Every part is
+ // valid under user, while system/developer roles accept only the text subset.
+ destination.push(chatMsg);
+ } else {
+ // Images, audio and files are user-content-only on the Chat wire. Split only at
+ // role boundaries so interleaved instruction/attachment groups retain their order.
+ let role: "system" | "developer" | "user" | undefined;
+ let content: unknown[] = [];
+ for (const part of chatParts) {
+ const nextRole = part.type === "text" ? developerWireRole : "user";
+ if (role !== undefined && nextRole !== role) {
+ destination.push({ role, content });
+ content = [];
+ }
+ role = nextRole;
+ content.push(part);
+ }
+ if (role !== undefined) destination.push({ role, content });
}
- chatMsg = { role: "user", content: chatParts.filter(p => p.type !== "text") };
+ break;
}
}
if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts
index 12ccfff89da..e8759f57c53 100644
--- a/src/adapters/registry.ts
+++ b/src/adapters/registry.ts
@@ -8,6 +8,7 @@ import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-dee
import { withUniqueToolCallIds } from "./unique-tool-call-ids";
import { createCodeBuddyAdapter } from "./codebuddy/adapter";
import { createQoderAdapter } from "./qoder/adapter";
+import { createTraeCnAdapter } from "./trae-cn/adapter";
import { createCommandCodeAdapter } from "./command-code";
import { createCursorAdapter } from "./cursor";
import { createDevinAdapter } from "./devin";
@@ -40,6 +41,7 @@ export interface AdapterFactoryContext {
export type AdapterWire =
| "codebuddy"
+ | "trae-cn"
| "command-code"
| "openai-chat"
| "ollama-native"
@@ -160,6 +162,11 @@ export const ADAPTER_REGISTRY = {
contractParent: "codebuddy",
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createClaudeCliAdapter(provider),
},
+ "trae-cn": {
+ wire: "trae-cn",
+ mutation: "codex-owned",
+ create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createTraeCnAdapter(provider),
+ },
"dsh-account": {
contractParent: "anthropic",
create: (provider: OcxProviderConfig, context: AdapterFactoryContext) => createDshAccountAdapter(provider, context),
diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts
new file mode 100644
index 00000000000..c2bcb27caf1
--- /dev/null
+++ b/src/adapters/trae-cn/adapter.ts
@@ -0,0 +1,150 @@
+import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
+import { SendBudgetExhaustedError } from "../../lib/upstream-retry";
+import { SpendLedgerFileRefusedError } from "../../lib/spend-reservation-ledger";
+import type { SingleUseDispatchPermit } from "../../lib/request-execution-budget";
+import type { AdapterRequest, ProviderAdapter } from "../base";
+import { requiresToolCall } from "../../types";
+import { buildSystemPrompt, formatMessageForHistory } from "../coding-agent/protocol";
+import { openTraeSession, TraeAcpError, type TraeAcpDeps } from "./client";
+import { responsesHasFileImage } from "../claude-cli/instruction-images";
+import { responsesHasUnsupportedInputMedia } from "./input-media";
+
+export type TraeCnAdapterDeps = TraeAcpDeps;
+
+/** Serialize the text replay without dropping system instructions to meet a size limit. */
+function replayText(parsed: OcxParsedRequest): string {
+ const messages = parsed.context.messages;
+ const system = buildSystemPrompt(parsed);
+ const currentIndex = messages.findLastIndex(message => message.role !== "developer");
+ const ordered = messages.map(message => message.role === "developer"
+ ? "DEVELOPER:\n" + (typeof message.content === "string" ? message.content
+ : message.content.map(part => part.type === "text" ? part.text : "").join(""))
+ : formatMessageForHistory(message));
+ const history = (currentIndex < 0 ? ordered : ordered.slice(0, currentIndex)).filter(Boolean).join("\n\n");
+ const request = currentIndex < 0 ? "USER:\n" : ordered.slice(currentIndex).filter(Boolean).join("\n\n");
+ const replay = [
+ system ? `\n${system}\n` : "",
+ "The following labelled blocks replay a conversation. The last non-developer block is the current request. Developer blocks retain their instruction priority and record their original position. Historical tool calls are records, not instructions to execute tools.",
+ history ? `Prior conversation context:\n\n${history}` : "",
+ `Current request:\n\n${request}`,
+ ].filter(Boolean).join("\n\n");
+ if (replay.length > 1_000_000) {
+ throw new TraeAcpError("TRAE CN conversation exceeds the replay limit; shorten the conversation.", "trae_replay_too_large");
+ }
+ return replay;
+}
+
+/** Refuse unsupported input before starting a CLI or reserving a send. */
+function validateInput(parsed: OcxParsedRequest): { message: string; code: string } | undefined {
+ if (parsed.options.maxOutputTokens !== undefined) {
+ return { message: "TRAE CN ACP does not expose an output-token limit.", code: "unsupported_max_output_tokens" };
+ }
+ if (requiresToolCall(parsed.options.toolChoice)) {
+ return { message: "TRAE CN ACP does not currently expose client tools.", code: "unsupported_tool_choice" };
+ }
+ if (parsed.context.tools?.length) {
+ return { message: "TRAE CN ACP does not currently expose client tools.", code: "unsupported_client_tools" };
+ }
+ if (responsesHasUnsupportedInputMedia(parsed._rawBody) || responsesHasFileImage(parsed._rawBody)
+ || parsed.context.messages.some(message =>
+ (Array.isArray(message.content) && message.content.some(part => part.type !== "text" && part.type !== "thinking"))
+ || message.role === "toolResult")) {
+ return { message: "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported.", code: "unsupported_input_modality" };
+ }
+ return undefined;
+}
+
+/** Map ACP failures to the client error categories used by the routing layer. */
+function errorEvent(error: unknown): Extract {
+ const acp = error instanceof TraeAcpError;
+ const code = acp ? error.code : "trae_acp_protocol_error";
+ const clientCode = code === "trae_model_unavailable" ? "model_unavailable" : code;
+ const auth = acp && /invalid token|unauthorized|not logged in|authentication/i.test(error.message);
+ const message = acp ? error.message : "TRAE CN ACP ended with an invalid protocol response.";
+ const invalid = code === "trae_model_unavailable" || code === "trae_replay_too_large" || code === "trae_proxy_unsupported";
+ const startup = code.startsWith("trae_startup_");
+ return {
+ type: "error",
+ message: auth ? `${message} Sign in through TRAE CN and retry.` : message.slice(0, 512),
+ status: invalid ? 400 : auth ? 401 : startup ? 500 : 502,
+ errorType: invalid ? "invalid_request_error" : auth ? "authentication_error" : startup ? "server_error" : "upstream_error",
+ code: auth ? "trae_cn_not_logged_in" : clientCode,
+ retryable: false,
+ };
+}
+
+/** Own one text-only ACP session per turn and charge only a dispatched prompt. */
+export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAdapterDeps = {}): ProviderAdapter {
+ return {
+ name: "trae-cn",
+ reportsPhysicalSends: true,
+ buildRequest(): AdapterRequest { return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; },
+ async *parseStream(): AsyncGenerator {
+ yield { type: "error", message: "TRAE CN uses ACP runTurn; HTTP parseStream is disabled." };
+ },
+ async runTurn(parsed, incoming, emit): Promise {
+ const invalid = validateInput(parsed);
+ if (invalid) {
+ emit({ type: "error", message: invalid.message, status: 400, errorType: "invalid_request_error", code: invalid.code, retryable: false });
+ return;
+ }
+ if (incoming.abortSignal?.aborted) return;
+ const producer = incoming.sendBudget?.beginSpendProducer?.();
+ let permit: SingleUseDispatchPermit | undefined;
+ let terminal: Extract | undefined;
+ let promptFailure: unknown;
+ let cancelledUsageEmitted = false;
+ /** Report received usage once when cancellation replaces the pending terminal outcome. */
+ const emitCancelledUsage = () => {
+ const usage = terminal?.usage ?? (promptFailure instanceof TraeAcpError ? promptFailure.usage : undefined);
+ if (!usage || cancelledUsageEmitted) return;
+ cancelledUsageEmitted = true;
+ emit({ type: "incomplete", reason: "client_cancelled", usage, retryable: false });
+ };
+ try {
+ const replay = replayText(parsed);
+ const decision = incoming.sendBudget?.reserveDispatch({ sendClass: "initial", targetKey: `trae-cn|${parsed.modelId}` });
+ if (decision && !decision.allowed) throw new SendBudgetExhaustedError();
+ permit = decision?.allowed ? decision.permit : undefined;
+ const session = await openTraeSession(provider.baseUrl, deps, incoming.abortSignal, provider);
+ let cleanupFailure: unknown;
+ try {
+ await session.prompt(parsed.modelId, replay, event => {
+ if (event.type === "done" || event.type === "incomplete" || event.type === "error") terminal = event;
+ else emit(event);
+ }, incoming.abortSignal, {
+ beforeSend: () => {
+ incoming.beforePhysicalSend?.();
+ if (permit && !permit.use()) throw new SendBudgetExhaustedError();
+ },
+ onSend: () => {
+ emit({ type: "heartbeat", replayUnsafe: true });
+ incoming.onPhysicalSend?.({ ordinal: 1 });
+ },
+ });
+ } catch (error) { promptFailure = error; }
+ try { await session.close(); } catch (error) { cleanupFailure = error; }
+ if (promptFailure instanceof SendBudgetExhaustedError || promptFailure instanceof SpendLedgerFileRefusedError) {
+ throw promptFailure;
+ }
+ if (cleanupFailure) {
+ const usage = promptFailure instanceof TraeAcpError ? promptFailure.usage : undefined;
+ if (cleanupFailure instanceof TraeAcpError && usage && !cleanupFailure.usage) {
+ throw new TraeAcpError(cleanupFailure.message, cleanupFailure.code, usage);
+ }
+ throw cleanupFailure;
+ }
+ if (promptFailure) throw promptFailure;
+ if (incoming.abortSignal?.aborted) emitCancelledUsage();
+ else if (terminal) emit(terminal);
+ } catch (error) {
+ if (incoming.abortSignal?.aborted) { emitCancelledUsage(); return; }
+ if (error instanceof SendBudgetExhaustedError || error instanceof SpendLedgerFileRefusedError) throw error;
+ const usage = terminal?.usage ?? (error instanceof TraeAcpError ? error.usage : undefined);
+ emit({ ...errorEvent(error), ...(usage ? { usage } : {}) });
+ } finally {
+ try { permit?.release(); } finally { producer?.close(); }
+ }
+ },
+ };
+}
diff --git a/src/adapters/trae-cn/client.ts b/src/adapters/trae-cn/client.ts
new file mode 100644
index 00000000000..c5f2ba00198
--- /dev/null
+++ b/src/adapters/trae-cn/client.ts
@@ -0,0 +1,2 @@
+// ACP transport implementation lives in the session module so parsing stays independent.
+export { openTraeSession, TraeAcpError, type TraeAcpDeps, type TraeSession, type SpawnTraeCli } from "./session";
diff --git a/src/adapters/trae-cn/input-media.ts b/src/adapters/trae-cn/input-media.ts
new file mode 100644
index 00000000000..3582b8d066d
--- /dev/null
+++ b/src/adapters/trae-cn/input-media.ts
@@ -0,0 +1,51 @@
+/** Recognize object parts before inspecting caller-provided instruction content. */
+const record = (value: unknown): value is Record =>
+ value !== null && typeof value === "object" && !Array.isArray(value);
+const TEXT_PART_TYPES = new Set(["text", "input_text", "output_text"]);
+const MEDIA_PART_TYPES = new Set(["input_image", "input_video", "input_audio", "input_file", "image", "image_url", "video", "video_url", "audio", "file", "document"]);
+
+/** Retain the unsupported-content fact before a wire parser reduces instructions to text. */
+function hasUnsupportedInstructions(body: unknown, field: "input" | "messages"): boolean {
+ if (!record(body)) return false;
+ const messages = body[field];
+ return Array.isArray(messages) && messages.some(message =>
+ record(message) && (message.role === "system" || message.role === "developer")
+ && Array.isArray(message.content) && message.content.some(part =>
+ typeof part !== "string" && (!record(part) || typeof part.type !== "string"
+ || !TEXT_PART_TYPES.has(part.type) || typeof part.text !== "string")));
+}
+
+/** Reject media that cannot survive Responses projection; user images may be captioned first. */
+export function responsesHasUnsupportedInputMedia(body: unknown, options: { captionUserImages?: boolean } = {}): boolean {
+ if (hasUnsupportedInstructions(body, "input")) return true;
+ if (!record(body) || !Array.isArray(body.input) || body.input.length === 0) return false;
+ const ordinaryUserMessage = (item: Record): boolean =>
+ (item.type === undefined || item.type === "message") && item.role === "user";
+ const messageMedia = body.input.some(item => record(item)
+ && (item.type === "agent_message" || ordinaryUserMessage(item))
+ && Array.isArray(item.content) && item.content.some(part => record(part) && typeof part.type === "string"
+ && MEDIA_PART_TYPES.has(part.type)
+ && !(options.captionUserImages === true && part.type === "input_image" && ordinaryUserMessage(item))));
+ const toolMedia = body.input.some(item => record(item)
+ && (item.type === "function_call_output" || item.type === "custom_tool_call_output")
+ && Array.isArray(item.output) && item.output.some(part => record(part) && typeof part.type === "string"
+ && MEDIA_PART_TYPES.has(part.type)));
+ return messageMedia || toolMedia;
+}
+
+/** A rebuilt search result may be removed later, but every other tool image is refused first. */
+export function responsesHasUnsupportedToolMedia(body: unknown): boolean {
+ if (!record(body) || !Array.isArray(body.input)) return false;
+ const searchCallIds = new Set(body.input.flatMap(item => record(item) && item.type === "function_call"
+ && item.name === "web_search" && typeof item.call_id === "string" ? [item.call_id] : []));
+ return body.input.some(item => record(item)
+ && (item.type === "function_call_output" || item.type === "custom_tool_call_output")
+ && !(typeof item.call_id === "string" && searchCallIds.has(item.call_id))
+ && Array.isArray(item.output) && item.output.some(part => record(part) && typeof part.type === "string"
+ && MEDIA_PART_TYPES.has(part.type)));
+}
+
+/** Inspect Chat instructions before their non-text parts disappear during Responses projection. */
+export function chatHasUnsupportedInstructionMedia(body: unknown): boolean {
+ return hasUnsupportedInstructions(body, "messages");
+}
diff --git a/src/adapters/trae-cn/process.ts b/src/adapters/trae-cn/process.ts
new file mode 100644
index 00000000000..e6d6afa1800
--- /dev/null
+++ b/src/adapters/trae-cn/process.ts
@@ -0,0 +1,50 @@
+import type { ChildProcess } from "node:child_process";
+import type { Writable } from "node:stream";
+
+/**
+ * Keep the process-group leader alive until shutdown. Only group members signal group 0;
+ * the parent never sends a negative PID that could have been reused after the CLI exited.
+ * A private pipe requests shutdown and also closes when the proxy dies.
+ */
+export const TRAE_POSIX_SUPERVISOR = String.raw`
+grace=$1
+shift
+stop_group() {
+ trap '' TERM INT HUP
+ kill -TERM 0 2>/dev/null || :
+ ( /bin/sleep "$grace"; kill -KILL 0 ) &
+ wait "$cli" 2>/dev/null || :
+ kill -KILL 0
+}
+trap stop_group TERM INT HUP
+exec 4<&0
+(
+ trap - TERM INT HUP
+ exec 0<&- 1>&- 2>&- 4<&-
+ IFS= read -r stop <&3 || :
+ kill -TERM 0
+) &
+guard=$!
+"$@" <&4 3<&- 4<&- &
+cli=$!
+exec 4<&-
+wait "$cli"
+status=$?
+case "$status" in 126|127) printf 'OCX_TRAE_LAUNCH:%s\n' "$status" >&3 ;; esac
+exec 3<&-
+exec 0<&- 1>&- 2>&-
+wait "$guard"
+stop_group
+`;
+
+/** Preserve argv boundaries; the CLI path and arguments are never interpolated into shell code. */
+export function traePosixInvocation(file: string, args: readonly string[], graceMs: number): { file: string; args: string[] } {
+ const seconds = Math.max(0, Number.isFinite(graceMs) ? graceMs : 2000) / 1000;
+ return { file: "/bin/sh", args: ["-c", TRAE_POSIX_SUPERVISOR, "ocx-trae-cn-supervisor", String(seconds), file, ...args] };
+}
+
+/** The private descriptor identifies the owned supervisor without consulting a numeric PID. */
+export function traeStopPipe(child: ChildProcess): Writable | undefined {
+ const pipe = child.stdio?.[3];
+ return pipe && "write" in pipe ? pipe as Writable : undefined;
+}
diff --git a/src/adapters/trae-cn/profile.ts b/src/adapters/trae-cn/profile.ts
new file mode 100644
index 00000000000..a5b899c2086
--- /dev/null
+++ b/src/adapters/trae-cn/profile.ts
@@ -0,0 +1,29 @@
+import { posix, win32 } from "node:path";
+import { whichFromPath, type PathLookupDeps } from "../coding-agent/profile";
+import { baseScopedEnv } from "../coding-agent/turn";
+
+export const TRAE_CN_BASE_URL = "https://www.trae.cn";
+
+/** Accept the canonical TRAE CN destination, including an optional trailing slash. */
+export function traeCnDestinationMatches(baseUrl: string | undefined): boolean {
+ return baseUrl?.replace(/\/+$/, "").toLowerCase() === TRAE_CN_BASE_URL;
+}
+
+/** Preserve the CLI's documented config directory before replacing the child's HOME. */
+export function traeCnChildEnv(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): Record {
+ const pathApi = platform === "win32" ? win32 : posix;
+ const home = env[platform === "win32" ? "USERPROFILE" : "HOME"];
+ const traeHome = env.TRAE_HOME ? pathApi.resolve(env.TRAE_HOME) : home ? pathApi.resolve(home, ".trae") : undefined;
+ return { ...baseScopedEnv(), NO_COLOR: "1", ...(traeHome ? { TRAE_HOME: traeHome } : {}) };
+}
+
+/** Find an installed CLI on PATH or at its standard user-local installation path. */
+export function resolveTraeCli(deps: PathLookupDeps = {}): string | undefined {
+ const platform = deps.platform ?? process.platform;
+ const onPath = whichFromPath("traecli", deps);
+ if (onPath) return (platform === "win32" ? win32 : posix).resolve(onPath);
+ const home = (deps.env ?? process.env)[platform === "win32" ? "USERPROFILE" : "HOME"];
+ if (!home) return undefined;
+ const directory = (platform === "win32" ? win32 : posix).resolve(home, ".local", "bin");
+ return whichFromPath("traecli", { ...deps, platform, env: { PATH: directory } });
+}
diff --git a/src/adapters/trae-cn/protocol.ts b/src/adapters/trae-cn/protocol.ts
new file mode 100644
index 00000000000..eb86df9b160
--- /dev/null
+++ b/src/adapters/trae-cn/protocol.ts
@@ -0,0 +1,118 @@
+import type { AdapterEvent, OcxUsage } from "../../types";
+import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits";
+
+export class TraeAcpProtocolError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "TraeAcpProtocolError";
+ }
+}
+
+export type TraeAcpMessage = Record;
+export type TraeAcpFrame =
+ | { kind: "response"; id: number; result?: unknown; error?: { code?: number; message: string } }
+ | { kind: "update"; sessionId: string; update: Record }
+ | { kind: "request"; id: number; method: string };
+
+/** Exclude primitives and arrays before validating fields in an ACP object. */
+function record(value: unknown): Record | undefined {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined;
+}
+
+/** Validate the JSON-RPC envelope before dispatching a reply, update, or client request. */
+export function parseTraeAcpMessage(value: unknown): TraeAcpFrame {
+ const message = record(value);
+ if (!message || message.jsonrpc !== "2.0") throw new TraeAcpProtocolError("Invalid ACP JSON-RPC envelope");
+ if (message.method === "session/update") {
+ const params = record(message.params);
+ const update = record(params?.update);
+ if (typeof params?.sessionId !== "string" || !update) throw new TraeAcpProtocolError("Invalid ACP session update");
+ return { kind: "update", sessionId: params.sessionId, update };
+ }
+ if (typeof message.method === "string") {
+ if (!Number.isSafeInteger(message.id)) throw new TraeAcpProtocolError("Invalid ACP request id");
+ return { kind: "request", id: message.id as number, method: message.method };
+ }
+ if (!Number.isSafeInteger(message.id)) throw new TraeAcpProtocolError("Invalid ACP response id");
+ if (message.error !== undefined) {
+ const error = record(message.error);
+ if (!error || typeof error.message !== "string" || error.message.length > 4096) {
+ throw new TraeAcpProtocolError("Invalid ACP error response");
+ }
+ return { kind: "response", id: message.id as number, error: {
+ ...(typeof error.code === "number" ? { code: error.code } : {}), message: error.message,
+ } };
+ }
+ return { kind: "response", id: message.id as number, result: message.result };
+}
+
+/** Keep only bounded, publishable model identifiers from the signed-in session roster. */
+export function parseTraeModels(result: unknown): string[] {
+ const models = record(record(result)?.models)?.availableModels;
+ if (!Array.isArray(models)) throw new TraeAcpProtocolError("ACP session did not provide a model catalog");
+ const ids = new Set();
+ for (const row of models) {
+ const id = record(row)?.modelId;
+ if (isValidModelDiscoveryModelId(id)) ids.add(id);
+ if (ids.size >= 256) break;
+ }
+ if (ids.size === 0) throw new TraeAcpProtocolError("ACP session returned no usable models");
+ return [...ids];
+}
+
+/** Require a session identifier before issuing model-selection or prompt requests. */
+export function traeSessionId(result: unknown): string {
+ const id = record(result)?.sessionId;
+ if (typeof id !== "string" || !id) throw new TraeAcpProtocolError("ACP session id is missing");
+ return id;
+}
+
+/** Map text and reasoning deltas; tools and unrecognized updates fail closed. */
+export function traePromptEvents(sessionId: string, update: Record): AdapterEvent[] {
+ const kind = update.sessionUpdate;
+ if (kind === "agent_message_chunk" || kind === "agent_thought_chunk") {
+ const content = record(update.content);
+ if (content?.type !== "text" || typeof content.text !== "string") {
+ throw new TraeAcpProtocolError("Invalid ACP text chunk");
+ }
+ if (!content.text) return [];
+ return kind === "agent_message_chunk"
+ ? [{ type: "text_delta", text: content.text }]
+ : [{ type: "thinking_delta", thinking: content.text }];
+ }
+ if (kind === "tool_call" || kind === "tool_call_update") {
+ return [{
+ type: "error", status: 502, errorType: "upstream_error", code: "trae_tool_call_unsupported", retryable: false,
+ message: "TRAE CN requested a tool through ACP, but this text-only provider does not expose client tools.",
+ }];
+ }
+ if (kind !== "available_commands_update" && kind !== "current_mode_update" && kind !== "config_option_update"
+ && kind !== "session_info_update" && kind !== "usage_update" && kind !== "plan"
+ && kind !== "user_message_chunk") {
+ throw new TraeAcpProtocolError(`Unsupported ACP session update for ${sessionId}: ${String(kind)}`);
+ }
+ return [];
+}
+
+/** Translate documented ACP terminal reasons without treating unknown values as success. */
+export function traeStopReason(result: unknown): string {
+ const stop = record(result)?.stopReason;
+ if (stop === "end_turn") return "stop";
+ if (stop === "max_tokens") return "max_tokens";
+ if (stop === "max_turn_requests") return "max_turn_requests";
+ if (stop === "refusal") return "refusal";
+ if (stop === "cancelled") return "cancelled";
+ throw new TraeAcpProtocolError(`Unsupported ACP stop reason: ${String(stop)}`);
+}
+
+/** Accept upstream usage only when both token counts are nonnegative safe integers. */
+export function traeUsage(result: unknown): OcxUsage | undefined {
+ const usage = record(record(result)?.usage);
+ if (!usage) return undefined;
+ const number = (value: unknown): number | undefined => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
+ const inputTokens = number(usage.inputTokens);
+ const outputTokens = number(usage.outputTokens);
+ const totalTokens = number(usage.totalTokens);
+ if (inputTokens === undefined || outputTokens === undefined) return undefined;
+ return { inputTokens, outputTokens, ...(totalTokens !== undefined ? { totalTokens } : {}), upstreamReported: true };
+}
diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts
new file mode 100644
index 00000000000..230f4e38a6c
--- /dev/null
+++ b/src/adapters/trae-cn/session.ts
@@ -0,0 +1,485 @@
+import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { commandInvocation } from "../../lib/win-exec";
+import { redactSecretString } from "../../lib/redact";
+import type { AdapterEvent, OcxProviderConfig, OcxUsage } from "../../types";
+import { CodingAgentStreamLimitError, readJsonLines } from "../coding-agent/protocol";
+import { resolveTraeCli, traeCnChildEnv, traeCnDestinationMatches } from "./profile";
+import { traePosixInvocation, traeStopPipe } from "./process";
+import { prepareTraeWindowsJob, TraeWindowsJobFrames } from "./windows-job";
+import { parseTraeAcpMessage, parseTraeModels, traePromptEvents, traeSessionId, traeStopReason, traeUsage, TraeAcpProtocolError, type TraeAcpFrame } from "./protocol";
+
+export type SpawnTraeCli = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
+export interface TraeAcpDeps {
+ spawn?: SpawnTraeCli;
+ resolveBinary?: () => string | undefined;
+ home?: string;
+ cwd?: string;
+ platform?: NodeJS.Platform;
+ timeoutMs?: number;
+ killGraceMs?: number;
+ reapTimeoutMs?: number;
+ makeTempDir?: typeof mkdtemp;
+}
+
+export class TraeAcpError extends Error {
+ constructor(message: string, readonly code = "trae_acp_error", readonly usage?: OcxUsage) {
+ super(message);
+ this.name = "TraeAcpError";
+ }
+}
+
+/** Keep actionable local errno values without forwarding paths or arbitrary error text. */
+function startupFailure(stage: "resolve_cli" | "environment" | "home" | "workspace" | "spawn", error: unknown): TraeAcpError {
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
+ const safeCode = typeof code === "string" && ["ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "EMFILE", "ENFILE", "EAGAIN", "EINVAL"].includes(code) ? code : "UNKNOWN";
+ return new TraeAcpError(`TRAE CN startup failed during ${stage} (${safeCode}).`, `trae_startup_${stage}_${safeCode.toLowerCase()}`);
+}
+
+type Pending = { resolve: (result: unknown) => void; reject: (error: Error) => void; timer: ReturnType };
+
+export interface TraePromptDispatch {
+ beforeSend(): void;
+ onSend(): void;
+}
+
+/** Bound child and stream teardown even when a dispatcher keeps its pipes open. */
+async function waitBounded(promise: Promise, timeoutMs: number): Promise {
+ let timer: ReturnType | undefined;
+ try {
+ return await Promise.race([promise.then(() => true), new Promise(resolve => {
+ timer = setTimeout(() => resolve(false), timeoutMs);
+ })]);
+ } finally { clearTimeout(timer); }
+}
+
+/** Own the JSON-RPC stream, pending requests, and bounded process shutdown. */
+class TraeAcpClient {
+ private nextId = 0;
+ private readonly pending = new Map();
+ private readonly updates: TraeAcpFrame[] = [];
+ private readonly waiters: Array<(frame: TraeAcpFrame | null) => void> = [];
+ private stderr = "";
+ private stderrTruncated = false;
+ private readonly windowsFrames?: TraeWindowsJobFrames;
+ private initialized = false;
+ private launchStatus?: number;
+ private spawnFailed = false;
+ private selectedModel?: { sessionId: string; modelId: string };
+ private readonly reader: Promise;
+ private readonly lifecycle: Promise;
+ private failure?: Error;
+ private closed = false;
+ private updatesEnded = false;
+ private closing?: Promise;
+
+ constructor(private readonly child: ChildProcess, private readonly timeoutMs: number, private readonly deps: TraeAcpDeps) {
+ if ((deps.platform ?? process.platform) === "win32") {
+ this.windowsFrames = new TraeWindowsJobFrames(text => this.appendStderr(text), (stage, code) => {
+ this.fail(new TraeAcpError("TRAE CN Windows supervisor failed during " + stage + " (Win32 " + code + ").",
+ stage === "transport" ? "trae_acp_transport" : "trae_startup_windows_" + stage));
+ });
+ }
+ this.lifecycle = new Promise(resolve => {
+ child.once("error", error => {
+ this.spawnFailed = child.pid === undefined && !this.initialized;
+ this.fail(startupFailure("spawn", error));
+ });
+ child.once("close", () => { this.windowsFrames?.finish(); resolve(); });
+ });
+ child.stdin?.on("error", error => this.fail(error));
+ traeStopPipe(child)?.on("error", () => undefined);
+ let control = "";
+ traeStopPipe(child)?.on("data", (chunk: Uint8Array) => {
+ control = (control + Buffer.from(chunk).toString("ascii")).slice(-64);
+ const launch = /OCX_TRAE_LAUNCH:(126|127)\n/.exec(control);
+ if (launch) this.launchStatus = Number(launch[1]);
+ });
+ const stderrDecoder = new TextDecoder();
+ child.stderr?.on("data", (chunk: Uint8Array) => {
+ if (this.windowsFrames) this.windowsFrames.push(chunk);
+ else this.appendStderr(stderrDecoder.decode(chunk, { stream: true }));
+ });
+ child.stderr?.once("end", () => { if (!this.windowsFrames) this.appendStderr(stderrDecoder.decode()); });
+ this.reader = this.read();
+ this.reader.catch(() => undefined);
+ }
+
+ private appendStderr(text: string): void {
+ if (this.stderrTruncated) return;
+ if (this.stderr.length + text.length > 8192) {
+ this.stderr = "";
+ this.stderrTruncated = true;
+ } else this.stderr += text;
+ }
+
+ markInitialized(): void { this.initialized = true; }
+
+ /** A successful set_model supersedes setup notifications; guard updates after that acknowledgement. */
+ acknowledgeModelSelection(sessionId: string, modelId: string): void {
+ this.selectedModel = { sessionId, modelId };
+ for (let index = this.updates.length - 1; index >= 0; index--) {
+ const frame = this.updates[index]!;
+ if (frame.kind === "update" && frame.sessionId === sessionId && frame.update.sessionUpdate === "current_model_update") {
+ this.updates.splice(index, 1);
+ }
+ }
+ }
+
+ closedError(error: TraeAcpError): TraeAcpError {
+ if (!this.initialized && this.launchStatus) {
+ return startupFailure("spawn", { code: this.launchStatus === 127 ? "ENOENT" : "EACCES" });
+ }
+ return new TraeAcpError(this.diagnostic() || error.message, error.code, error.usage);
+ }
+
+ /** Wake every waiter immediately; process reaping is separate from request settlement. */
+ private fail(error: Error): void {
+ if (this.closed) return;
+ this.closed = true;
+ this.failure ??= error;
+ for (const waiter of this.pending.values()) { clearTimeout(waiter.timer); waiter.reject(error); }
+ this.pending.clear();
+ for (const waiter of this.waiters.splice(0)) waiter(null);
+ }
+
+ /** Demultiplex bounded replies and session updates; client capabilities stay disabled. */
+ private async read(): Promise {
+ try {
+ if (!this.child.stdout) throw new TraeAcpProtocolError("TRAE CN ACP stdout is unavailable");
+ for await (const message of readJsonLines(this.child.stdout, { maxLineBytes: 1024 * 1024, maxTotalBytes: 64 * 1024 * 1024 })) {
+ const frame = parseTraeAcpMessage(message);
+ if (frame.kind === "request") {
+ this.write({ jsonrpc: "2.0", id: frame.id, error: { code: -32601, message: "Client capabilities are disabled for this text-only provider" } });
+ throw new TraeAcpError("TRAE CN requested a disabled client capability", "trae_client_request_unsupported");
+ }
+ if (frame.kind === "response") {
+ const waiter = this.pending.get(frame.id);
+ if (!waiter) continue;
+ this.pending.delete(frame.id);
+ clearTimeout(waiter.timer);
+ if (frame.error) waiter.reject(new TraeAcpError(redactSecretString(frame.error.message).slice(0, 512), "trae_acp_rpc_error"));
+ else waiter.resolve(frame.result);
+ continue;
+ }
+ const selected = this.selectedModel;
+ if (selected && frame.sessionId === selected.sessionId && frame.update.sessionUpdate === "current_model_update") {
+ if (frame.update.currentModelId !== selected.modelId) {
+ throw new TraeAcpError("TRAE CN changed the selected model unexpectedly.", "trae_model_changed");
+ }
+ continue;
+ }
+ if (this.waiters.length) this.waiters.shift()!(frame);
+ else if (this.updates.length < 1024) this.updates.push(frame);
+ else throw new TraeAcpProtocolError("TRAE CN ACP update queue exceeded its bound");
+ }
+ this.fail(this.failure ?? new TraeAcpError("TRAE CN ACP closed before the turn completed", "trae_acp_closed"));
+ } catch (error) {
+ // The shared JSONL parser includes stdout snippets, which can contain partial secrets.
+ this.fail(error instanceof CodingAgentStreamLimitError
+ ? new TraeAcpError("TRAE CN ACP stdout exceeded its size limit.", "trae_stdout_limit")
+ : error instanceof TraeAcpError ? error
+ : new TraeAcpProtocolError("TRAE CN ACP returned malformed or unsupported protocol data."));
+ }
+ }
+
+ /** Write one JSON-RPC frame only while the child input remains available. */
+ write(message: Record): void {
+ if (this.closed || !this.child.stdin?.writable) throw new TraeAcpError("TRAE CN ACP input is closed", "trae_acp_closed");
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
+ }
+
+ /** Register before writing so immediate responses and stream errors settle the same request. */
+ async request(method: string, params: Record, dispatch?: TraePromptDispatch,
+ onSuccess?: (result: unknown) => void): Promise {
+ if (this.closed) throw this.failure ?? new TraeAcpError("TRAE CN ACP is closed", "trae_acp_closed");
+ const id = ++this.nextId;
+ const result = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(id);
+ reject(new TraeAcpError(`TRAE CN ACP timed out during ${method}`, "trae_acp_timeout"));
+ }, this.timeoutMs);
+ this.pending.set(id, { resolve: result => {
+ try { onSuccess?.(result); resolve(result); } catch (error) { reject(error); }
+ }, reject, timer });
+ });
+ try {
+ dispatch?.beforeSend();
+ this.write({ jsonrpc: "2.0", id, method, params });
+ dispatch?.onSend();
+ } catch (error) { this.fail(error instanceof Error ? error : new Error(String(error))); }
+ return result;
+ }
+
+ /** Drain already-received updates before reporting end of the stream. */
+ nextUpdate(): Promise {
+ const queued = this.updates.shift();
+ if (queued) return Promise.resolve(queued);
+ if (this.closed || this.updatesEnded) return Promise.resolve(null);
+ return new Promise(resolve => this.waiters.push(resolve));
+ }
+
+ /** Settle the one update consumer without subscribing again for every streamed chunk. */
+ endUpdates(): void {
+ this.updatesEnded = true;
+ for (const waiter of this.waiters.splice(0)) waiter(null);
+ }
+
+ /** Expose only a bounded, redacted diagnostic when the CLI closes unexpectedly. */
+ diagnostic(): string {
+ if (this.stderrTruncated) return "TRAE CN stderr exceeded the diagnostic limit; its contents were suppressed.";
+ return redactSecretString(this.stderr.trim() || this.failure?.message || "").trim().slice(0, 512);
+ }
+
+ /** Return a fatal reader verdict that arrived after its prompt reply was already resolved. */
+ readerFailure(): Error | undefined { return this.failure; }
+
+ /** Stop RPCs first, then terminate the owned process tree and bound all remaining waits. */
+ async close(): Promise {
+ if (this.closing) return this.closing;
+ this.fail(new TraeAcpError("TRAE CN ACP session closed", "trae_acp_closed"));
+ this.closing = (async () => {
+ const reapTimeoutMs = this.deps.reapTimeoutMs ?? 2250;
+ const graceMs = this.deps.killGraceMs ?? 2000;
+ let stopped: boolean;
+ if ((this.deps.platform ?? process.platform) === "win32") {
+ try { this.child.stdin?.end("__OCX_STOP_TRAE__\n"); } catch { /* Supervisor may have exited. */ }
+ let exited = await waitBounded(this.lifecycle, graceMs + reapTimeoutMs);
+ if (!exited) {
+ // Closing the supervisor's non-inherited Job handle kills remaining members.
+ // Without its accounting proof we still report an unconfirmed shutdown.
+ try { this.child.kill("SIGKILL"); } catch { /* Already exited. */ }
+ exited = await waitBounded(this.lifecycle, reapTimeoutMs);
+ }
+ stopped = exited && (this.spawnFailed || this.windowsFrames?.processTreeExited === true);
+ } else {
+ try { traeStopPipe(this.child)?.end("stop\n"); } catch { /* An exited supervisor already closed the descriptor. */ }
+ stopped = await waitBounded(this.lifecycle, graceMs + reapTimeoutMs);
+ }
+ traeStopPipe(this.child)?.destroy();
+ this.child.stdin?.destroy();
+ this.child.stdout?.destroy();
+ this.child.stderr?.destroy();
+ await waitBounded(this.reader, reapTimeoutMs);
+ if (!stopped) throw new TraeAcpError("TRAE CN process shutdown was not confirmed before its deadline.", "trae_shutdown_timeout");
+ })();
+ return this.closing;
+ }
+}
+
+export interface TraeSession {
+ readonly models: string[];
+ prompt(modelId: string, text: string, emit: (event: AdapterEvent) => void, signal?: AbortSignal, dispatch?: TraePromptDispatch): Promise;
+ close(): Promise;
+}
+
+/** Start a cancellable ACP session in owned temporary directories; callers close successful sessions. */
+export async function openTraeSession(
+ baseUrl: string | undefined, deps: TraeAcpDeps = {}, signal?: AbortSignal,
+ routing: Pick = {},
+): Promise {
+ signal?.throwIfAborted();
+ if (routing.proxy !== undefined || routing.noProxy !== undefined) {
+ throw new TraeAcpError("TRAE CN ACP cannot honor provider proxy/noProxy overrides; the official CLI owns its network route.", "trae_proxy_unsupported");
+ }
+ if (!traeCnDestinationMatches(baseUrl)) throw new TraeAcpError("TRAE CN requires its canonical provider destination", "non_canonical_destination");
+ let binary: string | undefined;
+ try { binary = (deps.resolveBinary ?? resolveTraeCli)(); } catch (error) { throw startupFailure("resolve_cli", error); }
+ if (!binary) throw new TraeAcpError("TRAE CN CLI not found. Install traecli and sign in through TRAE CN.", "cli_not_found");
+ let env: Record;
+ try { env = traeCnChildEnv(process.env, deps.platform ?? process.platform); } catch (error) { throw startupFailure("environment", error); }
+ const ownHome = deps.home === undefined;
+ const makeTempDir = deps.makeTempDir ?? mkdtemp;
+ let home: string;
+ try { home = deps.home ?? await makeTempDir(join(tmpdir(), "ocx-trae-cn-")); } catch (error) { throw startupFailure("home", error); }
+ let work: string;
+ try { work = deps.cwd ?? await makeTempDir(join(tmpdir(), "ocx-trae-cn-work-")); } catch (error) {
+ if (ownHome) await rm(home, { recursive: true, force: true }).catch(() => undefined);
+ throw startupFailure("workspace", error);
+ }
+ let cleanupSupervisor: (() => Promise) | undefined;
+ const cleanup = async (): Promise => {
+ await cleanupSupervisor?.().catch(() => undefined);
+ await Promise.all([
+ ...(ownHome ? [rm(home, { recursive: true, force: true }).catch(() => undefined)] : []),
+ ...(!deps.cwd ? [rm(work, { recursive: true, force: true }).catch(() => undefined)] : []),
+ ]);
+ };
+ env.HOME = home;
+ if ((deps.platform ?? process.platform) === "win32") env.USERPROFILE = home;
+ const disabledTools = ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebFetch", "TodoWrite", "AskUserQuestion"];
+ const args = [...disabledTools.flatMap(tool => ["--disallowed-tool", tool]), "acp", "serve"];
+ let child: ChildProcess;
+ try {
+ signal?.throwIfAborted();
+ const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env });
+ const windows = (deps.platform ?? process.platform) === "win32";
+ const owned = windows
+ ? await prepareTraeWindowsJob(invocation, home, work, deps.reapTimeoutMs ?? 2250, env)
+ : { ...traePosixInvocation(invocation.file, invocation.args, deps.killGraceMs ?? 2000), options: {} };
+ if ("cleanup" in owned) cleanupSupervisor = owned.cleanup;
+ signal?.throwIfAborted();
+ child = (deps.spawn ?? nodeSpawn)(owned.file, owned.args, {
+ ...owned.options, env, stdio: windows ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"], windowsHide: true,
+ detached: !windows,
+ cwd: deps.cwd ?? work,
+ });
+ } catch (error) {
+ await cleanup();
+ if (signal?.aborted) throw error;
+ throw startupFailure("spawn", error);
+ }
+ const client = new TraeAcpClient(child, deps.timeoutMs ?? 300_000, deps);
+ const abort = () => { void client.close().catch(() => undefined); };
+ signal?.addEventListener("abort", abort, { once: true });
+ let opened = false;
+ try {
+ signal?.throwIfAborted();
+ await client.request("initialize", {
+ protocolVersion: 1,
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
+ clientInfo: { name: "opencodex", title: "OpenCodex", version: "1" },
+ });
+ client.markInitialized();
+ const result = await client.request("session/new", {
+ cwd: deps.cwd ?? work, mcpServers: [], _meta: { trae: { options: { skills: [] } } },
+ });
+ const sessionId = traeSessionId(result);
+ const models = parseTraeModels(result);
+ opened = true;
+ return {
+ models,
+ async prompt(modelId, text, emit, signal, dispatch) {
+ if (signal?.aborted) return;
+ if (!models.includes(modelId)) throw new TraeAcpError(`TRAE CN model is not available to this account: ${modelId}`, "trae_model_unavailable");
+ const abort = () => { void client.close().catch(() => undefined); };
+ signal?.addEventListener("abort", abort, { once: true });
+ let completion: Promise | undefined;
+ try {
+ await client.request("session/set_model", { sessionId, modelId }, undefined, () => client.acknowledgeModelSelection(sessionId, modelId));
+ if (signal?.aborted) return;
+ let output = false;
+ let receivedCompletion: unknown;
+ let completionReceived = false;
+ completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, dispatch, result => {
+ receivedCompletion = result;
+ completionReceived = true;
+ });
+ const endUpdatesAfterBatch = () => {
+ const timer = setTimeout(() => client.endUpdates(), 0);
+ timer.unref?.();
+ };
+ const drainBufferedCompletion = async (): Promise => {
+ if (!completionReceived) await new Promise(resolve => { setTimeout(resolve, 0); });
+ };
+ void completion.then(endUpdatesAfterBatch, endUpdatesAfterBatch);
+ while (true) {
+ const frame = await client.nextUpdate();
+ if (!frame) break;
+ if (frame.kind !== "update" || frame.sessionId !== sessionId) continue;
+ let promptEvents: AdapterEvent[];
+ try {
+ promptEvents = traePromptEvents(sessionId, frame.update);
+ } catch {
+ await drainBufferedCompletion();
+ const usage = completionReceived ? traeUsage(receivedCompletion) : undefined;
+ emit({ type: "error", message: "TRAE CN ACP ended with an invalid protocol response.",
+ code: "trae_acp_protocol_error", status: 502, errorType: "upstream_error", retryable: false,
+ ...(usage ? { usage } : {}) });
+ client.write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
+ await client.close();
+ return;
+ }
+ for (const event of promptEvents) {
+ if (event.type === "error") {
+ await drainBufferedCompletion();
+ const usage = completionReceived ? traeUsage(receivedCompletion) : undefined;
+ emit({ ...event, ...(usage ? { usage } : {}) });
+ client.write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
+ await client.close();
+ return;
+ }
+ if (event.type === "text_delta") output = true;
+ emit(event);
+ }
+ }
+ const result = await completion;
+ const usage = traeUsage(result);
+ const readerFailure = signal?.aborted ? undefined : client.readerFailure();
+ if (readerFailure) {
+ if (readerFailure instanceof TraeAcpError) {
+ throw new TraeAcpError(readerFailure.message, readerFailure.code, usage);
+ }
+ throw new TraeAcpError("TRAE CN ACP ended with an invalid protocol response.",
+ "trae_acp_protocol_error", usage);
+ }
+ let stop: string;
+ try { stop = traeStopReason(result); } catch {
+ emit({ type: "error", message: "TRAE CN ACP ended with an invalid protocol response.", code: "trae_acp_protocol_error",
+ status: 502, errorType: "upstream_error", retryable: false, ...(usage ? { usage } : {}) });
+ return;
+ }
+ if (stop === "cancelled") {
+ emit({ type: "incomplete", reason: "upstream_cancelled", retryable: false,
+ message: "TRAE CN cancelled the turn.", ...(usage ? { usage } : {}) });
+ return;
+ }
+ if (stop === "refusal") {
+ emit({ type: "incomplete", reason: "content_filter", retryable: false,
+ message: "TRAE CN refused the turn.", ...(usage ? { usage } : {}) });
+ return;
+ }
+ if (stop === "max_turn_requests") {
+ emit({ type: "incomplete", reason: "max_turn_requests", retryable: false,
+ message: "TRAE CN reached its request limit for this turn.", ...(usage ? { usage } : {}) });
+ return;
+ }
+ const terminalError = stop !== "stop" && stop !== "max_tokens"
+ ? { message: "TRAE CN stopped the turn: " + stop, code: "trae_turn_stopped" }
+ : !output && stop !== "max_tokens" ? { message: "TRAE CN ended without a text response.", code: "trae_empty_response" } : undefined;
+ if (terminalError) {
+ emit({ type: "error", ...terminalError,
+ status: 502, errorType: "upstream_error", retryable: false, ...(usage ? { usage } : {}) });
+ return;
+ }
+ emit({ type: "done", stopReason: stop, ...(usage ? { usage } : {}) });
+ } catch (error) {
+ if (error instanceof TraeAcpError && error.code === "trae_acp_closed") {
+ try {
+ await client.close();
+ } catch (cleanupError) {
+ if (cleanupError instanceof TraeAcpError && error.usage && !cleanupError.usage) {
+ throw new TraeAcpError(cleanupError.message, cleanupError.code, error.usage);
+ }
+ throw cleanupError;
+ }
+ throw client.closedError(error);
+ }
+ throw error;
+ } finally {
+ signal?.removeEventListener("abort", abort);
+ completion?.catch(() => undefined);
+ }
+ },
+ close: async () => {
+ signal?.removeEventListener("abort", abort);
+ await client.close();
+ await cleanup();
+ },
+ };
+ } catch (error) {
+ if (error instanceof TraeAcpError && error.code === "trae_acp_closed") {
+ await client.close();
+ throw client.closedError(error);
+ }
+ throw error;
+ } finally {
+ if (!opened) {
+ signal?.removeEventListener("abort", abort);
+ // Keep owned files when process exit could not be confirmed.
+ await client.close().then(cleanup).catch(() => undefined);
+ }
+ }
+}
diff --git a/src/adapters/trae-cn/windows-job-source.ts b/src/adapters/trae-cn/windows-job-source.ts
new file mode 100644
index 00000000000..770b13fe745
--- /dev/null
+++ b/src/adapters/trae-cn/windows-job-source.ts
@@ -0,0 +1,172 @@
+/** Windows PowerShell 5.1 host; child membership and inherited handles are atomic. */
+export const TRAE_WINDOWS_JOB_SOURCE = String.raw`
+using System;
+using System.Collections.Concurrent;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Pipes;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+public static class TraeAcpJob {
+ [StructLayout(LayoutKind.Sequential)] struct Startup {
+ public int cb; public IntPtr reserved, desktop, title;
+ public int x, y, width, height, charsX, charsY, fill, flags;
+ public short show, reservedSize; public IntPtr reservedData, stdin, stdout, stderr;
+ }
+ [StructLayout(LayoutKind.Sequential)] struct ProcessInfo {
+ public IntPtr process, thread; public uint pid, tid;
+ }
+ [StructLayout(LayoutKind.Sequential)] struct StartupEx { public Startup startup; public IntPtr attributes; }
+ [StructLayout(LayoutKind.Sequential)] struct Limits {
+ public long processTime, jobTime; public uint flags;
+ public UIntPtr minWorkingSet, maxWorkingSet; public uint activeLimit;
+ public UIntPtr affinity; public uint priority, scheduling;
+ }
+ [StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
+ public Limits basic;
+ public ulong readOps, writeOps, otherOps, readBytes, writeBytes, otherBytes;
+ public UIntPtr processMemory, jobMemory, peakProcessMemory, peakJobMemory;
+ }
+ [StructLayout(LayoutKind.Sequential)] struct Accounting {
+ public long userTime, kernelTime, periodUserTime, periodKernelTime;
+ public uint faults, total, active, terminated;
+ }
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
+ static extern IntPtr CreateJobObject(IntPtr attributes, string name);
+ [DllImport("kernel32.dll", SetLastError=true)]
+ static extern bool SetInformationJobObject(IntPtr job, int info, ref ExtendedLimits limits, uint size);
+ [DllImport("kernel32.dll", SetLastError=true)]
+ static extern bool QueryInformationJobObject(IntPtr job, int info, out Accounting data, uint size, IntPtr returned);
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
+ static extern bool CreateProcess(string file, StringBuilder args, IntPtr pa, IntPtr ta, bool inherit, uint flags,
+ IntPtr environment, string cwd, ref StartupEx startup, out ProcessInfo process);
+ [DllImport("kernel32.dll", SetLastError=true)]
+ static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, uint flags, ref IntPtr size);
+ [DllImport("kernel32.dll", SetLastError=true)]
+ static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value,
+ IntPtr size, IntPtr previous, IntPtr returned);
+ [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list);
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code);
+ [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread);
+ [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle, uint ms);
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
+ static readonly object gate = new object();
+ static StreamWriter status;
+ static void Frame(string line) { lock (gate) { status.WriteLine(line); status.Flush(); } }
+ static void Require(bool ok) { if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); }
+ static uint Active(IntPtr job) {
+ Accounting data;
+ Require(QueryInformationJobObject(job, 1, out data, (uint)Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero));
+ return data.active;
+ }
+ public static int Run(string file, string args, string cwd, int joinMs) {
+ status = new StreamWriter(Console.OpenStandardError(), new UTF8Encoding(false));
+ status.NewLine = "\n";
+ IntPtr job = IntPtr.Zero, attributes = IntPtr.Zero, jobList = IntPtr.Zero, handles = IntPtr.Zero;
+ ProcessInfo process = new ProcessInfo();
+ bool started = false, attributesInitialized = false, stopped = false;
+ string stage = "setup";
+ Task outTask = null, errTask = null;
+ try {
+ var parent = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
+ string first = parent.ReadLine();
+ if (first == null || first == "__OCX_STOP_TRAE__") return 0;
+ job = CreateJobObject(IntPtr.Zero, null); Require(job != IntPtr.Zero);
+ ExtendedLimits limits = new ExtendedLimits();
+ limits.basic.flags = 0x2000; // KILL_ON_JOB_CLOSE; no breakaway.
+ Require(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(typeof(ExtendedLimits))));
+ var input = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable);
+ var output = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
+ var errors = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
+ StartupEx startup = new StartupEx(); startup.startup.cb = Marshal.SizeOf(typeof(StartupEx)); startup.startup.flags = 0x100;
+ startup.startup.stdin = input.ClientSafePipeHandle.DangerousGetHandle();
+ startup.startup.stdout = output.ClientSafePipeHandle.DangerousGetHandle();
+ startup.startup.stderr = errors.ClientSafePipeHandle.DangerousGetHandle();
+ IntPtr size = IntPtr.Zero;
+ InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref size); Require(size != IntPtr.Zero);
+ attributes = Marshal.AllocHGlobal(size);
+ Require(InitializeProcThreadAttributeList(attributes, 2, 0, ref size));
+ attributesInitialized = true; startup.attributes = attributes;
+ jobList = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(jobList, job);
+ Require(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x0002000D), jobList,
+ new IntPtr(IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); // Atomic Job List.
+ handles = Marshal.AllocHGlobal(3 * IntPtr.Size);
+ Marshal.WriteIntPtr(handles, 0, startup.startup.stdin);
+ Marshal.WriteIntPtr(handles, IntPtr.Size, startup.startup.stdout);
+ Marshal.WriteIntPtr(handles, 2 * IntPtr.Size, startup.startup.stderr);
+ Require(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x00020002), handles,
+ new IntPtr(3 * IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); // Only CLI pipes; never Job/status handles.
+ stage = "create";
+ Require(CreateProcess(file, new StringBuilder(args), IntPtr.Zero, IntPtr.Zero, true,
+ 0x08080004, IntPtr.Zero, cwd, ref startup, out process)); // NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | SUSPENDED
+ started = true;
+ input.DisposeLocalCopyOfClientHandle(); output.DisposeLocalCopyOfClientHandle(); errors.DisposeLocalCopyOfClientHandle();
+ stage = "resume";
+ Require(ResumeThread(process.thread) != UInt32.MaxValue);
+ stage = "transport";
+ var stop = new ManualResetEvent(false);
+ var commands = new BlockingCollection(16);
+ commands.Add(first);
+ // Stop/EOF remains readable even if the CLI stops reading its stdin.
+ Task.Run(() => {
+ try {
+ string line;
+ while ((line = parent.ReadLine()) != null && line != "__OCX_STOP_TRAE__") {
+ if (!commands.TryAdd(line)) { Frame("OCX_TRAE_ERROR:transport:0"); break; }
+ }
+ } catch { Frame("OCX_TRAE_ERROR:transport:0"); }
+ finally { stop.Set(); commands.CompleteAdding(); }
+ });
+ Task.Run(() => {
+ try {
+ var writer = new StreamWriter(input, new UTF8Encoding(false)); writer.NewLine = "\n"; writer.AutoFlush = true;
+ foreach (string line in commands.GetConsumingEnumerable()) writer.WriteLine(line);
+ } catch { stop.Set(); }
+ });
+ outTask = Task.Run(() => {
+ try { output.CopyTo(Console.OpenStandardOutput()); }
+ finally { stop.Set(); }
+ });
+ errTask = Task.Run(() => {
+ try {
+ byte[] buffer = new byte[3072]; int count;
+ while ((count = errors.Read(buffer, 0, buffer.Length)) != 0)
+ Frame("OCX_TRAE_STDERR:" + Convert.ToBase64String(buffer, 0, count));
+ } catch { stop.Set(); }
+ });
+ // Root exit also stops the job, including helpers that kept inherited pipes open.
+ while (!stop.WaitOne(20) && WaitForSingleObject(process.process, 0) == 258) { }
+ } catch (Exception error) {
+ var native = error as Win32Exception;
+ Frame("OCX_TRAE_ERROR:" + stage + ":" + (native == null ? 0 : native.NativeErrorCode));
+ } finally {
+ stopped = !started;
+ if (started) {
+ try {
+ Require(TerminateJobObject(job, 1));
+ var clock = Stopwatch.StartNew();
+ while (Active(job) != 0 && clock.ElapsedMilliseconds < joinMs) Thread.Sleep(10);
+ stopped = Active(job) == 0 && WaitForSingleObject(process.process, 0) == 0;
+ } catch { stopped = false; }
+ }
+ if (job != IntPtr.Zero) CloseHandle(job);
+ // Drain trailing stderr before reporting exit; every wait remains bounded.
+ try { if (outTask != null && errTask != null) Task.WaitAll(new Task[] { outTask, errTask }, joinMs); }
+ catch (AggregateException) { Frame("OCX_TRAE_ERROR:transport:0"); }
+ if (attributesInitialized) DeleteProcThreadAttributeList(attributes);
+ if (attributes != IntPtr.Zero) Marshal.FreeHGlobal(attributes);
+ if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles);
+ if (jobList != IntPtr.Zero) Marshal.FreeHGlobal(jobList);
+ if (process.thread != IntPtr.Zero) CloseHandle(process.thread);
+ if (process.process != IntPtr.Zero) CloseHandle(process.process);
+ // Process exit releases private pipes/workers. Dispose could wait on a blocked write.
+ Frame(stopped ? "OCX_TRAE_JOB_STOPPED" : "OCX_TRAE_JOB_STOP_FAILED");
+ }
+ return stopped ? 0 : 1;
+ }
+}
+`;
diff --git a/src/adapters/trae-cn/windows-job.ts b/src/adapters/trae-cn/windows-job.ts
new file mode 100644
index 00000000000..ab7f41b4f3b
--- /dev/null
+++ b/src/adapters/trae-cn/windows-job.ts
@@ -0,0 +1,100 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { join, win32 } from "node:path";
+import type { SpawnInvocation } from "../../lib/win-exec";
+import { TRAE_WINDOWS_JOB_SOURCE } from "./windows-job-source";
+
+/** Scoped env keys are uppercase; native CreateProcess does not search for lpApplicationName. */
+function windowsEnv(env: Record, name: string): string | undefined {
+ return Object.entries(env).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
+}
+
+/** Require an absolute native executable, resolving the cmd dispatcher from the scoped Windows env. */
+export function traeWindowsJobExecutable(file: string, env: Record): string {
+ if (win32.isAbsolute(file)) return file;
+ if (/^cmd(?:\.exe)?$/i.test(file)) {
+ const comspec = windowsEnv(env, "ComSpec");
+ if (comspec && win32.isAbsolute(comspec)) return comspec;
+ return win32.join(windowsEnv(env, "SystemRoot") ?? "C:\\Windows", "System32", "cmd.exe");
+ }
+ throw Object.assign(new Error("Native TRAE CN supervision requires an absolute executable path."), { code: "ENOENT" });
+}
+
+/** Quote the native command line once; cmd/bat arguments already carry cmd escaping. */
+export function traeWindowsCommandLine(invocation: SpawnInvocation): string {
+ const quote = (value: string): string => '"' + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
+ return quote(invocation.file) + " " + (invocation.options.windowsVerbatimArguments
+ ? invocation.args.join(" ") : invocation.args.map(quote).join(" "));
+}
+
+/** Use a small encoded loader so the embedded source cannot exceed CreateProcess's argv limit. */
+export async function prepareTraeWindowsJob(invocation: SpawnInvocation, home: string, cwd: string, joinMs: number,
+ env: Record): Promise }> {
+ const directory = await mkdtemp(join(home, "supervisor-"));
+ const cleanup = () => rm(directory, { recursive: true, force: true });
+ try {
+ invocation = { ...invocation, file: traeWindowsJobExecutable(invocation.file, env) };
+ const source = join(directory, "TraeAcpJob.cs");
+ await writeFile(source, TRAE_WINDOWS_JOB_SOURCE, { mode: 0o600 });
+ const literal = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
+ const deadline = Math.max(1, Math.min(60_000, Math.floor(joinMs / 2)));
+ const loader = [
+ "$ErrorActionPreference='Stop';$entered=$false;try {",
+ "Add-Type -TypeDefinition ([IO.File]::ReadAllText(" + literal(source) + ")) | Out-Null;",
+ "$entered=$true;$code=[TraeAcpJob]::Run(" + [invocation.file, traeWindowsCommandLine(invocation), cwd].map(literal).join(",") + "," + deadline + ");exit $code",
+ "} catch { [Console]::Error.WriteLine('OCX_TRAE_ERROR:loader:0');",
+ "if (-not $entered) { [Console]::Error.WriteLine('OCX_TRAE_JOB_STOPPED') };exit 1 }",
+ ].join("\n");
+ return { file: win32.join(windowsEnv(env, "SystemRoot") ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
+ args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(loader, "utf16le").toString("base64")],
+ options: {}, cleanup };
+ } catch (error) { await cleanup().catch(() => undefined); throw error; }
+}
+
+/** CLI stderr is encoded data, never supervisor control; storage stays bounded across split frames. */
+export class TraeWindowsJobFrames {
+ private line = "";
+ private dropping = false;
+ private invalid = false;
+ private stopped = false;
+ private readonly utf8 = new TextDecoder();
+ constructor(private readonly stderr: (text: string) => void, private readonly error: (stage: string, code: number) => void) {}
+
+ /** Frame ASCII supervisor records while dropping oversized or non-ASCII lines before interpretation. */
+ push(chunk: Uint8Array): void {
+ // Supervisor frames are ASCII. Decode the base64 payload's UTF-8 only after framing.
+ for (const byte of chunk) {
+ if (byte === 10) {
+ if (!this.dropping) this.frame(this.line.replace(/\r$/, ""));
+ this.line = "";
+ this.dropping = false;
+ } else if (!this.dropping) {
+ if (this.line.length >= 4200 || byte > 127) {
+ this.invalid = true;
+ this.line = "";
+ this.dropping = true;
+ } else this.line += String.fromCharCode(byte);
+ }
+ }
+ }
+
+ /** Accept allowlisted control records and bounded base64 stderr; reject every other frame. */
+ private frame(line: string): void {
+ if (line === "OCX_TRAE_JOB_STOPPED") { this.stopped = true; return; }
+ const diagnostic = /^OCX_TRAE_STDERR:([A-Za-z0-9+/]+={0,2})$/.exec(line);
+ if (diagnostic && diagnostic[1]!.length % 4 === 0 && diagnostic[1]!.length <= 4096) {
+ this.stderr(this.utf8.decode(Buffer.from(diagnostic[1]!, "base64"), { stream: true }));
+ return;
+ }
+ const failure = /^OCX_TRAE_ERROR:(setup|create|resume|transport|loader):(\d{1,10})$/.exec(line);
+ if (failure) { this.error(failure[1]!, Number(failure[2])); return; }
+ this.invalid = true;
+ }
+
+ /** Flush stderr decoding and reject a partial or previously dropped final control record. */
+ finish(): void {
+ if (this.line.length || this.dropping) this.invalid = true;
+ this.stderr(this.utf8.decode());
+ }
+
+ get processTreeExited(): boolean { return this.stopped && !this.invalid; }
+}
diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts
index 5267fa078d6..e440cbd68bc 100644
--- a/src/chat/inbound.ts
+++ b/src/chat/inbound.ts
@@ -459,6 +459,8 @@ export interface ChatResponsesProjectionOptions {
* Every other route keeps the plaintext projection.
*/
acceptAnthropicReasoningSignatures?: boolean;
+ /** Preserve instruction attachments when a policy retry can select a media-capable target. */
+ preserveInstructionMedia?: boolean;
}
export function chatCompletionsToResponsesBody(raw: unknown, options: ChatResponsesProjectionOptions = {}): Rec {
@@ -483,10 +485,21 @@ export function chatCompletionsToResponsesBody(raw: unknown, options: ChatRespon
// Anthropic and Google mappers synthesize a missing result — so an instruction that arrives
// inside an open batch waits for the batch to drain instead of splitting it.
const awaitingToolResult = new Set();
- const heldInstructions: string[] = [];
+ const heldInstructions: Array<{ text: string } | { content: Rec[] }> = [];
const releaseHeldInstructions = (): void => {
if (heldInstructions.length === 0) return;
- input.push(developerInstructionItem(heldInstructions.join("\n\n")));
+ let text: string[] = [];
+ const releaseText = (): void => {
+ if (text.length > 0) input.push(developerInstructionItem(text.join("\n\n")));
+ text = [];
+ };
+ for (const instruction of heldInstructions) {
+ if ("content" in instruction) {
+ releaseText();
+ input.push({ type: "message", role: "developer", content: instruction.content });
+ } else text.push(instruction.text);
+ }
+ releaseText();
heldInstructions.length = 0;
};
// A user or assistant turn ends any open tool batch, so held text rejoins the timeline
@@ -503,19 +516,31 @@ export function chatCompletionsToResponsesBody(raw: unknown, options: ChatRespon
switch (role) {
case "system":
case "developer": {
+ const preserveMedia = options.preserveInstructionMedia === true && Array.isArray(msg.content)
+ && msg.content.some(part => isRec(part) && (imageUrlFromPart(part) || videoFromPart(part) || fileFromPart(part)));
// A leading block is this request's instructions and keeps that treatment: it is the
// prompt head, and hoisting it is what the upstream prefix cache wants.
if (input.length === 0) {
- pushSystemText(systemParts, msg.content);
+ if (preserveMedia) {
+ const blocks = userContentToBlocks(msg.content);
+ if (blocks.length > 0) input.push({ type: "message", role: "developer", content: blocks });
+ } else pushSystemText(systemParts, msg.content);
break;
}
// Past the first turn the slot carries meaning. `U1 -> A1 -> D2 -> U2` says D2 applies
// to U2 and not to U1, and folding it into `instructions` moved it ahead of both while
// rewriting the prompt head on every turn that carried one. The outbound adapter has
// preserved this slot since #4161; the position was already gone by the time it ran.
+ if (preserveMedia) {
+ const blocks = userContentToBlocks(msg.content);
+ if (blocks.length === 0) break;
+ if (awaitingToolResult.size > 0) heldInstructions.push({ content: blocks });
+ else input.push({ type: "message", role: "developer", content: blocks });
+ break;
+ }
const text = contentToText(msg.content).trim();
if (!text) break;
- if (awaitingToolResult.size > 0) heldInstructions.push(text);
+ if (awaitingToolResult.size > 0) heldInstructions.push({ text });
else input.push(developerInstructionItem(text));
break;
}
diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts
index 9853e2b0d74..f3d16f15a0d 100644
--- a/src/codex/catalog-auto-refresh.ts
+++ b/src/codex/catalog-auto-refresh.ts
@@ -253,15 +253,57 @@ async function tick(): Promise {
// Codex sources read Codex credentials and probe its binary; only a managed client needs them.
if (codexManaged) await refreshCatalogAutoRefreshSources(config, () => entryGeneration === generation);
if (entryGeneration !== generation) return;
- const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
- import("./management-convergence"),
- import("./catalog-admission"),
+ const managementConvergenceModule = import("./management-convergence");
+ const catalogAdmissionModule = import("./catalog-admission");
+ const [managementConvergence, catalogAdmission] = await Promise.all([
+ managementConvergenceModule, catalogAdmissionModule,
]);
- // A stop or restart landed while the funnel was loading: the result belongs to a
- // generation that no longer owns the timer, so it must not publish.
+ const createManagementConvergeCodex = managementConvergence.createManagementConvergeCodex;
+ const createCatalogConvergeRequest = catalogAdmission.createCatalogConvergeRequest;
+ // Recheck timer ownership after both funnel modules settle. A stopped or restarted
+ // generation cannot publish the result prepared by this tick.
if (entryGeneration !== generation) return;
- const converge = createManagementConvergeCodex(config);
- const outcome = await converge(createCatalogConvergeRequest({ deadlineMs: TICK_DEADLINE_MS }));
+ let converged = false;
+ let outcome: import("./convergence-types").CatalogOnlyOutcome | undefined;
+ const convergeOnce = async () => {
+ if (converged) return;
+ converged = true;
+ const result = await createManagementConvergeCodex(config)(createCatalogConvergeRequest({ deadlineMs: TICK_DEADLINE_MS }));
+ if (result.kind === "catalog-only") outcome = result;
+ };
+ let fannedOut = false;
+ const fanoutOnce = async () => {
+ if (fannedOut || !outcome?.changed || !clientFanout) return;
+ fannedOut = true;
+ try {
+ const failed = (await clientFanout(() => entryGeneration === generation)).filter(result => !result.ok).length;
+ if (failed > 0 && entryGeneration === generation) {
+ console.warn(`[catalog-auto-refresh] ${failed} client integration(s) were not refreshed; ocx sync retries them`);
+ }
+ } catch {
+ if (entryGeneration === generation) {
+ console.warn("[catalog-auto-refresh] client integrations were not refreshed; ocx sync retries them");
+ }
+ }
+ };
+ // CLI discovery belongs to the background source phase, before any catalog lock. Its
+ // successful roster remains pinned through convergence and connected-client refresh.
+ // Management callers meanwhile converge immediately from existing cached evidence.
+ if (Object.values(config.providers).some(provider =>
+ provider.adapter === "trae-cn" && !provider.disabled && provider.liveModels !== false)) {
+ const { refreshTraeCnCatalogSources } = await import("./catalog/trae-cn-models");
+ try {
+ await refreshTraeCnCatalogSources(config, () => entryGeneration === generation, async () => {
+ await convergeOnce();
+ await fanoutOnce();
+ });
+ } catch {
+ // Source failure still permits publication from the last confirmed roster.
+ }
+ }
+ if (entryGeneration !== generation) return;
+ await convergeOnce();
+ if (!outcome) return;
if (entryGeneration !== generation) return;
// createManagementConvergeCodex always projects catalog-only. Any other kind is a
// funnel contract break, not something this scheduler should re-classify.
@@ -278,17 +320,7 @@ async function tick(): Promise {
? "[catalog-auto-refresh] served model set changed; running Codex sessions keep the old list until restarted (ocx sync --restart-codex)"
: "[catalog-auto-refresh] served model set changed");
}
- const fanout = clientFanout;
- if (outcome.changed && fanout) {
- const current = () => entryGeneration === generation;
- try {
- const failed = (await fanout(current)).filter(result => !result.ok).length;
- // Outcomes can contain private paths and reasons; only the failure count leaves this hook.
- if (current() && failed > 0) console.warn(`[catalog-auto-refresh] ${failed} client integration(s) were not refreshed; ocx sync retries them`);
- } catch {
- if (current()) console.warn("[catalog-auto-refresh] client integrations were not refreshed; ocx sync retries them");
- }
- }
+ await fanoutOnce();
} catch {
// A failed refresh is not an error worth surfacing: the next tick tries again.
} finally {
diff --git a/src/codex/catalog/combo-member.ts b/src/codex/catalog/combo-member.ts
index 75adbb54b2d..dd046b680c3 100644
--- a/src/codex/catalog/combo-member.ts
+++ b/src/codex/catalog/combo-member.ts
@@ -233,6 +233,9 @@ export function resolveComboCatalogMember(
const hinted = prov
? applyProviderConfigHints(target.provider, prov, model, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, visionSidecarEnabled)
: model;
+ if (prov?.adapter === "trae-cn") {
+ return withVisionSidecarInput({ ...hinted, inputModalities: ["text"], supportsVerbosity: false }, prov, visionSidecarEnabled);
+ }
return model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND && model.inputModalities !== undefined
? withVisionSidecarInput({ ...hinted, inputModalities: [...model.inputModalities] }, prov, visionSidecarEnabled)
: hinted;
diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts
index 07583da7f64..f242dcb94c3 100644
--- a/src/codex/catalog/provider-models.ts
+++ b/src/codex/catalog/provider-models.ts
@@ -123,6 +123,7 @@ import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogG
import { workBuddyCatalogIdentity } from "../../providers/workbuddy";
import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig, withVisionSidecarInput } from "./model-hints";
import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility";
+import { fetchTraeCnProviderModels } from "./trae-cn-models";
import { captureModelsRequest, captureProviderGather, materializeCapturedHeaders } from "./gather-capture";
@@ -216,7 +217,7 @@ export async function fetchProviderModelsWithAuth(
const configured: CatalogModel[] = configuredIds.map(id => applyProviderConfigHints(
name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold,
captured.effectiveAlias, metadataConfigDir, false,
- ));
+ )).map(model => prov.adapter === "trae-cn" ? { ...model, inputModalities: ["text"], supportsVerbosity: false } : model);
const withConfiguredRetention = (
models: CatalogModel[],
options?: { retainComboTargets?: boolean; warnDrops?: boolean },
@@ -244,6 +245,9 @@ export async function fetchProviderModelsWithAuth(
}
return merged;
};
+ if (prov.adapter === "trae-cn" && prov.liveModels !== false) {
+ return fetchTraeCnProviderModels(captured, ttlMs, contextCap, configured, withConfiguredRetention, resolveAuth.kind === "refreshing");
+ }
// Static catalogs never need an OAuth refresh or an upstream model request. Clear any
// discovery failure left by an older live configuration even when the account is logged out.
if (prov.liveModels === false) {
diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts
index 9db8ddd8fe9..7b761db6f82 100644
--- a/src/codex/catalog/routed-gather.ts
+++ b/src/codex/catalog/routed-gather.ts
@@ -765,8 +765,10 @@ async function gatherRoutedModelsUncached(
}
: mergedWithHardBounds;
const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
+ const wireBounded = enrichedProvider?.adapter === "trae-cn"
+ ? { ...mergedWithAutoCompact, inputModalities: ["text"], supportsVerbosity: false } : mergedWithAutoCompact;
// Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints.
- return withVisionSidecarInput(mergedWithAutoCompact, enrichedProvider, visionSidecarEnabled);
+ return withVisionSidecarInput(wireBounded, enrichedProvider, visionSidecarEnabled);
});
// Custom rows override discovered rows that encode to the same Codex-facing slug. A provider whose
// captured discovery declares an order (preferFirst) keeps each replacement in the discovered row's
diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts
new file mode 100644
index 00000000000..d5a127fa197
--- /dev/null
+++ b/src/codex/catalog/trae-cn-models.ts
@@ -0,0 +1,131 @@
+import { openTraeSession } from "../../adapters/trae-cn/client";
+import { traeCnDestinationMatches } from "../../adapters/trae-cn/profile";
+import {
+ captureModelCacheGeneration,
+ DEFAULT_MODEL_CACHE_TTL_MS,
+ getFreshCached,
+ getCachedFetchedAt,
+ getPinnedCached,
+ getStaleCached,
+ isModelCacheGenerationCurrent,
+ isModelsFetchCoolingDown,
+ markModelsFetchFailure,
+ markProviderDiscoveryFailed,
+ markProviderDiscoveryOk,
+ observeModelCacheRevision,
+ pinCachedModels,
+ setCached,
+} from "../model-cache";
+import { applyProviderConfigHints } from "./model-hints";
+import type { CapturedProviderGather } from "./gather-capture";
+import type { ProviderModelsResult } from "./provider-models";
+import type { CatalogModel } from "./parsing";
+import type { OcxConfig } from "../../types";
+import { providerContextCap } from "../../providers/context-cap";
+
+const discoveryTails = new Map>();
+
+/** Serialize account-roster sessions per provider without retaining a rejected flight. */
+async function withDiscoveryTurn(provider: string, run: () => Promise): Promise {
+ const previous = discoveryTails.get(provider) ?? Promise.resolve();
+ let release!: () => void;
+ const turn = new Promise(resolve => { release = resolve; });
+ const tail = previous.then(() => turn);
+ discoveryTails.set(provider, tail);
+ await previous;
+ try {
+ return await run();
+ } finally {
+ release();
+ if (discoveryTails.get(provider) === tail) discoveryTails.delete(provider);
+ }
+}
+
+/** Prepare CLI-owned evidence in the background source phase, independently of management admission. */
+export async function refreshTraeCnCatalogSources(
+ config: Readonly, current: () => boolean = () => true,
+ after?: () => Promise,
+): Promise {
+ const { fetchProviderModelsWithAuth, refreshingModelsAuthResolver } = await import("./provider-models");
+ const { captureProviderGather } = await import("./gather-capture");
+ const retained: Array<{ name: string; contentRevision: string; release: () => void }> = [];
+ const releaseRetained = (): void => {
+ for (const roster of retained.reverse()) roster.release();
+ retained.length = 0;
+ };
+ try {
+ for (const [name, provider] of Object.entries(config.providers)) {
+ if (!current()) return;
+ if (provider.adapter !== "trae-cn" || provider.disabled || provider.liveModels === false) continue;
+ const gathered = await fetchProviderModelsWithAuth(captureProviderGather(name, provider, refreshingModelsAuthResolver),
+ config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS, providerContextCap(config, name), refreshingModelsAuthResolver);
+ if (!current()) return;
+ if (after && gathered.outcome.state === "authoritative") {
+ if (observeModelCacheRevision(name) !== gathered.contentRevision) return;
+ const release = pinCachedModels(name, gathered.models, getCachedFetchedAt(name) ?? Date.now(),
+ captureModelCacheGeneration(name));
+ if (!release) return;
+ retained.push({ name, release, contentRevision: observeModelCacheRevision(name) });
+ }
+ }
+ if (!after || retained.length === 0) return;
+ if (!current() || retained.some(roster => observeModelCacheRevision(roster.name) !== roster.contentRevision)) return;
+ await after();
+ } finally {
+ releaseRetained();
+ }
+}
+
+/** Discover an account roster with bounded failure retries and current hints on every cached return. */
+export async function fetchTraeCnProviderModels(
+ captured: CapturedProviderGather,
+ ttlMs: number,
+ contextCap: number | undefined,
+ configured: CatalogModel[],
+ withConfiguredRetention: (models: CatalogModel[]) => CatalogModel[],
+ allowDiscovery: boolean,
+): Promise {
+ const { name, provider, metadataModelIdCaseFold, metadataConfigDir } = captured;
+ const observed = (models: CatalogModel[], state: ProviderModelsResult["outcome"]["state"]): ProviderModelsResult => ({
+ models: withConfiguredRetention(models.map(model => applyProviderConfigHints(name, provider, model,
+ contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)))
+ .map(model => ({ ...model, inputModalities: ["text"], supportsVerbosity: false })),
+ outcome: { provider: name, state }, contentRevision: observeModelCacheRevision(name),
+ });
+ if (!traeCnDestinationMatches(provider.baseUrl) || provider.proxy !== undefined || provider.noProxy !== undefined) {
+ markProviderDiscoveryFailed(name, { reason: "blocked" });
+ return observed(configured, "degraded");
+ }
+ const fresh = getPinnedCached(name) ?? getFreshCached(name, ttlMs, Date.now());
+ if (fresh) return observed(fresh, "authoritative");
+ const stale = getStaleCached(name);
+ // Observed gathers cannot bind the CLI's live sign-in to their captured auth snapshot.
+ if (!allowDiscovery) return observed(stale ?? configured, "degraded");
+ if (isModelsFetchCoolingDown(name)) return observed(stale ?? configured, "degraded");
+ const generation = captureModelCacheGeneration(name);
+ return withDiscoveryTurn(name, async () => {
+ if (!isModelCacheGenerationCurrent(name, generation)) return observed(configured, "degraded");
+ const lockedFresh = getPinnedCached(name) ?? getFreshCached(name, ttlMs, Date.now());
+ if (lockedFresh) return observed(lockedFresh, "authoritative");
+ const lockedStale = getStaleCached(name);
+ if (isModelsFetchCoolingDown(name)) return observed(lockedStale ?? configured, "degraded");
+ const currentFallback = (): ProviderModelsResult => observed(getStaleCached(name) ?? configured, "degraded");
+ try {
+ const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000 }, undefined, provider);
+ await session.close();
+ if (!isModelCacheGenerationCurrent(name, generation)) return currentFallback();
+ const models = session.models.map(id => applyProviderConfigHints(name, provider,
+ { id, provider: name, inputModalities: ["text"] }, contextCap, metadataModelIdCaseFold,
+ captured.effectiveAlias, metadataConfigDir, false));
+ if (!setCached(name, models, Date.now(), generation)) return currentFallback();
+ markProviderDiscoveryOk(name, models.length);
+ return observed(models, "authoritative");
+ } catch {
+ if (isModelCacheGenerationCurrent(name, generation)) {
+ markModelsFetchFailure(name);
+ markProviderDiscoveryFailed(name, { reason: "provider" });
+ }
+ return currentFallback();
+ }
+ });
+}
diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts
index abd9ab2f315..5019feaea24 100644
--- a/src/codex/model-cache.ts
+++ b/src/codex/model-cache.ts
@@ -9,13 +9,14 @@
*/
import type { CatalogModel } from "./catalog";
import type { GenerationContext } from "../lib/state-store-sweeper";
-import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
+import { admitAppOwnedPinnedBytes, enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
import { clearLiveCursorRosterState } from "../adapters/cursor/catalog";
/** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */
export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
interface CacheEntry {
+ provider: string;
models: CatalogModel[];
fetchedAt: number;
sizeBytes: number;
@@ -55,11 +56,17 @@ let cacheBytes = 0;
let oldestCachedProvider: string | undefined;
let oldestCachedAt: number | null = null;
const modelCacheEncoder = new TextEncoder();
+export const MODEL_CACHE_MAX_PINNED_BYTES = 8 * 1024 * 1024;
+const MODEL_CACHE_MAX_PINNED_ENTRIES = 16;
+const pinnedCacheEntries = new Set();
+let pinnedCacheBytes = 0;
+/** Select the oldest resident roster eligible for eviction, excluding active publication leases. */
function recomputeOldestCachedProvider(): void {
oldestCachedProvider = undefined;
oldestCachedAt = null;
for (const [provider, entry] of cache) {
+ if (pinnedCacheEntries.has(entry)) continue;
if (oldestCachedAt !== null && entry.fetchedAt >= oldestCachedAt) continue;
oldestCachedProvider = provider;
oldestCachedAt = entry.fetchedAt;
@@ -199,6 +206,17 @@ export function getStaleCached(provider: string, authorityIdentity?: string): Ca
return entry.models;
}
+/** Original fetch time for publication pins; retaining evidence must not renew its TTL. */
+export function getCachedFetchedAt(provider: string): number | null {
+ return cache.get(provider)?.fetchedAt ?? null;
+}
+
+/** A publication lease is current evidence until release, independently of the ordinary TTL. */
+export function getPinnedCached(provider: string): CatalogModel[] | null {
+ const entry = cache.get(provider);
+ return entry && pinnedCacheEntries.has(entry) ? entry.models : null;
+}
+
/** Selector decoding may use unscoped rows, but scoped rows need current authority. */
export function getRoutingCached(
provider: string, resolveAuthority: () => string | undefined,
@@ -265,19 +283,55 @@ export function setCached(
authorityIdentity?: string,
): boolean {
if (generation !== undefined && !isModelCacheGenerationCurrent(provider, generation)) return false;
- deleteCachedProvider(provider);
- const sizeBytes = modelCacheEncoder.encode(provider).byteLength
+ storeCached(provider, models, now, authorityIdentity);
+ return true;
+}
+
+function modelCacheSizeBytes(provider: string, models: CatalogModel[]): number {
+ return modelCacheEncoder.encode(provider).byteLength
+ modelCacheEncoder.encode(JSON.stringify(models)).byteLength;
- cache.set(provider, { models, fetchedAt: now, sizeBytes, ...(authorityIdentity ? { authorityIdentity } : {}) });
+}
+
+/** Replace resident rows, register an optional publication pin, and charge the shared memory budget. */
+function storeCached(
+ provider: string, models: CatalogModel[], now: number, authorityIdentity?: string, pinned = false,
+ sizeBytes = modelCacheSizeBytes(provider, models),
+): CacheEntry {
+ deleteCachedProvider(provider);
+ const entry: CacheEntry = { provider, models, fetchedAt: now, sizeBytes, ...(authorityIdentity ? { authorityIdentity } : {}) };
+ cache.set(provider, entry);
+ if (pinned) {
+ pinnedCacheEntries.add(entry);
+ pinnedCacheBytes += sizeBytes;
+ }
cacheBytes += sizeBytes;
- if (oldestCachedAt === null || now < oldestCachedAt) {
+ if (!pinned && (oldestCachedAt === null || now < oldestCachedAt)) {
oldestCachedProvider = provider;
oldestCachedAt = now;
}
enforceAppOwnedMemoryBudget();
// Published and accepted, so anything derived from this provider's rows is now out of date.
bumpProviderCacheRevision(provider);
- return true;
+ return entry;
+}
+
+/** Keep a probed roster through publication, with bounded shared-budget admission and release. */
+export function pinCachedModels(
+ provider: string, models: CatalogModel[], now: number, generation: string,
+): (() => void) | null {
+ if (!isModelCacheGenerationCurrent(provider, generation)) return null;
+ const sizeBytes = modelCacheSizeBytes(provider, models);
+ if (pinnedCacheEntries.size >= MODEL_CACHE_MAX_PINNED_ENTRIES
+ || pinnedCacheBytes + sizeBytes > MODEL_CACHE_MAX_PINNED_BYTES
+ || !admitAppOwnedPinnedBytes(sizeBytes)
+ || !isModelCacheGenerationCurrent(provider, generation)) return null;
+ const entry = storeCached(provider, models, now, undefined, true, sizeBytes);
+ return () => {
+ if (!pinnedCacheEntries.delete(entry)) return;
+ pinnedCacheBytes -= entry.sizeBytes;
+ recomputeOldestCachedProvider();
+ enforceAppOwnedMemoryBudget();
+ };
}
/** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */
@@ -358,12 +412,19 @@ export function reconcileModelCacheGeneration(context: GenerationContext): numbe
return reconcileModelCacheProviders(context.providerNames, context.generation);
}
+/** Charge resident and detached pinned rosters exactly once until their leases are released. */
export function modelCacheRetainedStoreSnapshot(): RetainedStoreSnapshot {
+ let cachedPinnedBytes = 0;
+ let detachedPinnedCount = 0;
+ for (const entry of pinnedCacheEntries) {
+ if (cache.get(entry.provider) === entry) cachedPinnedBytes += entry.sizeBytes;
+ else detachedPinnedCount += 1;
+ }
return {
- count: cache.size,
- bytes: cacheBytes,
- evictableBytes: cacheBytes,
- pinnedBytes: 0,
+ count: cache.size + detachedPinnedCount,
+ bytes: cacheBytes + pinnedCacheBytes - cachedPinnedBytes,
+ evictableBytes: cacheBytes - cachedPinnedBytes,
+ pinnedBytes: pinnedCacheBytes,
oldestAt: oldestCachedAt,
};
}
diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts
index 29e88303db0..00284f8675b 100644
--- a/src/providers/free-directory.ts
+++ b/src/providers/free-directory.ts
@@ -12,7 +12,7 @@ export const FREE_PROVIDER_ACCESS_GROUPS = {
"cloudflare-ai", "cohere", "coze", "duckduckgo-web", "felo-web", "friendliai", "gemini", "github-models",
"groq", "hackclub", "huggingchat", "huggingface", "iflytek", "inference-net", "kiro", "liquid", "llm7",
"mistral", "morph", "muse-spark-web", "nara", "navy", "nlpcloud", "ollama-cloud", "opencode", "openrouter",
- "ovhcloud", "perch", "pollinations", "puter", "qwen-web", "reka", "runcrate", "sambanova", "sparkdesk", "t3-web", "uncloseai",
+ "ovhcloud", "perch", "pollinations", "puter", "qwen-web", "reka", "runcrate", "sambanova", "sparkdesk", "t3-web", "trae-cn", "uncloseai",
],
"recurring-uncapped": [
"agnes", "ainative", "aion", "baidu", "glm", "glm-cn", "kilo-gateway", "opencode-zen", "requesty",
@@ -235,6 +235,12 @@ export const FREE_PROVIDER_DIRECTORY: readonly FreeDirectoryProvider[] = Object.
discovery: "unsupported",
liveModels: false,
...(!override ? { note: referenceNote } : {}),
+ ...(id === "trae-cn" ? {
+ label: "TRAE CN CLI (ACP reference)",
+ documentationUrl: "https://docs.trae.cn/cli_agent-client-protocol",
+ dashboardUrl: "https://www.trae.cn",
+ note: "Official CLI ACP startup documentation is available. No canonical proxy preset is enabled: the discovery contract, forwarding authorization, and maintenance-owner commitment remain unverified.",
+ } : {}),
...override,
} satisfies FreeDirectoryProvider;
});
diff --git a/src/providers/trae-cn-models.ts b/src/providers/trae-cn-models.ts
new file mode 100644
index 00000000000..3c307216cb1
--- /dev/null
+++ b/src/providers/trae-cn-models.ts
@@ -0,0 +1,8 @@
+/** Cold-start fallback; ACP's account-scoped session catalog replaces this list after discovery. */
+export const TRAE_CN_MODELS = [
+ "Doubao-Seed-1.8",
+ "GPT-5.4",
+ "Gemini-3.1-Pro-Preview",
+ "Kimi-K2.6",
+ "Qwen3.6-Plus",
+];
diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts
index 44691c9e57d..872d114914b 100644
--- a/src/server/chat-completions.ts
+++ b/src/server/chat-completions.ts
@@ -23,6 +23,7 @@ import { chatConversationToIr, chatIrRouteEvidence, withDirectChatConversation,
import { antigravityDirectDeclineReason } from "./antigravity-direct-eligibility";
import { normalizeChatImageParts } from "../chat/image-parts";
import { chatHasInstructionImage } from "../adapters/claude-cli/instruction-images";
+import { chatHasUnsupportedInstructionMedia } from "../adapters/trae-cn/input-media";
import {
chatCompletionsErrorResponse,
collectChatCompletion,
@@ -111,6 +112,8 @@ import { parseSyntheticRowId } from "./fast-row";
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../codex/loopback-target";
import { applyDroidReasoningDefault, droidReasoningDefault, DROID_DEFAULT_EFFORT_HEADER } from "./droid-reasoning-default";
+import { isShadowSourceModel } from "../lib/shadow-call";
+import { jsonUtf8Bytes } from "../lib/json-byte-size";
type Rec = Record;
@@ -401,6 +404,7 @@ async function handleChatCompletionsWithBudget(
let internalBody: Rec;
let directConversation: ChatIrConversation | undefined;
+ let chatReplayOriginalInboundBody: Rec | undefined;
try {
// Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as
// its wire source; this Responses projection is used only by the fallback path.
@@ -431,6 +435,17 @@ async function handleChatCompletionsWithBudget(
effort: effortRow.effort,
};
}
+ const intercept = config.shadowCallIntercept;
+ const mayRetryPolicy = settledRoute?.routeKind === "policy"
+ || Boolean(intercept?.enabled && intercept.model
+ && isShadowSourceModel(requestedModel, intercept.sourceModels));
+ if (mayRetryPolicy || Boolean(settledRoute?.combo)) {
+ // Policy retries and combo bridge children need the pre-projection conversation. Native
+ // combo targets keep the protocol envelope; ordinary non-replay Chat requests keep neither.
+ // Admit the serialized clone before allocating it and retain its charge with this request.
+ translatorBudget.chargeRetained(jsonUtf8Bytes(chatBody), { kind: "request_copies" });
+ chatReplayOriginalInboundBody = structuredClone(chatBody);
+ }
} catch (err) {
const overflow = isTranslatorBudgetExceededError(err);
const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500;
@@ -577,7 +592,9 @@ async function handleChatCompletionsWithBudget(
abortSignal: req.signal,
// Body is Responses-shaped by now, but the client spoke Chat Completions.
inboundWire: "chat",
+ chatReplayOriginalInboundBody,
chatInstructionImage: chatHasInstructionImage(chatBody),
+ chatUnsupportedInstructionMedia: chatHasUnsupportedInstructionMedia(chatBody),
chatRawReasoningVisible: rawReasoningVisible,
// A Chat caller may send an explicit empty `tools` array — a deny-all declaration the
// translation drops along with the whole catalog. Carried separately so the
diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts
index 6df092de541..cda915bb0cd 100644
--- a/src/server/management/provider-routes.ts
+++ b/src/server/management/provider-routes.ts
@@ -1665,6 +1665,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise boolean) => Promise;
+}
+
+export async function probeTraeCnProviderFromContext(
+ ctx: ManagementContext, name: string, provider: OcxProviderConfig,
+): Promise {
+ const { req, config, deps, convergeCodexCatalog } = ctx;
+ return probeTraeCnProvider(name, provider, convergeCodexCatalog, req.signal, {
+ refreshConnectedClients: async admit => {
+ const refresh = deps.refreshOwnedCatalogIntegrations
+ ?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations;
+ const { loadExportModels } = await import("./model-rows");
+ const { managementInferencePort } = await import("./context");
+ return refresh({ config, port: managementInferencePort(ctx), models: () => loadExportModels(config) },
+ undefined, { refreshOnly: true, admit });
+ },
+ });
+}
+
+/** Probe the signed-in CLI directly; cached or seeded model rows are not connection evidence. */
+export async function probeTraeCnProvider(
+ name: string, provider: OcxProviderConfig, converge: () => Promise,
+ signal?: AbortSignal, deps: TraeCnProbeDeps = {},
+): Promise {
+ const started = Date.now();
+ const generation = captureModelCacheGeneration(name);
+ try {
+ const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000, ...deps }, signal, provider);
+ await session.close();
+ let catalogRefresh: CatalogDisposition = { status: "skipped", reason: "stale", retryable: true };
+ let rosterReplaced = false;
+ let rosterRetentionPending = false;
+ let clientIntegrations: OwnedIntegrationRefreshOutcome[] = [];
+ let clientRefreshPending = false;
+ if (isModelCacheGenerationCurrent(name, generation)) {
+ clearModelCache(name);
+ const publishedGeneration = captureModelCacheGeneration(name);
+ const releaseRoster = pinCachedModels(name,
+ session.models.map(id => ({ id, provider: name, inputModalities: ["text"], supportsVerbosity: false })),
+ Date.now(), publishedGeneration);
+ if (releaseRoster) {
+ try {
+ rosterReplaced = true;
+ markProviderDiscoveryOk(name, session.models.length);
+ clearGatherRoutedModelsInflight();
+ try { catalogRefresh = await converge(); } catch {
+ catalogRefresh = { status: "failed", reason: "internal", phase: "commit", retryable: true, partialWrite: false };
+ }
+ const admit = () => isModelCacheGenerationCurrent(name, publishedGeneration);
+ if (deps.refreshConnectedClients) {
+ if (!admit()) clientRefreshPending = true;
+ else try {
+ clientIntegrations = await deps.refreshConnectedClients(admit);
+ clientRefreshPending = !admit() || clientIntegrations.some(result => !result.ok);
+ } catch { clientRefreshPending = true; }
+ }
+ } finally { releaseRoster(); }
+ } else if (isModelCacheGenerationCurrent(name, publishedGeneration)) {
+ rosterRetentionPending = true;
+ clientRefreshPending = true;
+ catalogRefresh = { status: "failed", reason: "admission", phase: "gather", retryable: true, partialWrite: false };
+ }
+ }
+ const published = catalogRefresh.status === "committed" && !catalogRefresh.degraded;
+ const staleProbe = !rosterReplaced;
+ return jsonResponse({ ok: true, models: session.models.length, latencyMs: Date.now() - started, rosterReplaced, rosterRetentionPending, catalogRefresh, clientIntegrations, clientRefreshPending,
+ message: `Connected. ${session.models.length} models. ${rosterRetentionPending
+ ? "The model roster could not fit the memory budget; retry Test connection after active requests finish."
+ : staleProbe
+ ? "The provider changed during the probe; this roster was not published. Run Test connection again."
+ : published && !clientRefreshPending
+ ? "Catalog refreshed; restart running Codex sessions to load it."
+ : "Catalog refresh is pending; sync the catalog before restarting running Codex sessions."}` });
+ } catch (error) {
+ return jsonResponse({ ok: false, latencyMs: Date.now() - started,
+ error: redactSecretString(error instanceof Error ? error.message : "TRAE CN ACP connection failed").slice(0, 512) });
+ }
+}
diff --git a/src/server/responses/chat-policy-media.ts b/src/server/responses/chat-policy-media.ts
new file mode 100644
index 00000000000..1d0dbe33ac2
--- /dev/null
+++ b/src/server/responses/chat-policy-media.ts
@@ -0,0 +1,9 @@
+import { chatToResponsesBody } from "../../protocols/codecs/chat";
+
+/** Reuse the chronological Chat projection without dropping instruction attachments. */
+export function restoreChatInstructionMedia(
+ projected: Record, original: unknown,
+): Record {
+ const restored = chatToResponsesBody(original, { preserveInstructionMedia: true });
+ return { ...projected, input: restored.input, instructions: restored.instructions };
+}
diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts
index e872ff521bf..2b4a7708a2e 100644
--- a/src/server/responses/core-combo.ts
+++ b/src/server/responses/core-combo.ts
@@ -67,6 +67,7 @@ import {
previousResponseProviderState,
} from "../../responses/state";
import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload";
+import { restoreChatInstructionMedia } from "./chat-policy-media";
import { eagerCodexToolCatalogSurface, warnEagerCodexToolCatalog } from "./codex-tool-catalog";
import { captureRouteStaticPolicy, routeConcreteModel, comboRouteDecisionTrace } from "../../router";
import { memoryModelRouteReason } from "./memory-models";
@@ -443,7 +444,8 @@ export async function executeComboResponses(
: undefined;
// Expand previous_response_id before image policy and child dispatch so a
// continuation that only references prior images still fails closed when
- // imageInput is disabled (and so targets see the full replayed input).
+ // imageInput is disabled (and so targets see the full replayed input). Chat projection can
+ // flatten instruction images, so its retained value-free fact participates in the same guard.
const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
const body = expandPreviousResponseInput(rawBody, inboundClientThreadId);
const replayFailure = previousResponseReplayFailure(body);
@@ -474,7 +476,7 @@ export async function executeComboResponses(
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
);
}
- if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
+ if (combo.imageInput === "disabled" && (options.chatInstructionImage || comboRequestHasImageInput(body))) {
return formatErrorResponse(400, "invalid_request_error", "This combo does not accept image input");
}
// The combo ladder owns the whole logical request, so the catalog diagnostic is counted here for
@@ -1094,7 +1096,7 @@ export async function executeComboResponses(
provider: targetRoute.provider,
modelId: targetRoute.modelId,
});
- const childBody = concreteComboRequestBody(
+ const projectedChildBody = concreteComboRequestBody(
childInput,
pick.target,
jevEffortApplies ? initialJevDecision.effort : comboDefaultEffort(config, comboId),
@@ -1104,6 +1106,9 @@ export async function executeComboResponses(
? (initialJevDecision.effort === null ? "fallback" : "force")
: combo.defaultEffortMode,
);
+ const childBody = options.inboundWire === "chat" && options.chatReplayOriginalInboundBody !== undefined
+ ? restoreChatInstructionMedia(projectedChildBody, options.chatReplayOriginalInboundBody)
+ : projectedChildBody;
if (initialJevDecision) {
if (jevEffortApplies) delete childBody.service_tier;
if (jevEffortApplies && initialJevDecision.effort !== null) {
diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts
index 976139eb7f8..34719e8ca68 100644
--- a/src/server/responses/core-options.ts
+++ b/src/server/responses/core-options.ts
@@ -163,6 +163,8 @@ export interface HandleResponsesOptions {
onRequestBodyRead?: () => void;
/** Internal handoff for retry wrappers that must reuse the already-accounted request body. */
onRequestBodyParsed?: (body: unknown) => void;
+ /** Original Chat body retained only for eligible policy retries and translated combo children. */
+ chatReplayOriginalInboundBody?: unknown;
forceEmptyResponseId?: boolean;
/** Internal, connection-owned control channel; never reconstructed from headers. */
nativeControl?: NativeResponseControl;
@@ -307,8 +309,12 @@ export interface HandleResponsesOptions {
chatRawReasoningVisible?: boolean;
/** Value-free fact retained before Chat instruction content is projected to text. */
chatInstructionImage?: boolean;
+ /** Original Chat instruction content unsupported by the text-only TRAE adapter. */
+ chatUnsupportedInstructionMedia?: boolean;
/** Original Responses instruction image, retained across preprocessing and combo replay. */
responsesInstructionImage?: boolean;
+ /** Unsupported instruction/agent media retained before Responses parsing and combo replay. */
+ responsesUnsupportedInputMedia?: boolean;
/** File-backed Responses image retained before parsing and replay. */
responsesFileImage?: boolean;
/** Set only by the Chat or Messages ingress on an eligible Antigravity route; see {@link DirectRequestOption}. */
diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts
index b573a0ffe11..b05c481830c 100644
--- a/src/server/responses/core-pipeline.ts
+++ b/src/server/responses/core-pipeline.ts
@@ -11,7 +11,8 @@ import type {
import type { TranslatorBudget } from "../../lib/translator-budget";
import { prepareResponsesRequest } from "./request-prepare";
import { prepareResponsesTransport } from "./request-transport";
-import { prepareResponsesSidecarAuth } from "./request-sidecar-auth";
+import { prepareResponsesSidecarAuth, resolveResponsesOpenAiSidecar } from "./request-sidecar-auth";
+import { shouldResolveOpenAiVisionSidecar } from "../../vision";
import { createResponsesEffects } from "./response-effects";
import { createResponsesSendBudget } from "./request-send-budget";
import { executePassthroughResponse } from "./passthrough-execution";
@@ -26,8 +27,20 @@ import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
import { formatErrorResponse } from "../../bridge";
import { InvalidRetainedCompactionSummary, prepareCompactionRetention } from "./compaction-retention";
import { responsesHasInstructionImage } from "../../adapters/claude-cli/instruction-images";
+import { responsesHasUnsupportedInputMedia, responsesHasUnsupportedToolMedia } from "../../adapters/trae-cn/input-media";
+import { planVisionSidecar } from "../../vision";
import { markResponseNonReplayable } from "../../lib/upstream-retry";
+function traeMediaRefusal(logCtx: RequestLogContext): Response {
+ const message = "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported.";
+ const response = new Response(JSON.stringify({ error: {
+ type: "invalid_request_error", code: "unsupported_input_modality", param: null, message,
+ } }), { status: 400, headers: { "Content-Type": "application/json" } });
+ rememberProxyErrorMessage(logCtx, message, { status: 400, type: "invalid_request_error", code: "unsupported_input_modality" });
+ markProxyError(response);
+ return response;
+}
+
/** Compose request phases while retaining the original admission-finally ownership. */
export async function handleResponsesInner(
req: Request,
@@ -48,21 +61,51 @@ export async function handleResponsesInner(
if (requestState instanceof Response) return requestState;
const transportState = await prepareResponsesTransport(requestContext, admissionState, requestState);
if (transportState instanceof Response) return markProxyError(transportState);
- if (transportState.runTurnAdapter.name === "claude-cli") {
+ const unsupportedTraeReplayMedia = transportState.runTurnAdapter.name === "trae-cn"
+ && (options.chatUnsupportedInstructionMedia || options.responsesInstructionImage || options.responsesFileImage
+ || responsesHasInstructionImage(requestState.parsed._rawBody)
+ || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody, { captionUserImages: true })
+ || responsesHasUnsupportedToolMedia(requestState.parsed._rawBody));
+ let sidecarState: Awaited> | undefined;
+ if (unsupportedTraeReplayMedia) return traeMediaRefusal(logCtx);
+ if (transportState.runTurnAdapter.name === "trae-cn") {
+ const needsVision = shouldResolveOpenAiVisionSidecar(config, requestState.route.provider,
+ requestState.route.modelId, requestState.parsed, requestState.route.providerName);
+ const openAiSidecar = await resolveResponsesOpenAiSidecar(requestContext, requestState, transportState, needsVision);
+ const needsCaption = options.responsesUnsupportedInputMedia
+ || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody);
+ if (needsCaption && planVisionSidecar(config, requestState.route.provider, requestState.route.modelId,
+ requestState.parsed, openAiSidecar, { providerName: requestState.route.providerName }) === undefined) {
+ openAiSidecar?.releaseProbeLease?.();
+ return traeMediaRefusal(logCtx);
+ }
+ sidecarState = await prepareResponsesSidecarAuth(requestContext, requestState, transportState, openAiSidecar);
+ }
+ if (transportState.runTurnAdapter.name === "claude-cli" || transportState.runTurnAdapter.name === "trae-cn") {
const instructionImage = options.chatInstructionImage || options.responsesInstructionImage
|| responsesHasInstructionImage(requestState.parsed._rawBody)
|| requestState.parsed.context.messages.some(message => message.role === "developer"
&& Array.isArray(message.content) && message.content.some(part => part.type === "image"));
- if (instructionImage || options.responsesFileImage) {
- const message = instructionImage ? "Claude Code CLI does not support images in developer instructions."
- : "Claude Code CLI does not support file-backed image references.";
+ const unsupportedTraeMedia = transportState.runTurnAdapter.name === "trae-cn"
+ && (() => {
+ const captionUserImages = planVisionSidecar(config, requestState.route.provider, requestState.route.modelId,
+ requestState.parsed, sidecarState?.openAiSidecar, { providerName: requestState.route.providerName }) !== undefined;
+ return options.chatUnsupportedInstructionMedia
+ || options.responsesUnsupportedInputMedia && !captionUserImages
+ || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody, { captionUserImages });
+ })();
+ if (instructionImage || options.responsesFileImage || unsupportedTraeMedia) {
+ if (transportState.runTurnAdapter.name === "trae-cn") return traeMediaRefusal(logCtx);
+ const message = transportState.runTurnAdapter.name === "claude-cli" && instructionImage
+ ? "Claude Code CLI does not support images in developer instructions."
+ : "Claude Code CLI does not support file-backed image references.";
const response = new Response(JSON.stringify({ error: {
type: "invalid_request_error", code: "unsupported_input_modality", param: null,
message,
} }), { status: 400, headers: { "Content-Type": "application/json" } });
rememberProxyErrorMessage(logCtx, message, { status: 400, type: "invalid_request_error", code: "unsupported_input_modality" });
markProxyError(response);
- markResponseNonReplayable(response);
+ if (transportState.runTurnAdapter.name === "claude-cli") markResponseNonReplayable(response);
return response;
}
}
@@ -74,7 +117,7 @@ export async function handleResponsesInner(
options.translatorBudget, options.abortSignal ?? req.signal);
}
const execute = async (): Promise => {
- const sidecarState = await prepareResponsesSidecarAuth(requestContext, requestState, transportState);
+ sidecarState ??= await prepareResponsesSidecarAuth(requestContext, requestState, transportState);
if (sidecarState instanceof Response) return markProxyError(sidecarState);
const responseEffects = createResponsesEffects(
requestContext,
diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts
index 7db10d29f0f..d6326639cb2 100644
--- a/src/server/responses/policy-fallback.ts
+++ b/src/server/responses/policy-fallback.ts
@@ -18,6 +18,8 @@ import { isPolicyCandidateRefusal, isMandatoryToolChoiceRefusal, mandatoryToolCh
import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
import { captureAdmissionPolicy } from "../capture-admission-policy";
import { upstreamCodeForComboFailure } from "../../bridge/errors";
+import { chatToResponsesBody } from "../../protocols/codecs/chat";
+import { restoreChatInstructionMedia } from "./chat-policy-media";
type CoreHandler = typeof handleResponsesCore;
type CoreOptions = Parameters[3];
@@ -218,7 +220,13 @@ export async function handleResponsesWithPolicyFallback(
// Recovery and other core preparation may mutate the parsed body in place. Keep an
// immutable snapshot of the original wire body so a retry cannot serialize those
// mutations. Object-identity metadata is re-established by each attempt, not serialized.
- rawBody = structuredClone(body as Record);
+ if (options.inboundWire === "chat" && options.chatReplayOriginalInboundBody !== undefined) {
+ const restored = restoreChatInstructionMedia(
+ chatToResponsesBody(options.chatReplayOriginalInboundBody), options.chatReplayOriginalInboundBody,
+ );
+ rawBody = { ...structuredClone(body as Record),
+ input: restored.input, instructions: restored.instructions };
+ } else rawBody = structuredClone(body as Record);
}
},
onStoredPool401ReplayDispatched: () => {
diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts
index 181bfa86f2c..d08835aa714 100644
--- a/src/server/responses/request-prepare.ts
+++ b/src/server/responses/request-prepare.ts
@@ -83,6 +83,7 @@ import type { OcxParsedRequest } from "../../types";
import { buildToolBridgeMaps } from "./collaboration";
import { parseRequest } from "../../responses/parser";
import { responsesHasFileImage, responsesHasInstructionImage } from "../../adapters/claude-cli/instruction-images";
+import { responsesHasUnsupportedInputMedia } from "../../adapters/trae-cn/input-media";
import { anthropicSessionKeyFromParts } from "../../oauth/anthropic-routing";
import { configuredAnthropicInstance } from "../../providers/anthropic-instance";
import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
@@ -255,6 +256,7 @@ export async function prepareResponsesRequest(
return decodeRequestErrorResponse(err, "responses");
}
options.responsesInstructionImage ||= responsesHasInstructionImage(body);
+ options.responsesUnsupportedInputMedia ||= responsesHasUnsupportedInputMedia(body, { captionUserImages: true });
options.responsesFileImage ||= responsesHasFileImage(body);
// Remove display-only artifact paths before helpers, combo dispatch and upstream replay.
const originalSelector = options.mainHardLockFallbackOverride?.sourceModel ?? options.compactionRoutingOverride?.sourceModel
@@ -459,6 +461,7 @@ export async function prepareResponsesRequest(
?? (body !== originalBody
&& typeof (body as { previous_response_id?: unknown }).previous_response_id === "string");
options.responsesInstructionImage ||= responsesHasInstructionImage(body);
+ options.responsesUnsupportedInputMedia ||= responsesHasUnsupportedInputMedia(body, { captionUserImages: true });
options.responsesFileImage ||= responsesHasFileImage(body);
// Counted after replay expansion so the catalog this request pays for is the one measured:
// input restored from stored continuation state is history, and the prefix removes it.
diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts
index 195e7768bcf..eb2aa9b6da6 100644
--- a/src/server/responses/request-sidecar-auth.ts
+++ b/src/server/responses/request-sidecar-auth.ts
@@ -32,31 +32,19 @@ import {
CodexMainProfileDrainingError,
} from "../../codex/auth-context";
-/** One responsibility of the Responses request pipeline; state owners are explicit. */
-export async function prepareResponsesSidecarAuth(
+/** Resolve the optional OpenAI helper without rewriting images or reserving a description. */
+export async function resolveResponsesOpenAiSidecar(
requestContext: Pick,
- requestState: Pick<
- PreparedResponsesRequest,
- | "parsed"
- | "route"
- | "selectedForwardHeaders"
- | "translatorBudget"
- >,
+ requestState: Pick,
transportState: Pick,
-) {
+ needsOpenAiVision: boolean,
+): Promise {
const { options, config, req } = requestContext;
- const { parsed, route, translatorBudget } = requestState;
+ const { parsed, route } = requestState;
const { isPassthrough } = transportState;
-
-
let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
- const visionDescribeTerminal = options.visionDescribeTerminal === true;
const routedCompaction = parsed._compactionRequest === true
&& (!supportsNativeResponsesCompactionTrigger(route.provider) || parsed._portableCompaction === true);
- const preserveCompactionPrefix = routedCompaction && transportState.adapter.name === "anthropic";
- if (routedCompaction) parsed.context.messages = omitEarlierCompactionImages(parsed.context.messages);
- const needsOpenAiVision = !visionDescribeTerminal
- && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName);
// LOCAL PATCH (runturn-websearch): runTurn adapters can run the web-search
// sidecar through src/web-search/run-turn-loop.ts, so resolve the helper
// credential for them too. The passthrough bridge stays fetch-only.
@@ -121,6 +109,38 @@ export async function prepareResponsesSidecarAuth(
) throw err;
}
}
+ return openAiSidecar;
+}
+
+/** One responsibility of the Responses request pipeline; state owners are explicit. */
+export async function prepareResponsesSidecarAuth(
+ requestContext: Pick,
+ requestState: Pick<
+ PreparedResponsesRequest,
+ | "parsed"
+ | "route"
+ | "selectedForwardHeaders"
+ | "translatorBudget"
+ >,
+ transportState: Pick,
+ resolvedOpenAiSidecar?: ResolvedOpenAiForwardSidecar,
+) {
+ const { options, config } = requestContext;
+ const { parsed, route, translatorBudget } = requestState;
+ const routedCompaction = parsed._compactionRequest === true
+ && (!supportsNativeResponsesCompactionTrigger(route.provider) || parsed._portableCompaction === true);
+ const preserveCompactionPrefix = routedCompaction && transportState.adapter.name === "anthropic";
+ if (routedCompaction) parsed.context.messages = omitEarlierCompactionImages(parsed.context.messages);
+ const visionDescribeTerminal = options.visionDescribeTerminal === true;
+ const needsOpenAiVision = !visionDescribeTerminal
+ && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName);
+ const needsOpenAiSearch = !routedCompaction
+ && (transportState.adapter.runTurn
+ ? shouldResolveOpenAiWebSearchSidecar(config, parsed, transportState.isPassthrough)
+ : (shouldResolveOpenAiWebSearchSidecar(config, parsed, transportState.isPassthrough)
+ || shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, transportState.isPassthrough)));
+ const openAiSidecar = resolvedOpenAiSidecar
+ ?? await resolveResponsesOpenAiSidecar(requestContext, requestState, transportState, needsOpenAiVision);
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
// attached image through the selected sidecar backend and replace it with text BEFORE the main
diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts
index ba44ef99d96..85c011bc2da 100644
--- a/src/server/responses/run-turn-execution.ts
+++ b/src/server/responses/run-turn-execution.ts
@@ -60,6 +60,8 @@ import { orderDevinMessagesOutput } from "../../claude/devin-output-order";
import { unboundPoolSpendRefusalMessage } from "../workflow-refusal";
import { claudeUsagePreflight } from "../../adapters/claude-cli/usage-admission";
import { responsesHasInstructionImage } from "../../adapters/claude-cli/instruction-images";
+import { responsesHasUnsupportedInputMedia, responsesHasUnsupportedToolMedia } from "../../adapters/trae-cn/input-media";
+import { planVisionSidecar } from "../../vision";
// LOCAL PATCH (runturn-websearch): top-level fields route binding or the
// adapter itself may write during a turn. Iteration-local `turnParsed` objects
@@ -181,19 +183,32 @@ export async function executeResponsesRunTurn(
};
// Publish the known subscription pause before headers or a CLI turn, preserving its reset
// across both Responses and translated Chat instead of presenting a retryable generic 502.
- if (transportState.runTurnAdapter.name === "claude-cli") {
+ if (transportState.runTurnAdapter.name === "claude-cli" || transportState.runTurnAdapter.name === "trae-cn") {
if (options.chatInstructionImage || options.responsesInstructionImage || responsesHasInstructionImage(parsed._rawBody)
+ || (transportState.runTurnAdapter.name === "trae-cn"
+ && (() => {
+ const captionUserImages = planVisionSidecar(config, route.provider, route.modelId, parsed, sidecarState.openAiSidecar,
+ { providerName: route.providerName }) !== undefined;
+ return options.chatUnsupportedInstructionMedia
+ || options.responsesUnsupportedInputMedia && !captionUserImages
+ || responsesHasUnsupportedToolMedia(parsed._rawBody)
+ || responsesHasUnsupportedInputMedia(parsed._rawBody, { captionUserImages });
+ })())
|| parsed.context.messages.some(message => message.role === "developer"
&& Array.isArray(message.content) && message.content.some(part => part.type === "image"))) {
cancelResponseCompletion();
releaseSearchProbeLease();
const refusal = new Response(JSON.stringify({ error: {
type: "invalid_request_error", code: "unsupported_input_modality", param: null,
- message: "Claude Code CLI does not support images in developer instructions.",
+ message: transportState.runTurnAdapter.name === "trae-cn"
+ ? "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported."
+ : "Claude Code CLI does not support images in developer instructions.",
} }), { status: 400, headers: { "Content-Type": "application/json" } });
- markResponseNonReplayable(refusal);
+ if (transportState.runTurnAdapter.name === "claude-cli") markResponseNonReplayable(refusal);
return refusal;
}
+ }
+ if (transportState.runTurnAdapter.name === "claude-cli") {
let usage: Awaited>;
try {
usage = await claudeUsagePreflight(parsed.modelId);
@@ -301,6 +316,10 @@ export async function executeResponsesRunTurn(
let sentKeyBinding: KeyBinding | undefined;
const emit = (event: AdapterEvent) => {
if (event.type === "error" && sentKeyBinding) failedKeyBindings.set(event, sentKeyBinding);
+ // A cancelled stream may have stopped draining; keep its latest attempt's reported usage.
+ if (event.type === "incomplete" && event.reason === "client_cancelled" && event.usage && attemptSeq === runTurnAttemptSeq) {
+ transportState.bindKeyUsageFromBridge(event.usage);
+ }
options.onCompactionRecoveryAdapterEvent?.(event);
targetQueue.push(event);
};
@@ -1085,10 +1104,11 @@ export async function executeResponsesRunTurn(
? (zeroOutputLimit ?? (firstMeaningful ? classifyUndeclaredFirstTool(firstMeaningful) : undefined))
: undefined;
if (!firstMeaningful || firstMeaningful.type === "error" || classifiedError) {
- if (classifiedError?.usage) transportState.bindKeyUsageFromBridge(classifiedError.usage);
- const classified = adapterFailureFromEvent(classifiedError ?? (firstMeaningful?.type === "error"
+ const failureEvent: Extract = classifiedError ?? (firstMeaningful?.type === "error"
? firstMeaningful
- : { type: "error", status: 502, message: "Adapter ended before producing a response" }));
+ : { type: "error", status: 502, message: "Adapter ended before producing a response" });
+ if (failureEvent.usage) transportState.bindKeyUsageFromBridge(failureEvent.usage);
+ const classified = adapterFailureFromEvent(failureEvent);
const storageRefusal = firstMeaningful?.type === "error" && firstMeaningful.code === SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE;
const failure = formatErrorResponse(storageRefusal ? 503 : classified.httpStatus,
storageRefusal ? SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE : classified.error.type,
diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts
index cb0cf01874b..cde932dd963 100644
--- a/src/vision/eligibility.ts
+++ b/src/vision/eligibility.ts
@@ -133,12 +133,14 @@ type EnrichedProviderCache = Map;
* path when it describes a text model that excludes image input: an audio-only declaration is
* not a text-only model and must not be widened to image through the vision sidecar. The
* provider-wide `inputModalities` covers ids the per-model map does not list, such as a model
- * that arrived through live discovery; a per-model declaration still wins.
+ * that arrived through live discovery; a per-model declaration still wins unless the adapter
+ * has a hard text-only wire contract, as TRAE CN does.
*/
export function isModelVisionSidecarConsumer(
- provider: Pick,
+ provider: Pick & Partial>,
modelId: string,
): boolean {
+ if (provider.adapter === "trae-cn") return true;
const declared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
? provider.modelCapabilities?.[modelId]?.inputModalities : undefined;
if (declared !== undefined) return declared.includes("text") && !declared.includes("image");
@@ -213,6 +215,7 @@ function isVisionSidecarConsumerWithCache(
): boolean {
const provider = enrichedProviderForVision(config, providerName, cache);
if (provider === undefined) return false;
+ if (provider.adapter === "trae-cn") return true;
// Documented precedence, highest first: the dedicated per-model capability axis, then the
// operator's own custom row, then the provider-level hints. Reading the custom row ahead of
// `modelCapabilities` would let it override the more specific axis.
diff --git a/src/vision/plan.ts b/src/vision/plan.ts
index 56afea03c8b..3456246107a 100644
--- a/src/vision/plan.ts
+++ b/src/vision/plan.ts
@@ -118,14 +118,16 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
* Any of them can require the vision preprocessor. A genuinely unknown custom model is NOT
* guessed blind: it keeps the established pass-through behaviour. The provider-only fallback
* keeps legacy unit callers stable; production dispatch always supplies providerName so the
- * complete capability chain is consulted.
+ * complete capability chain is consulted. TRAE CN's text-only wire contract precedes all
+ * configurable image declarations.
*/
export function requiresVisionPreprocessing(
config: Pick & { customModels?: OcxConfig["customModels"] },
- provider: Pick,
+ provider: Pick & Partial>,
modelId: string,
providerName?: string,
): boolean {
+ if (provider.adapter === "trae-cn") return true;
const runtimeDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
? provider.modelCapabilities?.[modelId]?.inputModalities
: undefined;
diff --git a/structure/INDEX.md b/structure/INDEX.md
index f7a9d3f6912..88c07e59f17 100644
--- a/structure/INDEX.md
+++ b/structure/INDEX.md
@@ -92,6 +92,7 @@ Per-vendor contracts and the adapter authority that constructs them.
| [`providers/oauth-reconciliation.md`](providers/oauth-reconciliation.md) | Operator limits and per-model declarations across startup reconciliation and OAuth upsert. |
| [`providers/antigravity-account-import.md`](providers/antigravity-account-import.md) | Cockpit Tools import schema, identity check, probe-gated project_id fallback and no-write failure paths. |
| [`providers/claude-cli-replay.md`](providers/claude-cli-replay.md) | Claude CLI stable replay, canonical tool schemas, capture-only client tool ownership and subscription usage admission. |
+| [`providers/trae-cn.md`](providers/trae-cn.md) | Official CLI authentication, ACP text streaming, model discovery, capability limits and process lifecycle. |
| [`providers-and-adapters.md`](providers-and-adapters.md) | Provider and adapter selection, the adapter inventory, live model discovery, and the hosted-search continuation bridge. |
| [`providers/opengateway.md`](providers/opengateway.md) | Preset discovery, paired Responses tool-result projection and bounded empty-completion recovery. |
| [`providers/jev-decision.md`](providers/jev-decision.md) | JEV Combo decision methods (TypeSafe, self-hosted System One rows, opencodex models), the decision request path, dashboard surfaces, and the content-free statistics projection. |
@@ -236,6 +237,7 @@ A source area can be described by more than one doc, because these docs are orga
| `src/adapters/openai-responses/request-strips.ts` | [`transports/responses-citations.md`](transports/responses-citations.md) |
| `src/adapters/openai-responses/tool-schema.ts` | [`providers/hosted-tool-denials.md`](providers/hosted-tool-denials.md) |
| `src/adapters/openai-responses/transport.ts` | [`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) |
+| `src/adapters/trae-cn/` | [`providers/trae-cn.md`](providers/trae-cn.md) |
| `src/adapters/unique-tool-call-ids.ts` | [`providers/completed-tool-blocks.md`](providers/completed-tool-blocks.md) |
| `src/adapters/workbuddy-schema.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) |
| `src/adapters/workbuddy.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) |
@@ -293,6 +295,7 @@ A source area can be described by more than one doc, because these docs are orga
| `src/codex/catalog/ownership.ts` | [`catalog-routed-ownership.md`](catalog-routed-ownership.md) |
| `src/codex/catalog/parsing.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) |
| `src/codex/catalog/routed-gather.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)
[`providers/opengateway.md`](providers/opengateway.md) |
+| `src/codex/catalog/trae-cn-models.ts` | [`providers/trae-cn.md`](providers/trae-cn.md) |
| `src/codex/catalog/vendor-context.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) |
| `src/codex/cli-update-apply.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
| `src/codex/cli-update-artifacts.ts` | [`codex-cli-update.md`](codex-cli-update.md) |
@@ -474,6 +477,7 @@ A source area can be described by more than one doc, because these docs are orga
| `src/server/management/provider-overwrite-carry.ts` | [`providers/hosted-tool-denials.md`](providers/hosted-tool-denials.md) |
| `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) |
| `src/server/management/telemetry-routes.ts` | [`telemetry.md`](telemetry.md) |
+| `src/server/management/trae-cn-provider-probe.ts` | [`providers/trae-cn.md`](providers/trae-cn.md) |
| `src/server/management/usage-aggregate-cache.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) |
| `src/server/messages-native-tool-guard.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) |
| `src/server/messages-native.ts` | [`data-planes/error-detail.md`](data-planes/error-detail.md) |
diff --git a/structure/catalog-vendor-context.md b/structure/catalog-vendor-context.md
index 4bfbf1b1271..c5083d7f11b 100644
--- a/structure/catalog-vendor-context.md
+++ b/structure/catalog-vendor-context.md
@@ -32,8 +32,10 @@ class accompany every inferred value. These are bundled estimates, not live gate
`src/codex/catalog/parsing.ts` defines `CatalogModel.contextWindowInference`;
`src/codex/catalog/routed-gather.ts` preserves it through custom-row gap filling and cache projection; explicit
custom windows clear it. Re-hinting recomputes inferred windows and cap annotations rather than
-promoting the old estimate to an observation. Custom-only rows fill only missing context fields,
-leaving explicit modalities and reasoning unchanged. `src/codex/catalog/effort.ts` writes the
+promoting the old estimate to an observation. Custom-only rows fill only missing context fields.
+Explicit modalities and reasoning retain their precedence within adapter wire ceilings; TRAE CN
+custom rows and combo members are text-only with verbosity disabled before optional vision-sidecar publication.
+`src/codex/catalog/effort.ts` writes the
estimate under `inferred_context_window` / `context_window_inference` in capability provenance,
not `context_window`, so routing requirements still see unknown capacity. Existing asserted
modality/tool evidence remains usable. Shared client catalogs, Claude context markers and exports
diff --git a/structure/catalog.md b/structure/catalog.md
index 235e9755550..a63b9195c93 100644
--- a/structure/catalog.md
+++ b/structure/catalog.md
@@ -22,8 +22,9 @@ Accounts added through the [Orca import](codex-home.md#orca-source-owned-account
validation-pending. Import alone supplies no entitlement evidence for the model catalog.
## Shared catalog
+TRAE CN discovery sessions are serialized per provider, including background source refresh and ordinary refreshing gathers, so an older slow session cannot publish after a newer session. Each gather captures cache generation before joining the queue and revalidates it immediately after waiting, before reading cache rows or opening ACP; a replaced provider snapshot cannot borrow the replacement's authority. The automatic source phase still runs before convergence: each authoritative result must own its captured content revision before it is pinned, then stays pinned before the next provider can evict its ordinary cache row. A pin preserves the ordinary cache entry's original fetch time, so publication retention never renews discovery TTL; a roster already evicted under pressure uses the successful gather time. Those pins survive through convergence and connected-client fan-out, so memory pressure and `modelCacheTtlMs: 0` cannot discard a roster before publication completes. The round revalidates each pinned content revision again before publication; an intervening probe, discovery or authority change retires the whole round rather than restoring an older roster. Every acquired pin is released. `src/codex/management-convergence.ts` and unrelated observed gathers use existing cache evidence without waiting for this CLI discovery. A lost cache generation rereads the current roster before recording its revision; see [TRAE CN](providers/trae-cn.md).
`src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy.
-Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`.
+Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact within adapter wire ceilings, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`.
Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Registry context seed capture preserves [configured window precedence](catalog-vendor-context.md): provider-wide limits bound seed defaults, explicit per-model windows win, and smaller observed limits remain authoritative.
Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. That authority also requires the active account to remain usable with the credential discovery sent, so a needsReauth, pause, expiry or replacement of the active account stops later sibling refreshes and sends (`tests/adapters/google/antigravity-routing-carry.test.ts`). A sibling waiting for its OAuth refresh lock rechecks root/cache authority before token dispatch; an already-dispatched rotation still persists under the credential CAS. Because discovery resolves siblings with `requireUsableAccount`, the generic and xAI refresh paths in `src/oauth/index.ts` also recheck `needsReauth` under that lock, and the resolver rechecks it before adopting a refresh result, so a sibling marked for reauthentication while it waited sends neither its refresh token nor a bearer. Callers that do not require a usable account (explicit refresh, login, the token guardian) keep refreshing such an account; one that joined a usable-only refresh retries as its own owner. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings.
`src/providers/resolved-model-policy.ts` resolves and freezes only registry/operator static facts,
@@ -197,10 +198,10 @@ Nothing in the catalog validates Codex's own root `model` pin against this expos
`readConfiguredDefaultModel` in `src/codex/catalog/parsing.ts` reads the pin, and `ocx doctor`
reports it (see [Runtime](runtime.md)).
-Provider live-model lists are cached with a configured TTL (`src/codex/model-cache.ts`). Adding,
-deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change
-deliberately does not, because a disabled provider is already excluded from the catalog gather
-instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh.
+Provider live-model lists are cached with a configured TTL (`src/codex/model-cache.ts`). Adding, deleting, or editing a provider's shape clears that cache; a disabled-only change does not, because disabled providers are excluded from the gather.
+Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. Explicit probe publication can pin shared-budget-admitted rows within an 8 MiB/16-entry owner cap.
+Budget eviction skips pinned rows; authority clearing removes them from lookup while detached payload stays counted until the idempotent release.
+TRAE uses the lease through convergence and client refresh, ahead of the ordinary TTL until release; admission refusal reports pending retention without another discovery attempt ([TRAE contract](providers/trae-cn.md)).
Account-scoped discovery transports remain bound to the credential snapshot that supplied the
token. Devin discovery uses the allowlisted tenant API base URL from that same snapshot rather
than pairing a durable account key with the provider registry's default host. The same holds for
diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md
index d2ddbc4241e..74ff1cbbc68 100644
--- a/structure/data-planes/images.md
+++ b/structure/data-planes/images.md
@@ -18,6 +18,19 @@ file-backed image references in messages, agent messages or tool results before
dispatch. Other provider routes retain their own image handling; a usable image URL takes
precedence over a file ID. `tests/providers/claude-cli-client-http.test.ts` covers this boundary.
+TRAE CN retains its pre-dispatch refusal for instruction media, native agent-message media (including an item that also says
+`role: "user"`), video, documents and tool replay. An ordinary user `message` image may instead proceed only when the selected
+route produces an actual routed or credentialed OpenAI vision plan; preferring the OpenAI backend without usable credentials is
+not dispatchability. The sidecar replaces that image with a description before ACP dispatch. Tool-result images are refused
+before vision preparation, while rebuilt `web_search` results remain eligible for the existing no-search-plan cleanup.
+TRAE resolves the optional OpenAI helper once, before image admission, and reuses that same result for preprocessing. Replay
+refusals precede credential resolution and no image is stripped or described when no dispatchable plan exists; a text-only
+request does not resolve a vision helper. A refusal returns any acquired helper probe lease before completing, while a disabled
+or ineligible vision configuration acquires none. The credentialed and credential-free OpenAI cases and the resolver's single invocation
+are pinned beside the other media regressions.
+`tests/responses/responses-combo-run-turn-classified-failure.test.ts` covers these boundaries and verifies that refused agent
+and tool-result images trigger no vision request or ACP session.
+
Mistral's registry entry in `src/providers/registry/entries-extended.ts` declares `zai-glm-5-3` native input as text-only, so registry enrichment enrolls that model in the enabled vision sidecar for existing configurations: the catalog advertises image attachments and request preprocessing sends GLM descriptions. Native Mistral vision models and explicit operator modalities retain precedence (`tests/vision/mistral-vision-sidecar.test.ts`).
A registry entry can also declare `inputModalities` for the whole provider. It covers ids the per-model maps do not list, such as models from live discovery, and a per-model declaration still wins. AI Stupid Level declares text, so an image for one of its pinned models is described or stripped instead of being sent to the router (`tests/providers/aistupidlevel-provider.test.ts`).
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index 60e69bb43f1..dceda2d5560 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -10,7 +10,7 @@ threshold. The dashboard reuses `AccountAutoSwitchControl` below account actions
focus/draft semantics and translated copy. The hook protects same-provider selection mutations
and stale roster reads; confirmed pool-setting changes seed new overrides immediately, without
overwriting an existing custom draft. Old servers do not show a synthetic control. See
-[Anthropic threshold semantics](providers/anthropic-account-thresholds.md). Connection probes use the [discovery credential ownership contract](providers/anthropic-account-pool.md#discovery-credential-ownership), including a live check before sending.
+[Anthropic threshold semantics](providers/anthropic-account-thresholds.md). Connection probes use the [discovery credential ownership contract](providers/anthropic-account-pool.md#discovery-credential-ownership), including a live check before sending. The [TRAE CN probe](providers/trae-cn.md) uses a bounded ACP session in `src/server/management/trae-cn-provider-probe.ts`, replaces the unchanged generation's roster, requests catalog convergence and refreshes owned client integrations. Connectivity and publication outcomes remain separate; static catalogs remain not applicable.
Anthropic account rows now expose the shared boolean `paused` DTO and use the existing
`PUT /api/oauth/accounts/pause` body `{ provider, accountId, paused }`. The dashboard's
diff --git a/structure/manifest.json b/structure/manifest.json
index 05dd11805cc..ce0135e765c 100644
--- a/structure/manifest.json
+++ b/structure/manifest.json
@@ -643,6 +643,13 @@
"scope": "Claude CLI stable replay, canonical tool schemas, capture-only client tool ownership and subscription usage admission.",
"documents": ["src/adapters/"]
},
+ {
+ "path": "providers/trae-cn.md",
+ "tier": 4,
+ "title": "TRAE CN ACP",
+ "scope": "Official CLI authentication, ACP text streaming, model discovery, capability limits and process lifecycle.",
+ "documents": ["src/adapters/trae-cn/", "src/codex/catalog/trae-cn-models.ts", "src/server/management/trae-cn-provider-probe.ts"]
+ },
{
"path": "providers-and-adapters.md",
"tier": 4,
diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md
index bc46a1ffe47..6344c36c992 100644
--- a/structure/providers/chat-compat.md
+++ b/structure/providers/chat-compat.md
@@ -19,6 +19,27 @@ deferred reminders. This behavior previously covered only OpenCode Go and
roles, and Claude trailing-notice option are unchanged; cache hits are not guaranteed.
Tests: `tests/adapters/openai/openai-chat-system-order.test.ts` and `tests/adapters/openai/openai-chat-developer-position.test.ts`.
+Policy-candidate replay uses the same inbound Chat projection with instruction-media preservation enabled only for that retry.
+Leading media instructions stay ahead of the first conversation turn; later instructions retain their order, including
+several consecutive instructions and the existing deferred tool-result boundary. Text and attachment parts are emitted once,
+while the normalized effort and other retry settings remain authoritative. Ordinary Chat projection is unchanged.
+`tests/responses/responses-combo-run-turn-classified-failure.test.ts` exercises the real Chat policy retry for OpenAI image
+and Google video targets.
+Translated concrete combo children use that same retained Chat projection, so a replayable text-only target refusal cannot
+drop instruction media before an image-capable fallback target. With native Chat combos enabled, native targets retain the
+existing envelope while bridged targets consume the retained projection. Restored images remain subject to the combo's
+`imageInput` policy through the original Chat instruction-image admission fact.
+Structured instructions with image/audio/document parts keep their image detail and use the same leading-system template
+role rule as text-only instructions, so pinned templates receive a chronological `user` turn rather than an invalid late
+`system` or `developer` role. `tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts` covers the structured image.
+Inline audio never moves into a `system`/`developer` content part: its instruction text retains the original slot and the
+audio follows in a user-compatible carrier after any pending tool result. `tests/adapters/openai/openai-chat-audio.test.ts`
+covers the default folded role and this tool-result boundary. Default developer images and documents keep their instruction
+text and attachments together in one user carrier. Contiguous text and audio groups retain their source order, so interleaved instructions and recordings
+remain adjacent instead of being regrouped by content type.
+With `singleLeadingSystemMessage`, only text contributes to the merged instruction; every audio, image and document part moves
+to one user carrier in original relative order, also after the pending tool result. A combined audio/image regression covers it.
+
The role that slot carries is a separate decision, and the setting that makes it is tri-state.
`foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends
`developer`. Unset means nothing has been recorded about the destination; `true` records an
diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md
new file mode 100644
index 00000000000..6b89f537f1a
--- /dev/null
+++ b/structure/providers/trae-cn.md
@@ -0,0 +1,112 @@
+# TRAE CN ACP
+
+`src/adapters/trae-cn/` runs the official CLI's ACP server for manually configured `trae-cn` adapters.
+The directory row is inert and unverified; it advertises no canonical preset or live discovery.
+TRAE owns login and refresh; OpenCodex stores no TRAE token. A fresh session supplies the CLI-reported
+model roster and one selected-model turn. `src/adapters/trae-cn/input-media.ts` checks original
+system/developer content and native agent-message media before normalization loses their attachments.
+Value-free Responses/Chat facts survive preprocessing and combo delivery; supported text aliases stay valid.
+Pre-dispatch media refusals remain target-local, so eligible policy/combo fallback may proceed without
+replaying a sent prompt. The dispatch heartbeat still seals turns that reached the vendor CLI.
+The vendor does not document the discovery fields or
+guarantee their account scope. The JSON-RPC reader bounds frames, update queues, stderr,
+and request time; cancellation and cleanup terminate the child. Text and thought chunks retain their
+distinct adapter events. Tool input/replay and non-text media are rejected, permission requests are
+denied and terminate the session. Tool notifications fail with `trae_tool_call_unsupported`; tool and malformed-update
+refusals retain usage already received in the prompt reply. Only a
+normal `end_turn` completion without text fails with `trae_empty_response`. An outputless `max_tokens`
+completion retains its truncation verdict and maps to an incomplete Responses result. ACP
+`max_turn_requests` emits an incomplete turn with that limit reason and retains partial text and usage.
+Empty completions and unsupported stop reasons retain reported usage in their terminal error. Refusals
+emit a non-retryable `content_filter` incomplete event with usage. A fatal reader frame received after
+the prompt reply supersedes success and carries that reply's usage into its terminal error. The CLI uses
+an empty temporary workspace with disallow rules and no session MCP/skills. `src/codex/catalog/trae-cn-models.ts` publishes discovered
+models as text-only with verbosity disabled, including configured ids outside the seed list. All
+cached fallbacks reapply current caps and retention; a cold failure also observes discovery cooldown.
+`liveModels: false` bypasses ACP discovery. Static and discovered rows enforce the wire's text-only
+and no-verbosity limits after config hints. Vision classification and preprocessing enforce the same
+limit before per-model overrides; only sidecar publication can advertise image attachments.
+The CLI inherits an absolute `TRAE_HOME` when selected by the operator, otherwise the original
+user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overrides
+are refused before CLI startup, including explicit direct routing.
+Observed-auth gathers and management admission use only cached evidence; only refreshing gathers and explicit connection
+probes may launch the CLI. Automatic refresh prepares CLI-owned evidence in its independent background
+source phase. Discovery sessions are serialized per provider; each gather binds cache generation before queueing and
+revalidates it after waiting, so a slow older or replaced provider snapshot cannot overwrite a newer roster.
+The source phase revalidates the gather's content revision before pinning each authoritative result and discovering the next provider. Those pins survive
+through catalog convergence and connected-client refresh before release, so later ordinary cache eviction cannot
+discard an earlier roster. Every pinned content revision is revalidated before publication;
+an intervening discovery retires that publication round instead of restoring its older roster. This keeps a zero-TTL roster authoritative for the full publication
+round without launching ACP twice. Cold or stalled discovery cannot delay unrelated management responses. Lost-generation discovery rereads the current
+cache before attaching its revision. Relative PATH, user-local fallback entries and the original CLI home resolve before the child changes its cwd.
+Windows home fallback discovery uses the same native-before-launcher order as PATH, including `traecli.cmd`.
+Custom catalog rows and combo members receive the wire modality/verbosity ceiling before optional sidecar publication.
+
+Cancellation is bound before the first initialization RPC. Pending RPCs fail as shutdown starts;
+stdin errors enter the same failure path. Shutdown terminates the POSIX process group or Windows
+Job Object and bounds both process and stream waits. The POSIX supervisor in
+`src/adapters/trae-cn/process.ts` keeps its group leader alive after the CLI exits. The parent requests
+shutdown through a private pipe; live group members signal group 0, so numeric group-ID reuse cannot
+target an unrelated group. Descendants that detach into another group are outside this cleanup scope.
+On Windows 10+, `src/adapters/trae-cn/windows-job.ts` launches a PowerShell 5.1 supervisor with
+atomic Job List and handle-list process attributes. It owns descendants even after a dispatcher
+exits, denies breakaway, and kills members when its private Job handle closes. CLI stderr travels
+as bounded base64 frames; only supervisor frames can attest that the Job has no active members.
+Scoped Windows environment keys are matched case-insensitively, and the native launch receives an
+absolute executable/dispatcher path. Unsupported supervision fails before launching an unowned CLI.
+No numeric PID is reopened.
+Unconfirmed shutdown is an error and retains owned temporary files; the adapter publishes its terminal
+event only after the parent has bounded both supervisor and stream waits. The parent keeps draining
+stderr after it suppresses diagnostic retention and attempts forced termination if the supervisor misses its deadline.
+Missing Job accounting proof remains an error, including when a status write cannot be delivered.
+Received usage is preserved even when cleanup fails. A failed workspace allocation removes an
+already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send. The adapter
+also emits a replay-unsafe heartbeat at that write, so combo preflight cannot resend the turn
+after an empty result or another post-dispatch failure. Startup failures and unavailable models
+return unused reservations without a physical send. An unavailable model keeps the internal
+`trae_model_unavailable` ACP code, while the adapter emits the shared `model_unavailable` routing
+code so a combo can try its next healthy target.
+Local spend-ledger file refusals propagate to the shared local-storage 503 mapping, with producer cleanup, before ACP error classification.
+Developer replay blocks retain their chronological positions, including trailing instructions after
+the current request, while the system prompt preserves their instruction priority.
+Replay over 1,000,000 UTF-16 code units is
+rejected before startup. Unexpected EOF diagnostics retain bounded, redacted stderr after process
+cleanup, including stderr that arrives after stdout closes. Once stderr exceeds 8192 characters,
+all diagnostic text is suppressed so truncation cannot separate a secret from its redaction label.
+Malformed stdout produces fixed protocol diagnostics, never the shared JSONL parser's snippets.
+Byte-limit failures retain `trae_stdout_limit`; startup failures retain a stage and allowlisted errno.
+POSIX launch exits 126/127 before initialization report safe startup codes via the private control pipe.
+Prompt completion closes the bounded update stream once, without a new promise reaction per chunk.
+Client cancellation emits no provider error; a parsed ACP cancelled reply still forwards its usage
+when it races with client abort. Received usage survives cleanup cancellation in a
+`client_cancelled` incomplete outcome and is bound before a closed client stream can drop it. An upstream cancellation without client abort emits
+an incomplete event with usage. Explicit output-token limits are refused before startup.
+A successful `session/set_model` acknowledgement supersedes prior setup model notifications.
+The reader then validates subsequent model changes against the selected id, before or during prompt dispatch.
+The finite CLI tool denylist and
+disabled ACP client capabilities do not sandbox CLI-side hooks or unlisted vendor tools.
+
+`src/server/management/trae-cn-provider-probe.ts` opens and closes a direct ACP session for the
+connection test and replaces the unchanged generation's cached roster after success. It requests
+catalog convergence and refreshes previously connected clients through the owned integration writer.
+The successfully probed roster is pinned in the shared model cache until both operations finish.
+Shared-budget admission can demote reclaimable continuation state before retaining that roster;
+the pin is bounded by the model-cache owner's 8 MiB/16-entry cap and released on every outcome.
+An authority clear still revokes the roster while its detached bytes remain accounted until release.
+If admission is refused, the probe reports `rosterRetentionPending` and an admission failure without
+starting another CLI session or claiming roster publication.
+The response separates connectivity from catalog/client publication and notes the running Codex
+session restart requirement. Invalidation before the cache write reports `rosterReplaced: false`
+and asks for another test. Invalidation after that write does not undo the historical `true` result.
+Disk-only edits do not invalidate the running process's cache generation.
+It never probes the vendor
+website or accepts a cached roster as login evidence. The cache remains provider-scoped, so account
+switches require this explicit refresh. Each prompt still checks its model against a new session roster.
+
+User setup: [TRAE CN guide](../../docs-site/src/content/docs/guides/trae-cn.md).
+
+The adapter regression file includes a sanitized session/model response captured from CLI 0.120.19.
+Its installed-CLI discovery check runs only with `OCX_TRAE_CN_LIVE_TEST=1`, an absolute
+`OCX_TRAE_CN_LIVE_BINARY` and an explicit absolute `TRAE_HOME` containing an existing sign-in.
+Test HOME isolation stays enabled. The check opens and closes a session without sending a model
+prompt; ordinary tests remain offline.
diff --git a/structure/transports/policy-fallback.md b/structure/transports/policy-fallback.md
index 9c2c2239dc8..0762cfa753a 100644
--- a/structure/transports/policy-fallback.md
+++ b/structure/transports/policy-fallback.md
@@ -6,6 +6,12 @@ Subagent selection and encrypted-task recovery keep that membership; a replaceme
refused before dispatch, and policy fallback may continue with another originally eligible candidate.
A shadow-intercept target is probed without that capture and is captured only once interception is
accepted, so a declined policy target never scopes the request's own route.
+For Chat ingress, candidate replay retains the normalized settings from the first attempt, including effort decoded from a
+synthetic selector, while rebuilding only the conversation and instructions from the original inbound messages through the
+[media-preserving chronological projection](../providers/chat-compat.md#chronological-in-conversation-instructions).
+This keeps later media instructions in place without duplicating their text or replacing the normalized model and reasoning.
+The original Chat body is cloned only for a policy route or a shadow request that can enter one. Its serialized size is
+admitted to the request's translator `request_copies` budget before allocation; non-policy Chat requests retain no clone.
The outer wrapper snapshots the original wire body of any request whose model a configured shadow
intercept can rewrite: interception runs after that snapshot, so a routing-profile target selected
by interception keeps the same candidate traversal and exhausted-refusal 400 a direct policy
diff --git a/structure/transports/responses.md b/structure/transports/responses.md
index 8f75fe93644..6d987d4d459 100644
--- a/structure/transports/responses.md
+++ b/structure/transports/responses.md
@@ -417,7 +417,7 @@ Final-route normalization in `src/server/responses/core-normalize.ts` decodes cl
| `request-prepare.ts` | Body parsing, combo handoff, final route, encrypted-task recovery and initial admission, retaining [original policy authorization](policy-fallback.md). Both shadow-intercept sites use `shadowCallPreservesSelectedModel` in `src/lib/shadow-call.ts`: explicit `request_kind: "turn"` plus `thread_source: "user"`, or spawned-child provenance, preserves the selected model. `x-openai-subagent: collab_spawn` is a connection header on both transports (a child session opens its own connection); turn metadata comes from the HTTP `x-codex-turn-metadata` header or, on WebSocket, only from each frame's `client_metadata["x-codex-turn-metadata"]` (including `subagent_kind: "thread_spawn"`), never from stale upgrade metadata. Kind-only `turn` helpers and missing/malformed metadata retain interception; concrete self-targets remain no-ops and helper combos retain failover. `tests/responses/responses-shadow-intercept.test.ts` covers both sites and frame precedence. xAI OAuth model-scope admission previews the billed Fast lane using the serialization decision shared with Chat, Messages and routed compact admission; final normalization rechecks the actual wire destination. |
| `shadow-target-availability.ts` | Shadow-call target resolution for `request-prepare.ts`: an unavailable target fails once with `409 intercept_target_unavailable` instead of reaching the native source model or the default provider. |
| `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. Each physical adapter binding owns the build-only [append-only Claude tool projection](../data-planes/claude-tool-loading.md), preserving the request's original catalog across fallback and rebuilds. Copilot Auto renewal onto Chat (including negotiation-401 refresh) signals the native owner before inference; `passthrough-dispatch.ts` reuses its inference-401/429 adapter handoff so JSON and SSE retain the Responses envelope. |
-| `request-sidecar-auth.ts` | Routed-compaction image projection, sidecar credential resolution and vision preprocessing. |
+| `request-sidecar-auth.ts` | Routed-compaction image projection, sidecar credential resolution and vision preprocessing. `resolveResponsesOpenAiSidecar` separates credential resolution from image rewriting so TRAE can admit ordinary user images only against a real dispatchable plan and reuse the same helper afterward. Text-only TRAE turns skip helper resolution; a pre-dispatch image refusal releases an acquired probe lease. |
| `response-effects.ts` | Completion notification, replay publication, live request-tool aliases and routed summary controls; Anthropic keeps client tools/system/options, and flags hosted-tool or mandatory-choice summaries so the adapter sends wire `tool_choice: none`. |
| `request-send-budget.ts` | Request-wide send accounting, remaining allowance, the pending recovery permit and the shared ambiguous-resend grant. |
| `reset-replay.ts` | Self-contained-request eligibility and the shared operator replacement grant for native Responses and generic translated initial/rebuilt pre-header sends; adapter-owned transports and translated post-header failures are excluded. |
diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts
index e1873cbed20..50daa2b2342 100644
--- a/tests/adapters/adapter-registry-authority.test.ts
+++ b/tests/adapters/adapter-registry-authority.test.ts
@@ -1,4 +1,46 @@
-import { describe, expect, test } from "bun:test";
+import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
+import { EventEmitter, once } from "node:events";
+import { chmodSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
+import { isAbsolute, posix, win32 } from "node:path";
+import { mkdtemp } from "node:fs/promises";
+import { PassThrough, Writable } from "node:stream";
+import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
+import { createTraeCnAdapter } from "../../src/adapters/trae-cn/adapter";
+import * as traeClient from "../../src/adapters/trae-cn/client";
+import { traePosixInvocation, traeStopPipe } from "../../src/adapters/trae-cn/process";
+import { prepareTraeWindowsJob, TraeWindowsJobFrames, traeWindowsCommandLine, traeWindowsJobExecutable } from "../../src/adapters/trae-cn/windows-job";
+import { commandInvocation } from "../../src/lib/win-exec";
+import { buildCatalogEntries } from "../../src/codex/catalog";
+import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge";
+import { resolveTraeCli, traeCnChildEnv } from "../../src/adapters/trae-cn/profile";
+import { parseTraeAcpMessage, parseTraeModels, traeSessionId } from "../../src/adapters/trae-cn/protocol";
+import * as traeProtocol from "../../src/adapters/trae-cn/protocol";
+import { parseRequest as parseResponsesRequest } from "../../src/responses/parser";
+import { fetchProviderModels, fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models";
+import { captureProviderGather } from "../../src/codex/catalog/gather-capture";
+import { captureModelCacheGeneration, clearModelCache, getFreshCached, observeModelCacheRevision, setCached } from "../../src/codex/model-cache";
+import { probeTraeCnProvider } from "../../src/server/management/trae-cn-provider-probe";
+import { handleProviderRoutes } from "../../src/server/management/provider-routes";
+import { customRowInputModalities, isModelVisionSidecarConsumer, isVisionSidecarConsumer, modelAcceptsImageInput } from "../../src/vision/eligibility";
+import { requiresVisionPreprocessing } from "../../src/vision/plan";
+import { gatherRoutedModels } from "../../src/codex/catalog/routed-gather";
+import { refreshTraeCnCatalogSources } from "../../src/codex/catalog/trae-cn-models";
+import * as managementConvergence from "../../src/codex/management-convergence";
+import * as catalogScheduler from "../../src/codex/catalog-auto-refresh";
+import * as configApi from "../../src/config";
+import * as catalogAdmission from "../../src/codex/catalog-admission";
+import type { CatalogDisposition } from "../../src/codex/convergence-types";
+import { createTranslatorBudget } from "../../src/lib/translator-budget";
+import { SendBudgetExhaustedError } from "../../src/lib/upstream-retry";
+import { SpendLedgerFileRefusedError } from "../../src/lib/spend-reservation-ledger";
+import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget";
+import { mapSpendLedgerStorageError } from "../../src/server/responses/spend-storage-error";
+import { appOwnedBytesSnapshot, configureAppOwnedMemoryBudget, enforceAppOwnedMemoryBudget, registerRetainedStore, resetAppOwnedMemoryForTests } from "../../src/lib/app-owned-memory";
+import { registerDefaultAppOwnedMemoryStores } from "../../src/lib/app-owned-memory-stores";
+import { getPinnedCached, evictOldestModelCacheForBudget, modelCacheRetainedStoreSnapshot } from "../../src/codex/model-cache";
+import type { AdapterEvent } from "../../src/types";
+import type { IncomingMeta } from "../../src/adapters/base";
+import { createTempHome, type TempHome } from "../helpers/temp-home";
import {
adapterDefinitions,
createRegisteredAdapter,
@@ -6,8 +48,8 @@ import {
getAdapterDefinition,
} from "../../src/adapters/registry";
import { resolveAdapter } from "../../src/server/adapter-resolve";
-import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types";
-import { withTestTranslatorBudget } from "../helpers/translator-budget";
+import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types";
+import { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget";
const EXPECTED_ADAPTER_NAMES = {
"workbuddy-ai": "workbuddy-ai",
@@ -27,6 +69,7 @@ const EXPECTED_ADAPTER_NAMES = {
"mimo-free": "mimo-free",
qoder: "qoder",
"claude-cli": "claude-cli",
+ "trae-cn": "trae-cn",
"dsh-account": "dsh-account",
} as const;
@@ -41,6 +84,8 @@ function provider(adapter: string): OcxProviderConfig {
? "https://www.codebuddy.ai"
: adapter === "qoder"
? "https://qoder.com"
+ : adapter === "trae-cn"
+ ? "https://www.trae.cn"
// ollama-native refuses a bare /v1 path on a host it does not recognise, rather than
// guessing that an arbitrary destination speaks Ollama's compatibility surface.
: adapter === "ollama-native"
@@ -116,3 +161,1633 @@ describe("adapter registry authority", () => {
}
});
});
+
+type AcpRequest = { id: number; method: string; params: { prompt?: Array<{ text: string }> } };
+
+function traeFixture(intercept?: (request: AcpRequest, child: ChildProcess) => boolean) {
+ const child = new EventEmitter() as ChildProcess & { killed: boolean; exitCode: number | null };
+ const stdout = new PassThrough();
+ const requests: AcpRequest[] = [];
+ const signals: Array = [];
+ let options: SpawnOptions | undefined;
+ const send = (frame: unknown) => stdout.write(JSON.stringify(frame) + "\n");
+ Object.assign(child, { stdout, stderr: new PassThrough(), killed: false, exitCode: null });
+ child.stdin = new Writable({ write(chunk, _encoding, callback) {
+ if (String(chunk) === "__OCX_STOP_TRAE__\n") { callback(); child.emit("trae-stop-request"); return; }
+ const request = JSON.parse(String(chunk)) as AcpRequest;
+ requests.push(request);
+ callback();
+ queueMicrotask(() => {
+ if (stdout.destroyed || intercept?.(request, child)) return;
+ let result: unknown = {};
+ if (request.method === "session/new") result = { sessionId: "fixture-session", models: { availableModels: [{ modelId: "test-model" }] } };
+ if (request.method === "session/prompt") {
+ for (const [sessionUpdate, text] of [["agent_thought_chunk", "thinking"], ["agent_message_chunk", "answer"]]) {
+ send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: { sessionUpdate, content: { type: "text", text } } } });
+ }
+ result = { stopReason: "end_turn", usage: { inputTokens: 10, outputTokens: 2, totalTokens: 12 } };
+ }
+ send({ jsonrpc: "2.0", id: request.id, result });
+ });
+ } });
+ child.kill = signal => {
+ signals.push(signal);
+ child.killed = true;
+ child.exitCode = 0;
+ queueMicrotask(() => { stdout.end(); child.emit("close", 0); });
+ return true;
+ };
+ const stopPipe = new Writable({ write(_chunk, _encoding, callback) { callback(); child.kill("SIGTERM"); } });
+ Object.assign(child, { stdio: [child.stdin, stdout, child.stderr, stopPipe] });
+ const deps: traeClient.TraeAcpDeps = {
+ platform: "linux",
+ resolveBinary: () => "/fixture/traecli", timeoutMs: 300, killGraceMs: 2, reapTimeoutMs: 5,
+ spawn: (_command, _args, opts) => { options = opts; return child; },
+ };
+ return { child, requests, signals, deps, send, options: () => options };
+}
+
+function traeRequest(): OcxParsedRequest {
+ return { modelId: "test-model", stream: true, options: {}, context: {
+ systemPrompt: ["Keep the system policy"],
+ messages: [{ role: "user", content: "current request", timestamp: 0 }],
+ } };
+}
+
+function traeMeta(extra: Partial = {}): IncomingMeta {
+ return { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), ...extra };
+}
+
+function fakeSendBudget({ reserveAllowed = true, useAllowed = true } = {}) {
+ const state = { used: 0, refunded: 0, closed: 0 };
+ const budget = {
+ beginSpendProducer: () => ({ close: () => { state.closed++; } }),
+ reserveDispatch: () => reserveAllowed ? ({ allowed: true, permit: {
+ use: () => { if (!useAllowed) return false; state.used++; return true; },
+ release: () => { if (!state.used) state.refunded++; },
+ } }) : { allowed: false },
+ } as unknown as NonNullable;
+ return { state, budget };
+}
+
+describe("TRAE CN ACP regression contract", () => {
+ let testHome: TempHome;
+ const config = (): OcxProviderConfig => ({ adapter: "trae-cn", baseUrl: "https://www.trae.cn", authMode: "key", models: ["test-model"], liveModels: true });
+ const rawModels = async (row: OcxProviderConfig, cap?: number) => (await fetchProviderModelsWithAuth(
+ captureProviderGather("trae-cn", row, refreshingModelsAuthResolver), 60_000, cap, refreshingModelsAuthResolver,
+ )).models;
+ beforeEach(() => { testHome = createTempHome("ocx-trae-regression-"); clearModelCache(); });
+ afterEach(() => { mock.restore(); clearModelCache(); testHome.remove(); });
+
+ async function withProbeMemory(pinned: boolean, run: (evictions: () => number) => Promise): Promise {
+ const budget = 64 * 1024 * 1024;
+ resetAppOwnedMemoryForTests(); clearModelCache(); configureAppOwnedMemoryBudget(budget);
+ registerRetainedStore({ id: "model_cache", category: "caches",
+ snapshot: modelCacheRetainedStoreSnapshot, evictOldest: evictOldestModelCacheForBudget });
+ let bytes = pinned ? budget : budget - 1;
+ let evictions = 0;
+ registerRetainedStore({ id: "probe-continuation", category: "continuation",
+ snapshot: () => ({ count: bytes ? 1 : 0, bytes, pinnedBytes: pinned ? bytes : 0,
+ evictableBytes: pinned ? 0 : bytes, oldestAt: pinned || !bytes ? null : 1 }),
+ evictOldest: () => { evictions++; const released = bytes; bytes = 0; return released; },
+ });
+ try { return await run(() => evictions); }
+ finally { clearModelCache(); resetAppOwnedMemoryForTests(); registerDefaultAppOwnedMemoryStores(); }
+ }
+
+ function pressureSession() {
+ return spyOn(traeClient, "openTraeSession").mockImplementation(async () => ({
+ models: ["new-probed-model"], prompt: async () => undefined, close: async () => undefined,
+ }));
+ }
+
+ for (const ttl of [60_000, 0]) {
+ test("probe publication keeps one discovered roster under pressure; ttl=" + ttl, async () => {
+ await withProbeMemory(false, async evictions => {
+ const open = pressureSession();
+ const row = { ...config(), models: ["configured-fallback"] };
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-cn", modelCacheTtlMs: ttl, providers: { "trae-cn": row } };
+ let state: string | undefined;
+ let rosterAvailable = false;
+ let clientRosterAvailable = false;
+ const response = await probeTraeCnProvider("trae-cn", row, async () => {
+ await refreshTraeCnCatalogSources(next);
+ const resolver = observedModelsAuthResolver(null, []);
+ const gathered = await fetchProviderModelsWithAuth(captureProviderGather("trae-cn", row, resolver), ttl, undefined, resolver);
+ state = gathered.outcome.state;
+ rosterAvailable = gathered.models.some(model => model.id === "new-probed-model");
+ enforceAppOwnedMemoryBudget();
+ expect(appOwnedBytesSnapshot().retainedBytes).toBeLessThanOrEqual(64 * 1024 * 1024);
+ return { status: "committed", changed: true, degraded: false, notices: [] };
+ }, undefined, { refreshConnectedClients: async admit => {
+ expect(admit()).toBe(true); enforceAppOwnedMemoryBudget();
+ clientRosterAvailable = Boolean(getPinnedCached("trae-cn")?.some(model => model.id === "new-probed-model"));
+ return [];
+ } });
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(state).toBe("authoritative"); expect(rosterAvailable).toBe(true); expect(clientRosterAvailable).toBe(true);
+ expect(await response.json()).toMatchObject({ ok: true, rosterReplaced: true, clientRefreshPending: false, catalogRefresh: { status: "committed" } });
+ expect(evictions()).toBe(1); expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ if (ttl === 0) expect(getFreshCached("trae-cn", ttl, Date.now())).toBeNull();
+ });
+ });
+ }
+
+ test("saturated pinned memory refuses publication without another CLI discovery", async () => {
+ await withProbeMemory(true, async () => {
+ const open = pressureSession();
+ const converge = mock(async (): Promise => ({ status: "committed", changed: true, degraded: false, notices: [] }));
+ const body = await (await probeTraeCnProvider("trae-cn", config(), converge)).json();
+ expect(body).toMatchObject({ ok: true, rosterReplaced: false, rosterRetentionPending: true,
+ catalogRefresh: { status: "failed", reason: "admission", phase: "gather", partialWrite: false } });
+ expect(converge).not.toHaveBeenCalled(); expect(open).toHaveBeenCalledTimes(1);
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ expect(appOwnedBytesSnapshot().retainedBytes).toBe(64 * 1024 * 1024);
+ });
+ });
+
+ test("convergence and client-refresh errors release every roster pin", async () => {
+ await withProbeMemory(false, async () => {
+ pressureSession();
+ const body = await (await probeTraeCnProvider("trae-cn", config(), async () => { throw new Error("fixture"); }, undefined, {
+ refreshConnectedClients: async () => { throw new Error("fixture"); },
+ })).json();
+ expect(body).toMatchObject({ ok: true, clientRefreshPending: true, catalogRefresh: { status: "failed" } });
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ });
+ });
+
+ test("local spend-ledger refusals retain the shared 503 diagnostic before CLI startup", async () => {
+ const refusal = new SpendLedgerFileRefusedError("journal", "extra-hard-link");
+ let producerClosed = 0;
+ const sendBudget = createRequestExecutionBudget(undefined, "trae-ledger-refusal", {
+ enforced: true, charge: () => { throw refusal; }, refund: () => undefined,
+ beginReporter: () => ({ start: () => undefined, rebindTarget: () => true, report: () => undefined,
+ close: () => { producerClosed++; } }),
+ });
+ const fixture = traeFixture();
+ const events: AdapterEvent[] = [];
+ await expect(createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta({ sendBudget }),
+ event => events.push(event))).rejects.toBe(refusal);
+ expect(fixture.options()).toBeUndefined(); expect(events).toEqual([]);
+ expect(sendBudget.used).toBe(0); expect(producerClosed).toBe(1);
+ expect(mapSpendLedgerStorageError(refusal)).toMatchObject({ status: 503, code: "spend_ledger_storage_unavailable", errorType: "server_error" });
+ });
+
+ for (const closeFails of [false, true]) {
+ test("cleanup cancellation retains received usage without provider errors; closeFails=" + closeFails, async () => {
+ const controller = new AbortController();
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ spyOn(traeClient, "openTraeSession").mockResolvedValue({
+ models: ["test-model"],
+ prompt: async (_model, _text, emit, _signal, dispatch) => {
+ dispatch?.beforeSend?.(); dispatch?.onSend?.();
+ emit({ type: "text_delta", text: "finished" }); emit({ type: "done", usage });
+ },
+ close: async () => { controller.abort(); if (closeFails) throw new Error("fixture cleanup failed"); },
+ });
+ const budget = fakeSendBudget();
+ const meta = traeMeta({ abortSignal: controller.signal, sendBudget: budget.budget });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config()).runTurn!(traeRequest(), meta, event => events.push(event));
+ expect(events.map(event => event.type)).toEqual(["heartbeat", "text_delta", "incomplete"]);
+ expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "client_cancelled", usage, retryable: false });
+ expect(budget.state).toEqual({ used: 1, refunded: 0, closed: 1 });
+ const observed: unknown[] = [];
+ const json = buildResponseJSON(events, "test-model", { translatorBudget: meta.translatorBudget, onUsage: value => observed.push(value) });
+ expect(observed).toEqual([usage]);
+ expect(json.status).toBe("incomplete");
+ expect(json.usage).toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+ }
+
+ test("client cancellation retains usage from a thrown prompt failure", async () => {
+ const controller = new AbortController();
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ spyOn(traeClient, "openTraeSession").mockResolvedValue({
+ models: ["test-model"],
+ prompt: async (_model, _text, _emit, _signal, dispatch) => {
+ dispatch?.beforeSend(); dispatch?.onSend();
+ throw new traeClient.TraeAcpError("fixture prompt failure", "trae_acp_error", usage);
+ },
+ close: async () => { controller.abort(); },
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config()).runTurn!(traeRequest(), traeMeta({ abortSignal: controller.signal }),
+ event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "client_cancelled", usage });
+ });
+
+ for (const attachment of [
+ { type: "input_image", image_url: "https://fixture.invalid/image.png" },
+ { type: "input_image", image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a/nkAAAAASUVORK5CYII=" },
+ { type: "input_video", video_url: "https://fixture.invalid/clip.mp4" },
+ { type: "input_audio", audio_url: "https://fixture.invalid/audio.wav", format: "wav" },
+ { type: "input_file", file_id: "fixture-file" },
+ ]) {
+ test("original Responses instruction media are refused before CLI startup: " + attachment.type, async () => {
+ const fixture = traeFixture();
+ const parsed = parseResponsesRequest({ model: "test-model", input: [
+ { role: "system", content: [{ type: "input_text", text: "Describe the attachment" }, attachment] },
+ { role: "user", content: "Question" },
+ ] });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(parsed, traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "error", status: 400, code: "unsupported_input_modality" }]);
+ expect(fixture.options()).toBeUndefined(); expect(fixture.requests).toEqual([]);
+ });
+ }
+
+ for (const attachment of [
+ { type: "input_file", file_id: "fixture-agent-file" },
+ { type: "input_audio", audio_url: "https://fixture.invalid/agent.wav", format: "wav" },
+ ]) {
+ test("agent-message media references are refused before dispatch: " + attachment.type, async () => {
+ const fixture = traeFixture();
+ const parsed = parseResponsesRequest({ model: "test-model", input: [
+ { type: "agent_message", content: [attachment] }, { role: "user", content: "Question" },
+ ] });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(parsed, traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "error", status: 400, code: "unsupported_input_modality" }]);
+ expect(fixture.options()).toBeUndefined(); expect(fixture.requests).toEqual([]);
+ });
+ }
+
+ test("user file references remain refused when inline document bytes are also present", async () => {
+ const fixture = traeFixture();
+ const parsed = parseResponsesRequest({ model: "test-model", input: [{ role: "user", content: [
+ { type: "input_file", file_id: "fixture-user-file",
+ file_data: "data:application/pdf;base64,JVBERi0xLjQK", filename: "fixture.pdf" },
+ ] }] });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(parsed, traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "error", status: 400, code: "unsupported_input_modality" }]);
+ expect(fixture.options()).toBeUndefined(); expect(fixture.requests).toEqual([]);
+ });
+
+ for (const stop of ["cancelled", "end_turn"]) {
+ test("client abort after a parsed ACP reply retains usage: stop=" + stop, async () => {
+ const controller = new AbortController();
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session",
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "received" } } } });
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: stop, usage } });
+ return true;
+ });
+ const readUsage = traeProtocol.traeUsage;
+ spyOn(traeProtocol, "traeUsage").mockImplementation(result => {
+ const received = readUsage(result); controller.abort(); return received;
+ });
+ const budget = fakeSendBudget();
+ const meta = traeMeta({ abortSignal: controller.signal, sendBudget: budget.budget });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), meta, event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "client_cancelled", usage });
+ expect(events.some(event => event.type === "error")).toBe(false);
+ expect(budget.state).toEqual({ used: 1, refunded: 0, closed: 1 });
+ expect(fixture.child.killed).toBe(true);
+ expect(buildResponseJSON(events, "test-model", { translatorBudget: meta.translatorBudget }).usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+ }
+
+ // Captured from traecli --version 0.120.19 on 2026-11-08T16:22:30Z.
+ // Session/model strings are replaced and unrelated fields omitted; the opt-in test below checks the installed CLI.
+ test("recorded CLI 0.120.19 session/new keeps the reported model shape", () => {
+ const frame = parseTraeAcpMessage({ jsonrpc: "2.0", id: 2, result: {
+ sessionId: "recorded-session", models: {
+ currentModelId: "recorded-model-1", availableModels: [
+ {"modelId": "recorded-model-1", "name": "Recorded model 1", "description": "Redacted description"},
+ {"modelId": "recorded-model-2", "name": "Recorded model 2", "description": "Redacted description"},
+ {"modelId": "recorded-model-3", "name": "Recorded model 3", "description": "Redacted description"},
+ {"modelId": "recorded-model-4", "name": "Recorded model 4", "description": "Redacted description"},
+ {"modelId": "recorded-model-5", "name": "Recorded model 5", "description": "Redacted description"},
+ {"modelId": "recorded-model-6", "name": "Recorded model 6", "description": "Redacted description"},
+ {"modelId": "recorded-model-7", "name": "Recorded model 7", "description": "Redacted description"},
+ {"modelId": "recorded-model-8", "name": "Recorded model 8", "description": "Redacted description"},
+ {"modelId": "recorded-model-9", "name": "Recorded model 9", "description": "Redacted description"},
+ {"modelId": "recorded-model-10", "name": "Recorded model 10", "description": "Redacted description"},
+ {"modelId": "recorded-model-11", "name": "Recorded model 11", "description": "Redacted description"},
+ {"modelId": "recorded-model-12", "name": "Recorded model 12", "description": "Redacted description"},
+ {"modelId": "recorded-model-13", "name": "Recorded model 13", "description": "Redacted description"},
+ {"modelId": "recorded-model-14", "name": "Recorded model 14", "description": "Redacted description"},
+ {"modelId": "recorded-model-15", "name": "Recorded model 15", "description": "Redacted description"},
+ ],
+ },
+ } });
+ expect(frame.kind).toBe("response");
+ if (frame.kind !== "response") throw new Error("Expected recorded response");
+ expect(traeSessionId(frame.result)).toBe("recorded-session");
+ const models = parseTraeModels(frame.result);
+ expect(models).toHaveLength(15);
+ expect(models.slice(0, 2)).toEqual(["recorded-model-1", "recorded-model-2"]);
+ expect(models.at(-1)).toBe("recorded-model-15");
+ });
+
+ test.skipIf(process.env.OCX_TRAE_CN_LIVE_TEST !== "1")("installed TRAE CLI discovery (opt-in)", async () => {
+ const binary = process.env.OCX_TRAE_CN_LIVE_BINARY;
+ const cliHome = process.env.TRAE_HOME;
+ if (!binary || !isAbsolute(binary) || !cliHome || !isAbsolute(cliHome)) {
+ throw new Error("The opt-in check requires absolute OCX_TRAE_CN_LIVE_BINARY and TRAE_HOME paths; keep test HOME isolation enabled.");
+ }
+ const session = await traeClient.openTraeSession(config().baseUrl, { timeoutMs: 30_000, resolveBinary: () => binary });
+ try { expect(session.models.length).toBeGreaterThan(0); }
+ finally { await session.close(); }
+ }, 90_000);
+
+ test("developer replay records preserve chronology and instruction priority", async () => {
+ const prompts: string[] = [];
+ for (const developerFirst of [true, false]) {
+ const fixture = traeFixture();
+ const request = traeRequest();
+ const developer = { role: "developer" as const, content: "Chronology instruction" };
+ const assistant = { role: "assistant" as const, content: [{ type: "text" as const, text: "Earlier assistant reply" }] };
+ request.context.messages = [
+ { role: "user", content: "Earlier user request" },
+ ...(developerFirst ? [developer, assistant] : [assistant, developer]),
+ { role: "user", content: "Current chronology question" },
+ { role: "developer", content: "Trailing developer instruction" },
+ ];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(request, traeMeta(), () => undefined);
+ const prompt = fixture.requests.find(entry => entry.method === "session/prompt")!.params.prompt![0]!.text;
+ expect(prompt.split("")[0]).toContain("Chronology instruction");
+ const replay = prompt.split("Prior conversation context:")[1]!;
+ expect(replay).toContain("DEVELOPER:\nChronology instruction");
+ expect(replay.indexOf("DEVELOPER:\nChronology instruction") < replay.indexOf("ASSISTANT:\nEarlier assistant reply")).toBe(developerFirst);
+ expect(replay).toContain("Current request:\n\nUSER:\nCurrent chronology question");
+ expect(replay.indexOf("Trailing developer instruction")).toBeGreaterThan(replay.indexOf("Current chronology question"));
+ prompts.push(prompt);
+ }
+ expect(prompts[0]).not.toBe(prompts[1]);
+ });
+
+ test("streams text, reasoning and usage; charges only the prompt write", async () => {
+ const fixture = traeFixture();
+ const budget = fakeSendBudget();
+ const events: AdapterEvent[] = [];
+ const reports: string[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta({
+ sendBudget: budget.budget, onPhysicalSend: () => reports.push(fixture.requests.at(-1)!.method),
+ }), event => events.push(event));
+ expect(events.map(event => event.type)).toEqual(["heartbeat", "thinking_delta", "text_delta", "done"]);
+ expect(events[0]).toEqual({ type: "heartbeat", replayUnsafe: true });
+ expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 10, outputTokens: 2 } });
+ expect(reports).toEqual(["session/prompt"]);
+ expect(budget.state).toEqual({ used: 1, refunded: 0, closed: 1 });
+ const prompt = fixture.requests.find(request => request.method === "session/prompt")!.params.prompt![0]!.text;
+ expect(prompt).toContain("Keep the system policy");
+ expect(prompt).toContain("current request");
+ expect(fixture.child.killed).toBe(true);
+ expect(existsSync(String(fixture.options()!.env!.HOME))).toBe(false);
+ expect(existsSync(String(fixture.options()!.cwd))).toBe(false);
+ });
+
+ for (const reserveAllowed of [false, true]) {
+ test("dispatch-budget rejection writes no prompt: reservation=" + reserveAllowed, async () => {
+ const fixture = traeFixture();
+ const budget = fakeSendBudget({ reserveAllowed, useAllowed: false });
+ const events: AdapterEvent[] = [];
+ let sends = 0;
+ await expect(createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta({
+ sendBudget: budget.budget, onPhysicalSend: () => { sends++; },
+ }), event => events.push(event))).rejects.toBeInstanceOf(SendBudgetExhaustedError);
+ expect(fixture.requests.some(request => request.method === "session/prompt")).toBe(false);
+ expect(sends).toBe(0);
+ expect(events).toEqual([]);
+ expect(budget.state).toEqual({ used: 0, refunded: reserveAllowed ? 1 : 0, closed: 1 });
+ if (!reserveAllowed) expect(fixture.requests).toHaveLength(0);
+ });
+ }
+
+ for (const phase of ["initialize", "session/new", "session/set_model", "session/prompt"]) {
+ test(`abort during ${phase} tears down the child and directories`, async () => {
+ const controller = new AbortController();
+ const fixture = traeFixture(request => {
+ if (request.method !== phase) return false;
+ controller.abort();
+ return true;
+ });
+ const budget = fakeSendBudget();
+ let sends = 0;
+ const events: AdapterEvent[] = [];
+ let deadlineReached = false;
+ const watchdog = setTimeout(() => { deadlineReached = true; fixture.child.kill(); }, 1000);
+ try {
+ await createTraeCnAdapter(config(), { ...fixture.deps, timeoutMs: 60_000 }).runTurn!(traeRequest(), traeMeta({
+ abortSignal: controller.signal, sendBudget: budget.budget, onPhysicalSend: () => { sends++; },
+ }), event => events.push(event));
+ } finally { clearTimeout(watchdog); }
+ expect(deadlineReached).toBe(false);
+ expect(events).toEqual(phase === "session/prompt" ? [{ type: "heartbeat", replayUnsafe: true }] : []);
+ expect(fixture.child.killed).toBe(true);
+ expect(existsSync(String(fixture.options()!.env!.HOME))).toBe(false);
+ expect(existsSync(String(fixture.options()!.cwd))).toBe(false);
+ const dispatched = phase === "session/prompt" ? 1 : 0;
+ expect(sends).toBe(dispatched);
+ expect(budget.state).toEqual({ used: dispatched, refunded: 1 - dispatched, closed: 1 });
+ });
+ }
+
+ test("an unavailable model releases its reservation without a send", async () => {
+ const fixture = traeFixture();
+ const budget = fakeSendBudget();
+ let sends = 0;
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!({ ...traeRequest(), modelId: "unavailable" },
+ traeMeta({ sendBudget: budget.budget, onPhysicalSend: () => { sends++; } }), event => events.push(event));
+ expect(events[0]).toMatchObject({ type: "error", code: "model_unavailable", status: 400 });
+ expect(sends).toBe(0);
+ expect(budget.state).toEqual({ used: 0, refunded: 1, closed: 1 });
+ });
+
+ for (const kind of ["tools", "required", "toolCall", "toolResult", "image", "audio", "video", "document", "oversized", "output limit"]) {
+ test(`rejects ${kind} before starting the CLI`, async () => {
+ const request = traeRequest();
+ if (kind === "tools") request.context.tools = [{ name: "read_file", description: "Read a file", inputSchema: { type: "object" } }];
+ if (kind === "required") request.options.toolChoice = "required";
+ if (kind === "output limit") request.options.maxOutputTokens = 32;
+ if (kind === "toolCall") request.context.messages = [{ role: "assistant", content: [{ type: "toolCall", id: "call", name: "read_file", arguments: {} }], timestamp: 0 }];
+ if (kind === "toolResult") request.context.messages = [{ role: "toolResult", toolCallId: "call", toolName: "read_file", content: "result", timestamp: 0 }];
+ if (kind === "image" || kind === "audio") request.context.messages = [{ role: "user", content: [{ type: kind, data: "fixture", mimeType: "image/png" }], timestamp: 0 }] as OcxParsedRequest["context"]["messages"];
+ if (kind === "video") request.context.messages = [{ role: "user", content: [{ type: "video", videoUrl: "https://example.com/video.mp4" }], timestamp: 0 }];
+ if (kind === "document") request.context.messages = [{ role: "user", content: [{ type: "document", text: "[document: fixture]", mediaType: "application/pdf", data: "Zml4dHVyZQ==" }], timestamp: 0 }];
+ if (kind === "oversized") request.context.messages.unshift({ role: "user", content: "x".repeat(1_000_001), timestamp: 0 });
+ let spawned = false;
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), { resolveBinary: () => { spawned = true; throw new Error("must not spawn"); } })
+ .runTurn!(request, traeMeta(), event => events.push(event));
+ expect(spawned).toBe(false);
+ expect(events[0]).toMatchObject({ type: "error", status: 400 });
+ });
+ }
+
+ for (const proxy of ["http://127.0.0.1:8080", "socks5://127.0.0.1:1080", "direct", null]) {
+ test(`rejects provider proxy ${proxy} before CLI startup in turns, discovery and probes`, async () => {
+ const row = { ...config(), proxy };
+ const fixture = traeFixture();
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(row, fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events[0]).toMatchObject({ type: "error", code: "trae_proxy_unsupported", status: 400 });
+ const converge = mock(async (): Promise => ({ status: "skipped", reason: "busy", retryable: true }));
+ expect(await (await probeTraeCnProvider("trae-cn", row, converge, undefined, fixture.deps)).json()).toMatchObject({ ok: false });
+ expect(fixture.options()).toBeUndefined();
+ const open = spyOn(traeClient, "openTraeSession");
+ const result = await fetchProviderModelsWithAuth(captureProviderGather("trae-cn", row, refreshingModelsAuthResolver), 60_000, undefined, refreshingModelsAuthResolver);
+ expect(result.outcome.state).toBe("degraded");
+ expect(open).not.toHaveBeenCalled();
+ expect(converge).not.toHaveBeenCalled();
+ });
+ }
+
+ for (const phase of ["initialize", "session/prompt"]) {
+ test(`unexpected EOF during ${phase} retains late, redacted stderr`, async () => {
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== phase) return false;
+ (child.stdout as PassThrough).end();
+ child.kill = () => {
+ setTimeout(() => {
+ (child.stderr as PassThrough).write("Not logged in; run traecli login. Authorization: Bearer fixture-secret-value");
+ child.exitCode = 1;
+ child.emit("close", 1);
+ }, 1);
+ return true;
+ };
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), { ...fixture.deps, killGraceMs: 50 }).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", status: 401, code: "trae_cn_not_logged_in" });
+ expect(JSON.stringify(events)).toContain("run traecli login");
+ expect(JSON.stringify(events)).not.toContain("fixture-secret-value");
+ });
+ }
+
+ for (const withText of [false, true]) {
+ test(`refusal preserves usage without retry or success (text=${withText})`, async () => {
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ if (withText) fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session",
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Unable to help." } } } });
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "refusal", usage: { inputTokens: 10, outputTokens: 2, totalTokens: 12 } } });
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "content_filter", retryable: false, usage: { inputTokens: 10, outputTokens: 2 } });
+ expect(events.some(event => event.type === "done" || event.type === "error")).toBe(false);
+ });
+ }
+
+ test("stdin EPIPE rejects the pending RPC without an uncaught stream error", async () => {
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "initialize") return false;
+ child.stdin!.emit("error", new Error("write EPIPE"));
+ return true;
+ });
+ await expect(traeClient.openTraeSession(config().baseUrl, fixture.deps)).rejects.toThrow("EPIPE");
+ expect(fixture.child.killed).toBe(true);
+ });
+
+ test("a local ENOSPC startup failure reports its stage without arbitrary error text", async () => {
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), { resolveBinary: () => "/fixture/traecli",
+ makeTempDir: (async () => { throw Object.assign(new Error("private path and diagnostic"), { code: "ENOSPC" }); }) as typeof mkdtemp,
+ }).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "error", status: 500, code: "trae_startup_home_enospc", message: "TRAE CN startup failed during home (ENOSPC)." }]);
+ });
+
+ test("a stdout byte ceiling has a distinct safe diagnostic", async () => {
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "initialize") return false;
+ (child.stdout as PassThrough).write("x".repeat(1024 * 1024 + 1));
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "error", code: "trae_stdout_limit", message: "TRAE CN ACP stdout exceeded its size limit." }]);
+ });
+
+ for (let fragmentLength = 1; fragmentLength <= 7; fragmentLength++) {
+ test(`probe hides a ${fragmentLength}-character bearer fragment at the parser boundary`, async () => {
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "initialize") return false;
+ (child.stdout as PassThrough).write("x".repeat(64 - 7 - fragmentLength) + "Bearer abcdefghi\n");
+ return true;
+ });
+ const response = await probeTraeCnProvider("trae-cn", config(), async () => { throw new Error("must not converge"); }, undefined, fixture.deps);
+ expect(await response.json()).toMatchObject({ ok: false, error: "TRAE CN ACP returned malformed or unsupported protocol data." });
+ });
+ }
+
+ test("malformed prompt output never exposes parser snippets to the client", async () => {
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "session/prompt") return false;
+ (child.stdout as PassThrough).write("x".repeat(50) + "Bearer abcdefghi\n");
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "heartbeat", replayUnsafe: true }, { type: "error", message: "TRAE CN ACP ended with an invalid protocol response.", retryable: false }]);
+ });
+
+ test("an upstream cancellation without client abort emits a terminal event and usage", async () => {
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "cancelled", usage: { inputTokens: 3, outputTokens: 0, totalTokens: 3 } } });
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "heartbeat", replayUnsafe: true }, { type: "incomplete", reason: "upstream_cancelled", retryable: false, usage: { inputTokens: 3, outputTokens: 0 } }]);
+ });
+
+ for (const selected of ["test-model", "unexpected-model"]) {
+ test(`post-selection model notification validates ${selected}`, async () => {
+ const fixture = traeFixture(request => {
+ if (request.method === "session/prompt") fixture.send({ jsonrpc: "2.0", method: "session/update",
+ params: { sessionId: "fixture-session", update: { sessionUpdate: "current_model_update", currentModelId: selected } } });
+ return false;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject(selected === "test-model" ? { type: "done" } : { type: "error", code: "trae_model_changed" });
+ });
+ }
+
+ for (const initialBeforeReply of [true, false]) for (const selectionNotification of [true, false]) {
+ test("initial model notifications are superseded by set_model: " + initialBeforeReply + "/" + selectionNotification, async () => {
+ const modelUpdate = (model: string) => ({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session",
+ update: { sessionUpdate: "current_model_update", currentModelId: model } } });
+ const fixture = traeFixture(request => {
+ if (request.method === "session/new") {
+ if (initialBeforeReply) fixture.send(modelUpdate("default-model"));
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { sessionId: "fixture-session", models: {
+ availableModels: [{ modelId: "default-model" }, { modelId: "test-model" }], currentModelId: "default-model",
+ } } });
+ if (!initialBeforeReply) fixture.send(modelUpdate("default-model"));
+ return true;
+ }
+ if (request.method === "session/set_model" && selectionNotification) fixture.send(modelUpdate("test-model"));
+ return false;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "done" });
+ expect(events.some(event => event.type === "error")).toBe(false);
+ expect(fixture.requests.filter(request => request.method === "session/set_model").map(request => request.params))
+ .toEqual([{ sessionId: "fixture-session", modelId: "test-model" }]);
+ expect(fixture.requests.filter(request => request.method === "session/prompt")).toHaveLength(1);
+ });
+ }
+
+ test("relative PATH entries resolve before the CLI changes its working directory", () => {
+ expect(resolveTraeCli({ platform: "linux", env: { PATH: "./node_modules/.bin" }, exists: () => true }))
+ .toBe(posix.resolve("./node_modules/.bin/traecli"));
+ });
+
+ for (const kind of ["tool_call", "session/request_permission"]) {
+ test(`server ${kind} is rejected and the session is closed`, async () => {
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send(kind === "tool_call"
+ ? { jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: { sessionUpdate: kind } } }
+ : { jsonrpc: "2.0", id: 99, method: kind, params: {} });
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.some(event => event.type === "error")).toBe(true);
+ expect(events.some(event => event.type === "done")).toBe(false);
+ expect(fixture.child.killed).toBe(true);
+ });
+ }
+
+ test("tool refusal retains usage from an already received prompt reply", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } });
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session",
+ update: { sessionUpdate: "tool_call" } } });
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_tool_call_unsupported", usage });
+ expect(buildResponseJSON(events, "test-model").usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+
+ test("tool refusal drains a same-chunk prompt reply that follows the notification", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "session/prompt") return false;
+ const frames = [
+ { jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session",
+ update: { sessionUpdate: "tool_call" } } },
+ { jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } },
+ ];
+ (child.stdout as PassThrough).write(frames.map(frame => JSON.stringify(frame) + "\n").join(""));
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_tool_call_unsupported", usage });
+ expect(buildResponseJSON(events, "test-model").usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+
+ test("malformed update refusal retains usage from an already received prompt reply", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } });
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "agent_message_chunk", content: { type: "image", data: "fixture" },
+ } } });
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_acp_protocol_error", usage });
+ expect(buildResponseJSON(events, "test-model").usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+
+ test("malformed update refusal drains a same-chunk prompt reply that follows it", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "session/prompt") return false;
+ const frames = [
+ { jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "agent_message_chunk", content: { type: "image", data: "fixture" },
+ } } },
+ { jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } },
+ ];
+ (child.stdout as PassThrough).write(frames.map(frame => JSON.stringify(frame) + "\n").join(""));
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_acp_protocol_error", usage });
+ expect(buildResponseJSON(events, "test-model").usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+
+ for (const trailing of [
+ { name: "permission request", code: "trae_client_request_unsupported", frame: {
+ jsonrpc: "2.0", id: 99, method: "session/request_permission", params: {},
+ } },
+ { name: "unexpected model update", code: "trae_model_changed", frame: {
+ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "current_model_update", currentModelId: "unexpected-model",
+ } },
+ } },
+ { name: "malformed envelope", code: "trae_acp_protocol_error", frame: {
+ jsonrpc: "1.0", id: 100, result: {},
+ } },
+ ]) {
+ test("trailing " + trailing.name + " supersedes success and retains usage", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "partial" },
+ } } });
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } });
+ fixture.send(trailing.frame);
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: trailing.code, usage });
+ expect(events.some(event => event.type === "done")).toBe(false);
+ expect(buildResponseJSON(events, "test-model").usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+ }
+
+ test("trailing stdout EOF supersedes success and retains usage", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "partial" },
+ } } });
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } });
+ child.stdout!.end();
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_acp_closed", usage });
+ expect(events.some(event => event.type === "done")).toBe(false);
+ expect(buildResponseJSON(events, "test-model").usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+
+ test("shutdown remains bounded when the process and its pipe never close", async () => {
+ const fixture = traeFixture(request => request.method === "session/prompt");
+ fixture.child.kill = () => true;
+ const allocated: string[] = [];
+ const makeTempDir = (async () => {
+ const directory = await mkdtemp(testHome.path("owned-"));
+ allocated.push(directory);
+ return directory;
+ }) as typeof mkdtemp;
+ const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, makeTempDir });
+ const turn = session.prompt("test-model", "hello", () => undefined).catch(error => error);
+ await expect(session.close()).rejects.toThrow("shutdown was not confirmed");
+ expect(await turn).toBeInstanceOf(Error);
+ expect(fixture.child.stdout!.destroyed).toBe(true);
+ expect(existsSync(String(fixture.options()!.cwd))).toBe(true);
+ expect(allocated).toHaveLength(2);
+ expect(allocated.every(directory => existsSync(directory))).toBe(true);
+ });
+
+ test("a cleanup failure preserves already-received terminal usage", async () => {
+ const fixture = traeFixture();
+ fixture.child.kill = () => true;
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), { ...fixture.deps, home: testHome.root, cwd: testHome.root })
+ .runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_shutdown_timeout", usage: { inputTokens: 10, outputTokens: 2 } });
+ expect(events.some(event => event.type === "done")).toBe(false);
+ });
+
+ test("cleanup timeout preserves usage from a thrown reader failure", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } });
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "current_model_update", currentModelId: "unexpected-model",
+ } } });
+ return true;
+ });
+ fixture.child.kill = () => true;
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), { ...fixture.deps, home: testHome.root, cwd: testHome.root })
+ .runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_shutdown_timeout", usage });
+ expect(events.some(event => event.type === "done")).toBe(false);
+ expect(buildResponseJSON(events, "test-model").usage)
+ .toMatchObject({ input_tokens: 123, output_tokens: 456, total_tokens: 579 });
+ });
+
+ test("inner EOF cleanup timeout preserves received usage", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn", usage } });
+ child.stdout!.end();
+ return true;
+ });
+ fixture.child.kill = () => true;
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), { ...fixture.deps, home: testHome.root, cwd: testHome.root })
+ .runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_shutdown_timeout", usage });
+ expect(events.some(event => event.type === "done")).toBe(false);
+ });
+
+ test("dispatch-budget refusal outranks cleanup timeout", async () => {
+ spyOn(traeClient, "openTraeSession").mockResolvedValue({
+ models: ["test-model"],
+ prompt: async (_model, _text, _emit, _signal, dispatch) => { dispatch?.beforeSend(); },
+ close: async () => { throw new traeClient.TraeAcpError("fixture cleanup", "trae_shutdown_timeout"); },
+ });
+ const budget = fakeSendBudget({ useAllowed: false });
+ await expect(createTraeCnAdapter(config()).runTurn!(traeRequest(), traeMeta({ sendBudget: budget.budget }),
+ () => undefined)).rejects.toBeInstanceOf(SendBudgetExhaustedError);
+ });
+
+ for (const stopReason of ["end_turn", "max_tokens", "max_turn_requests", "fixture_unknown_stop"]) {
+ test("a reasoning-only completion preserves its reason and usage: " + stopReason, async () => {
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "thinking" },
+ } } });
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason, usage: { inputTokens: 123, outputTokens: 456, totalTokens: 579 } } });
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ const terminal = events.at(-1)!;
+ expect(terminal).toMatchObject({ usage: { inputTokens: 123, outputTokens: 456, totalTokens: 579 } });
+ if (stopReason === "max_tokens") {
+ expect(terminal).toMatchObject({ type: "done", stopReason: "max_tokens" });
+ expect(buildResponseJSON(events, "test-model")).toMatchObject({ status: "incomplete", incomplete_details: { reason: "max_output_tokens" } });
+ } else if (stopReason === "max_turn_requests") {
+ expect(terminal).toMatchObject({ type: "incomplete", reason: "max_turn_requests", retryable: false });
+ expect(buildResponseJSON(events, "test-model")).toMatchObject({ status: "incomplete", incomplete_details: { reason: "max_turn_requests" } });
+ } else {
+ const code = stopReason === "end_turn" ? "trae_empty_response" : "trae_acp_protocol_error";
+ expect(terminal).toMatchObject({ type: "error", code, retryable: false });
+ expect(events.some(event => event.type === "done")).toBe(false);
+ }
+ });
+ }
+
+ test("a request-limit terminal retains partial text and usage in JSON and SSE", async () => {
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: {
+ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "partial answer" },
+ } } });
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "max_turn_requests", usage: { inputTokens: 10, outputTokens: 2, totalTokens: 12 } } });
+ return true;
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ const json = buildResponseJSON(events, "test-model");
+ expect(json).toMatchObject({ status: "incomplete", incomplete_details: { reason: "max_turn_requests" }, usage: { input_tokens: 10, output_tokens: 2 } });
+ expect(JSON.stringify(json.output)).toContain("partial answer");
+ const budget = createTranslatorBudget();
+ async function* source() { yield* events; }
+ try {
+ const wire = await new Response(bridgeToResponsesSSE(source(), "test-model", undefined, undefined, undefined, undefined, 2000, { translatorBudget: budget })).text();
+ const frames = wire.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6)));
+ expect(frames.find(frame => frame.type === "response.incomplete")?.response)
+ .toMatchObject({ status: "incomplete", incomplete_details: { reason: "max_turn_requests" }, usage: { input_tokens: 10, output_tokens: 2 } });
+ expect(frames.filter(frame => frame.type === "response.output_text.delta").map(frame => frame.delta).join(""))
+ .toBe("partial answer");
+ } finally { budget.dispose(); }
+ });
+
+ for (const consumer of ["adapter", "probe"]) {
+ test("stderr overflow suppresses a split authorization value in " + consumer, async () => {
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "initialize") return false;
+ const prefix = "Authorization: Bearer ";
+ (child.stderr as PassThrough).write("x".repeat(8192 - prefix.length) + prefix);
+ (child.stderr as PassThrough).write("opaque-fixture-credential\n");
+ (child.stdout as PassThrough).end();
+ return true;
+ });
+ let serialized: string;
+ if (consumer === "adapter") {
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ serialized = JSON.stringify(events);
+ } else serialized = JSON.stringify(await (await probeTraeCnProvider("trae-cn", config(), async () => { throw new Error("must not converge"); }, undefined, fixture.deps)).json());
+ expect(serialized).toContain("contents were suppressed");
+ expect(serialized).not.toContain("opaque-fixture-credential");
+ });
+ }
+
+ for (const removed of [true, false]) {
+ test.skipIf(process.platform === "win32")("POSIX lookup-to-launch failure hides paths: removed=" + removed, async () => {
+ const binary = testHome.path("private cli");
+ writeFileSync(binary, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
+ const deps: traeClient.TraeAcpDeps = { home: testHome.root, cwd: testHome.root, killGraceMs: 20,
+ resolveBinary: () => { if (removed) unlinkSync(binary); else chmodSync(binary, 0o600); return binary; } };
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events).toMatchObject([{ type: "error", status: 500, code: "trae_startup_spawn_" + (removed ? "enoent" : "eacces") }]);
+ expect(JSON.stringify(events)).not.toContain(binary);
+ });
+ }
+
+ test("POSIX shutdown uses the owned pipe without signalling a possibly reused PID", async () => {
+ const fixture = traeFixture();
+ Object.assign(fixture.child, { pid: 424242 });
+ const signalGroup = spyOn(process, "kill").mockImplementation(() => { throw new Error("must not signal PID"); });
+ const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, platform: "linux" });
+ await session.close();
+ expect(signalGroup).not.toHaveBeenCalled();
+ expect(traeStopPipe(fixture.child)!.destroyed).toBe(true);
+ });
+
+ (process.platform === "win32" ? test.skip : test)("the live supervisor terminates a TERM-ignoring descendant after the CLI exits", async () => {
+ const heartbeat = testHome.path("descendant-heartbeat");
+ const worker = "const fs=require('node:fs');process.on('SIGTERM',()=>{});const beat=()=>fs.writeFileSync(" + JSON.stringify(heartbeat) + ",String(Date.now()));beat();setInterval(beat,10);setTimeout(()=>process.exit(0),5000);";
+ const cli = "const p=require('node:child_process').spawn(process.execPath,['-e'," + JSON.stringify(worker) + "],{stdio:'ignore'});p.unref();process.stdout.write(String(p.pid));";
+ const invocation = traePosixInvocation(process.execPath, ["-e", cli], 20);
+ const child = nodeSpawn(invocation.file, invocation.args, { detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"] });
+ const closed = once(child, "close");
+ const watchdog = setTimeout(() => { traeStopPipe(child)?.end("stop\n"); }, 2000);
+ try {
+ let output = "";
+ for await (const chunk of child.stdout!) output += String(chunk);
+ expect(Number(output)).toBeGreaterThan(0);
+ expect(child.exitCode).toBeNull();
+ const until = Date.now() + 1000;
+ while (!existsSync(heartbeat) && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 10));
+ const before = readFileSync(heartbeat, "utf8");
+ await new Promise(resolve => setTimeout(resolve, 30));
+ expect(readFileSync(heartbeat, "utf8")).not.toBe(before);
+ traeStopPipe(child)!.end("stop\n");
+ await closed;
+ const after = readFileSync(heartbeat, "utf8");
+ await new Promise(resolve => setTimeout(resolve, 50));
+ expect(readFileSync(heartbeat, "utf8")).toBe(after);
+ } finally {
+ clearTimeout(watchdog);
+ traeStopPipe(child)?.end();
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
+ await closed;
+ }
+ }, 5000);
+
+ test("a long pending prompt does not retain completion reactions for consumed chunks", async () => {
+ let ready!: () => void;
+ const drained = new Promise(resolve => { ready = resolve; });
+ let finish!: () => void;
+ const release = new Promise(resolve => { finish = resolve; });
+ let before = 0;
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/prompt") return false;
+ Bun.gc(true);
+ before = process.memoryUsage().heapUsed;
+ void (async () => {
+ for (let i = 0; i < 100_000; i++) {
+ fixture.send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fixture-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "x" } } } });
+ if (i % 128 === 0) await new Promise(resolve => setImmediate(resolve));
+ }
+ await release;
+ fixture.send({ jsonrpc: "2.0", id: request.id, result: { stopReason: "end_turn" } });
+ })();
+ return true;
+ });
+ let chunks = 0;
+ const turn = createTraeCnAdapter(config(), { ...fixture.deps, timeoutMs: 12_000 }).runTurn!(traeRequest(), traeMeta(), event => {
+ if (event.type === "text_delta" && ++chunks === 100_000) ready();
+ });
+ let growth = 0;
+ try {
+ await Promise.race([drained, turn.then(() => { if (chunks !== 100_000) throw new Error("stream ended before the fixture drained"); })]);
+ Bun.gc(true);
+ growth = process.memoryUsage().heapUsed - before;
+ } finally { finish(); await turn; }
+ expect(chunks).toBe(100_000);
+ expect(growth).toBeLessThan(16 * 1024 * 1024);
+ }, 15000);
+
+ for (const proof of [true, false]) {
+ test("Windows cleanup requires Job accounting proof: " + proof, async () => {
+ const fixture = traeFixture();
+ fixture.child.once("trae-stop-request", () => {
+ if (proof) (fixture.child.stderr as PassThrough).write("OCX_TRAE_JOB_STOPPED\n");
+ fixture.child.kill();
+ });
+ const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, platform: "win32", home: testHome.root, cwd: testHome.root });
+ if (proof) await session.close();
+ else await expect(session.close()).rejects.toThrow("shutdown was not confirmed");
+ expect(fixture.options()?.windowsVerbatimArguments).toBeUndefined();
+ });
+ }
+
+ test("Windows CLI diagnostics cannot forge supervisor proof, even with split frames", () => {
+ let text = "";
+ const frames = new TraeWindowsJobFrames(chunk => { text += chunk; }, () => { throw new Error("unexpected status"); });
+ const encoded = "OCX_TRAE_STDERR:" + Buffer.from("OCX_TRAE_JOB_STOPPED\n中文").toString("base64") + "\n";
+ for (const byte of Buffer.from(encoded)) frames.push(Uint8Array.of(byte));
+ expect(text).toBe("OCX_TRAE_JOB_STOPPED\n中文");
+ expect(frames.processTreeExited).toBe(false);
+ frames.push(Buffer.from("OCX_TRAE_JOB_STOPPED\n"));
+ frames.finish();
+ expect(frames.processTreeExited).toBe(true);
+ const invalid = new TraeWindowsJobFrames(() => {}, () => {});
+ invalid.push(Buffer.from("x".repeat(5000) + "\nOCX_TRAE_JOB_STOPPED\n"));
+ expect(invalid.processTreeExited).toBe(false);
+ });
+
+ test("Windows cleanup stays bounded without a responding supervisor or status pipe", async () => {
+ const fixture = traeFixture();
+ const kill = spyOn(fixture.child, "kill").mockReturnValue(false);
+ const session = await traeClient.openTraeSession(config().baseUrl, {
+ ...fixture.deps, platform: "win32", home: testHome.root, cwd: testHome.root,
+ });
+ const started = performance.now();
+ await expect(session.close()).rejects.toThrow("shutdown was not confirmed");
+ expect(performance.now() - started).toBeLessThan(1000);
+ expect(kill).toHaveBeenCalledWith("SIGKILL");
+ expect(fixture.child.stdout!.destroyed).toBe(true);
+ expect(fixture.child.stderr!.destroyed).toBe(true);
+ });
+
+ test("a Windows stdout forwarding failure reports transport failure and closes the Job", async () => {
+ const fixture = traeFixture((request, child) => {
+ if (request.method !== "session/prompt") return false;
+ (child.stderr as PassThrough).write("OCX_TRAE_ERROR:transport:0\n");
+ return true;
+ });
+ fixture.child.once("trae-stop-request", () => {
+ (fixture.child.stderr as PassThrough).write("OCX_TRAE_JOB_STOPPED\n");
+ fixture.child.kill();
+ });
+ const events: AdapterEvent[] = [];
+ await createTraeCnAdapter(config(), { ...fixture.deps, platform: "win32", home: testHome.root, cwd: testHome.root })
+ .runTurn!(traeRequest(), traeMeta(), event => events.push(event));
+ expect(events.at(-1)).toMatchObject({ type: "error", code: "trae_acp_transport", status: 502,
+ message: "TRAE CN Windows supervisor failed during transport (Win32 0)." });
+ expect(fixture.child.killed).toBe(true);
+ expect(events.some(event => event.type === "done")).toBe(false);
+ });
+
+ test("Windows launcher preserves cmd escaping and owns only its staging directory", async () => {
+ const env = { COMSPEC: "D:\\Windows\\System32\\cmd.exe", SYSTEMROOT: "D:\\Windows" };
+ const invocation = commandInvocation("C:\\Users\\test\\trae cli.cmd", ["a&b", "with space"], "win32", { env });
+ expect(invocation.file).toBe("cmd.exe");
+ expect(traeWindowsJobExecutable(invocation.file, env)).toBe(env.COMSPEC);
+ expect(traeWindowsJobExecutable("cmd.exe", { SYSTEMROOT: "D:\\Windows" })).toBe("D:\\Windows\\System32\\cmd.exe");
+ expect(() => traeWindowsJobExecutable("unknown.exe", env)).toThrow("absolute executable path");
+ expect(traeWindowsCommandLine(invocation)).toEndWith(invocation.args.join(" "));
+ const job = await prepareTraeWindowsJob(invocation, testHome.root, testHome.root, 2000, env);
+ try {
+ expect(job.options.windowsVerbatimArguments).toBeUndefined();
+ expect(job.file).toBe("D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
+ const loader = Buffer.from(job.args.at(-1)!, "base64").toString("utf16le");
+ expect(loader).not.toContain("ExecutionPolicy");
+ expect(loader).toContain("Add-Type");
+ expect(loader).toContain(env.COMSPEC);
+ expect(loader.length).toBeLessThan(10000);
+ } finally { await job.cleanup(); }
+ expect(existsSync(testHome.root)).toBe(true);
+ });
+
+ for (const stop of ["exit", "marker", "eof"]) for (const inheritedPipes of [true, false]) {
+ test.skipIf(process.platform !== "win32")("Windows Job cleanup: stop=" + stop + ", inherited pipes=" + inheritedPipes, async () => {
+ const heartbeat = testHome.path("heartbeat");
+ const dispatcher = testHome.path("dispatcher ready");
+ const argv = testHome.path("argv.json");
+ const script = testHome.path("cli fixture.mjs");
+ const shim = testHome.path("cli fixture.cmd");
+ const helper = "const fs=require('node:fs');setInterval(()=>fs.writeFileSync(" + JSON.stringify(heartbeat) + ",String(Date.now())),10);";
+ writeFileSync(script, [
+ "import {spawn} from 'node:child_process';import fs from 'node:fs';",
+ "fs.writeFileSync(" + JSON.stringify(argv) + ",JSON.stringify(process.argv.slice(2)));",
+ "spawn(process.execPath,['-e'," + JSON.stringify(helper) + "],{stdio:" + JSON.stringify(inheritedPipes ? ["ignore", "inherit", "inherit"] : "ignore") + "}).unref();",
+ "while(!fs.existsSync(" + JSON.stringify(heartbeat) + ")) await new Promise(r=>setTimeout(r,10));",
+ "fs.writeFileSync(" + JSON.stringify(dispatcher) + ",'ready');" + (stop === "exit" ? "process.exit(0);" : "setInterval(()=>{},1000);"),
+ ].join("\n"));
+ writeFileSync(shim, '@"' + process.execPath + '" "' + script + '" %*\r\n');
+ const env = traeCnChildEnv(process.env, "win32");
+ const args = ["acp", "serve", "a&b", "with space"];
+ const invocation = commandInvocation(shim, args, "win32", { env });
+ const job = await prepareTraeWindowsJob(invocation, testHome.root, testHome.root, 2000, env);
+ const child = nodeSpawn(job.file, job.args, { ...job.options, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
+ const closed = once(child, "close");
+ let diagnostic = "";
+ const failures: string[] = [];
+ const frames = new TraeWindowsJobFrames(text => { diagnostic += text; }, (stage, code) => { failures.push(stage + ":" + code); });
+ child.stdout!.resume();
+ child.stderr!.on("data", chunk => frames.push(chunk));
+ child.stdin!.on("error", () => {});
+ child.stdin!.write("{}\n");
+ const watchdog = setTimeout(() => { child.kill("SIGKILL"); }, 15_000);
+ try {
+ if (stop !== "exit") {
+ const readyDeadline = Date.now() + 10_000;
+ while (!existsSync(dispatcher) && child.exitCode === null && Date.now() < readyDeadline) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ expect(existsSync(dispatcher)).toBe(true);
+ expect(child.exitCode).toBeNull();
+ const started = performance.now();
+ // Keep stdin open for the marker case so EOF cannot hide a broken marker reader.
+ if (stop === "marker") child.stdin!.write("__OCX_STOP_TRAE__\n");
+ else child.stdin!.end();
+ await closed;
+ expect(performance.now() - started).toBeLessThan(5000);
+ }
+ await closed;
+ frames.finish();
+ expect(failures).toEqual([]);
+ expect(diagnostic).toBe("");
+ expect(frames.processTreeExited).toBe(true);
+ expect(existsSync(dispatcher)).toBe(true);
+ expect(JSON.parse(readFileSync(argv, "utf8"))).toEqual(args);
+ const after = readFileSync(heartbeat, "utf8");
+ await new Promise(resolve => setTimeout(resolve, 100));
+ expect(readFileSync(heartbeat, "utf8")).toBe(after);
+ } finally {
+ clearTimeout(watchdog);
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
+ child.stdin?.destroy();
+ await closed.catch(() => undefined);
+ await job.cleanup();
+ }
+ }, 20_000);
+ }
+
+ test("workspace allocation failure removes only an owned temporary HOME", async () => {
+ let owned = "";
+ let calls = 0;
+ const makeTempDir = (async (prefix: string) => {
+ if (calls++) throw new Error("workspace unavailable");
+ owned = await mkdtemp(prefix);
+ return owned;
+ }) as typeof mkdtemp;
+ await expect(traeClient.openTraeSession(config().baseUrl, { resolveBinary: () => "/fixture/traecli", makeTempDir })).rejects.toThrow("startup failed during workspace");
+ expect(existsSync(owned)).toBe(false);
+ await expect(traeClient.openTraeSession(config().baseUrl, { home: testHome.root, resolveBinary: () => "/fixture/traecli",
+ makeTempDir: (async () => { throw new Error("workspace unavailable"); }) as typeof mkdtemp,
+ })).rejects.toThrow("startup failed during workspace");
+ expect(existsSync(testHome.root)).toBe(true);
+ });
+
+ test("TRAE_HOME is inherited without changing the parent environment", () => {
+ const previous = process.env.TRAE_HOME;
+ try {
+ process.env.TRAE_HOME = testHome.path("selected-trae-home");
+ expect(traeCnChildEnv().TRAE_HOME).toBe(process.env.TRAE_HOME);
+ } finally { if (previous === undefined) delete process.env.TRAE_HOME; else process.env.TRAE_HOME = previous; }
+ });
+
+ test("the default CLI directory survives HOME isolation on POSIX and Windows", () => {
+ expect(traeCnChildEnv({ HOME: "/fixture/operator" }, "linux").TRAE_HOME).toBe("/fixture/operator/.trae");
+ expect(traeCnChildEnv({ USERPROFILE: "C:\\Users\\operator" }, "win32").TRAE_HOME).toBe("C:\\Users\\operator\\.trae");
+ expect(traeCnChildEnv({ HOME: "/fixture/operator", TRAE_HOME: "./selected-trae" }, "linux").TRAE_HOME)
+ .toBe(posix.resolve("./selected-trae"));
+ });
+
+ for (const platform of ["linux", "win32"] as const) {
+ test("a relative user-local CLI fallback resolves before changing cwd: " + platform, () => {
+ const pathApi = platform === "win32" ? win32 : posix;
+ const expected = pathApi.resolve("relative-user", ".local", "bin", platform === "win32" ? "traecli.exe" : "traecli");
+ const result = resolveTraeCli({ platform, env: { PATH: "", HOME: "relative-user", USERPROFILE: "relative-user" }, exists: path => path === expected });
+ expect(result).toBe(expected);
+ expect(traeCnChildEnv({ HOME: "relative-user", USERPROFILE: "relative-user" }, platform).TRAE_HOME)
+ .toBe(pathApi.resolve("relative-user", ".trae"));
+ if (platform === "win32") expect(traeWindowsJobExecutable(result!, {})).toBe(expected);
+ });
+ }
+
+ for (const binaries of [["traecli.cmd"], ["traecli.bat"], ["traecli.exe", "traecli.cmd"]]) {
+ test("Windows home fallback keeps native/launcher priority: " + binaries.join(","), () => {
+ const root = "C:\\Users\\fixture";
+ const available = binaries.map(name => win32.join(root, ".local", "bin", name));
+ const found = resolveTraeCli({ platform: "win32", env: { USERPROFILE: root, PATH: "" }, exists: path => available.includes(path) });
+ expect(found).toBe(available[0]);
+ });
+ }
+
+ test("static catalogs never open ACP and retain unknown text-only models", async () => {
+ const open = spyOn(traeClient, "openTraeSession").mockRejectedValue(new Error("must not discover"));
+ const row = { ...config(), models: ["future-model"], liveModels: false,
+ modelCapabilities: { "future-model": { inputModalities: ["text", "image"], supportsVerbosity: true } },
+ modelInputModalities: { "future-model": ["text", "image"] } };
+ const captured = captureProviderGather("trae-cn", row, refreshingModelsAuthResolver);
+ const result = await fetchProviderModelsWithAuth(captured, 60_000, undefined, refreshingModelsAuthResolver);
+ expect(open).not.toHaveBeenCalled();
+ expect(result.outcome.state).toBe("authoritative");
+ expect(result.models).toMatchObject([{ id: "future-model", inputModalities: ["text"], supportsVerbosity: false }]);
+ expect(isVisionSidecarConsumer({ providers: { "trae-cn": row } }, "trae-cn", "future-model")).toBe(true);
+ expect(isModelVisionSidecarConsumer(row, "future-model")).toBe(true);
+ const visionConfig = { providers: { "trae-cn": row }, customModels: [{ provider: "trae-cn", modelId: "future-model", inputModalities: ["text", "image"] }] };
+ expect(modelAcceptsImageInput(visionConfig, { provider: "trae-cn", id: "future-model", inputModalities: ["text", "image"] })).toBe(false);
+ expect(requiresVisionPreprocessing(visionConfig, row, "future-model", "trae-cn")).toBe(true);
+ });
+
+ test("a custom image row cannot override the TRAE wire without per-model capability hints", () => {
+ const row = config();
+ const visionConfig = { providers: { "trae-cn": row }, customModels: [{ provider: "trae-cn", modelId: "test-model", inputModalities: ["text", "image"] }] };
+ expect(customRowInputModalities(visionConfig, "trae-cn", "test-model")).toEqual(["text", "image"]);
+ expect(isVisionSidecarConsumer(visionConfig, "trae-cn", "test-model")).toBe(true);
+ expect(modelAcceptsImageInput(visionConfig, { provider: "trae-cn", id: "test-model" })).toBe(false);
+ expect(requiresVisionPreprocessing(visionConfig, row, "test-model", "trae-cn")).toBe(true);
+ });
+
+ for (const enabled of [false, true]) {
+ test("the final custom catalog row applies the wire ceiling before sidecar publication: " + enabled, async () => {
+ const models = await gatherRoutedModels({ port: 10100, defaultProvider: "trae-cn",
+ providers: { "trae-cn": { ...config(), liveModels: false } }, visionSidecar: { enabled },
+ customModels: [{ provider: "trae-cn", modelId: "test-model", inputModalities: ["text", "image"] }],
+ combos: { selected: { targets: [{ provider: "trae-cn", model: "test-model" }] } },
+ });
+ expect(models.find(model => model.provider === "trae-cn" && model.id === "test-model"))
+ .toMatchObject({ inputModalities: enabled ? ["text", "image"] : ["text"], supportsVerbosity: false });
+ expect(buildCatalogEntries(null, [], models).find(model => model.slug === "combo/selected")?.input_modalities)
+ .toEqual(enabled ? ["text", "image"] : ["text"]);
+ });
+ }
+
+ test("management admission stays cache-only while an independent source refresh prepares the next roster", async () => {
+ const fixture = traeFixture();
+ const realOpen = traeClient.openTraeSession;
+ const open = spyOn(traeClient, "openTraeSession").mockImplementation((baseUrl, _deps, signal) => realOpen(baseUrl, fixture.deps, signal));
+ const admittedRosters: unknown[] = [];
+ const admission = spyOn(catalogAdmission, "captureCatalogAdmissionSnapshot").mockImplementation(() => {
+ admittedRosters.push(getFreshCached("trae-cn", 60_000, Date.now())?.map(model => model.id) ?? null);
+ throw new Error("fixture stops before any catalog write");
+ });
+ const next = { port: 10100, defaultProvider: "trae-cn", providers: { "trae-cn": { ...config(), models: [] } } };
+ const converge = managementConvergence.createManagementConvergeCodex(next);
+ await converge(catalogAdmission.createCatalogConvergeRequest({ deadlineMs: 1000 }));
+ expect(open).not.toHaveBeenCalled();
+ expect(admittedRosters).toEqual([null]);
+ await refreshTraeCnCatalogSources(next);
+ await converge(catalogAdmission.createCatalogConvergeRequest({ deadlineMs: 1000 }));
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(admission).toHaveBeenCalledTimes(2);
+ expect(admittedRosters).toEqual([null, ["test-model"]]);
+ });
+
+ test("background discovery retains authoritative roster evidence through TTL-zero convergence", async () => {
+ const fixture = traeFixture();
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["fresh-roster"],
+ prompt: async () => undefined, close: async () => undefined });
+ const row = { ...config(), models: [] };
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-cn", modelCacheTtlMs: 0, providers: { "trae-cn": row } };
+ let pinned = false;
+ await refreshTraeCnCatalogSources(next, () => true, async () => {
+ pinned = getPinnedCached("trae-cn")?.some(model => model.id === "fresh-roster") === true;
+ const resolver = observedModelsAuthResolver(null, []);
+ const gathered = await fetchProviderModelsWithAuth(captureProviderGather("trae-cn", row, resolver), 0, undefined, resolver);
+ expect(gathered.outcome.state).toBe("authoritative");
+ expect(gathered.models.some(model => model.id === "fresh-roster")).toBe(true);
+ });
+ expect(pinned).toBe(true);
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(getPinnedCached("trae-cn")).toBeNull();
+ });
+
+ test("background publication does not renew a fresh roster TTL", async () => {
+ const row = { ...config(), models: [] };
+ const ttl = 2 * 60 * 60 * 1000;
+ const fetchedAt = Date.now() - 60 * 60 * 1000;
+ setCached("trae-cn", [{ id: "cached-model", provider: "trae-cn" }], fetchedAt,
+ captureModelCacheGeneration("trae-cn"));
+ const open = spyOn(traeClient, "openTraeSession");
+ let published = false;
+ await refreshTraeCnCatalogSources({ port: 10100, defaultProvider: "trae-cn", modelCacheTtlMs: ttl,
+ providers: { "trae-cn": row } }, () => true, async () => { published = true; });
+ expect(published).toBe(true);
+ expect(open).not.toHaveBeenCalled();
+ expect(getFreshCached("trae-cn", ttl, fetchedAt + ttl + 1)).toBeNull();
+ });
+
+ test("background publication pins the authoritative roster after ordinary cache eviction", async () => {
+ await withProbeMemory(false, async evictions => {
+ const open = pressureSession();
+ const row = { ...config(), models: ["configured-fallback"] };
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-cn", providers: { "trae-cn": row } };
+ let published = false;
+ await refreshTraeCnCatalogSources(next, () => true, async () => {
+ published = getPinnedCached("trae-cn")?.some(model => model.id === "new-probed-model") === true;
+ });
+ expect(published).toBe(true);
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(evictions()).toBe(1);
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ });
+ });
+
+ test("background publication retains multiple rosters under ordinary cache pressure", async () => {
+ await withProbeMemory(false, async evictions => {
+ let calls = 0;
+ const open = spyOn(traeClient, "openTraeSession").mockImplementation(async () => ({
+ models: [++calls === 1 ? "first-model" : "second-model"],
+ prompt: async () => undefined, close: async () => undefined,
+ }));
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-a",
+ providers: { "trae-a": { ...config(), models: [] }, "trae-b": { ...config(), models: [] } } };
+ let rosters: string[][] = [];
+ await refreshTraeCnCatalogSources(next, () => true, async () => {
+ rosters = [getPinnedCached("trae-a")!.map(model => model.id), getPinnedCached("trae-b")!.map(model => model.id)];
+ });
+ expect(rosters).toEqual([["first-model"], ["second-model"]]);
+ expect(open).toHaveBeenCalledTimes(2);
+ expect(evictions()).toBe(1);
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ });
+ });
+
+ test("background source exceptions release an earlier roster pin", async () => {
+ const open = pressureSession();
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-a",
+ providers: { "trae-a": { ...config(), models: [] }, "trae-b": { ...config(), models: [] } } };
+ let checks = 0;
+ await expect(refreshTraeCnCatalogSources(next, () => {
+ if (++checks === 3) throw new Error("fixture current failure");
+ return true;
+ }, async () => undefined)).rejects.toThrow("fixture current failure");
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(getPinnedCached("trae-a")).toBeNull();
+ expect(getPinnedCached("trae-b")).toBeNull();
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ });
+
+ test("background publication cannot pin a roster replaced after gather", async () => {
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["old-model"],
+ prompt: async () => undefined, close: async () => undefined });
+ const row = { ...config(), models: [] };
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-cn", providers: { "trae-cn": row } };
+ let checks = 0;
+ let published = false;
+ await refreshTraeCnCatalogSources(next, () => {
+ if (++checks === 2) {
+ setCached("trae-cn", [{ id: "new-model", provider: "trae-cn" }], Date.now(),
+ captureModelCacheGeneration("trae-cn"));
+ }
+ return true;
+ }, async () => { published = true; });
+ const resolver = observedModelsAuthResolver(null, []);
+ const observed = await fetchProviderModelsWithAuth(captureProviderGather("trae-cn", row, resolver),
+ 0, undefined, resolver);
+ expect(published).toBe(false);
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(observed.models.map(model => model.id)).toContain("new-model");
+ expect(observed.models.map(model => model.id)).not.toContain("old-model");
+ expect(getPinnedCached("trae-cn")).toBeNull();
+ });
+
+ test("concurrent refreshing discovery publishes in serialized session order", async () => {
+ const row = { ...config(), models: [] };
+ let calls = 0;
+ let entered!: () => void;
+ const firstCloseStarted = new Promise(resolve => { entered = resolve; });
+ let release!: () => void;
+ const releaseFirstClose = new Promise(resolve => { release = resolve; });
+ const session = (models: string[], close: () => Promise) => ({
+ models, prompt: async () => undefined, close,
+ });
+ const open = spyOn(traeClient, "openTraeSession").mockImplementation(async () => {
+ calls++;
+ return calls === 1
+ ? session(["old-model"], async () => { entered(); await releaseFirstClose; })
+ : session(["new-model"], async () => undefined);
+ });
+ const first = fetchProviderModelsWithAuth(
+ captureProviderGather("trae-cn", row, refreshingModelsAuthResolver), 0, undefined, refreshingModelsAuthResolver,
+ );
+ await firstCloseStarted;
+ const second = fetchProviderModelsWithAuth(
+ captureProviderGather("trae-cn", row, refreshingModelsAuthResolver), 0, undefined, refreshingModelsAuthResolver,
+ );
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(open).toHaveBeenCalledTimes(1);
+ release();
+ const [older, newer] = await Promise.all([first, second]);
+ expect(older.models.map(model => model.id)).toContain("old-model");
+ expect(newer.models.map(model => model.id)).toContain("new-model");
+ expect(open).toHaveBeenCalledTimes(2);
+ const resolver = observedModelsAuthResolver(null, []);
+ const observed = await fetchProviderModelsWithAuth(captureProviderGather("trae-cn", row, resolver),
+ 0, undefined, resolver);
+ expect(observed.models.map(model => model.id)).toContain("new-model");
+ expect(observed.models.map(model => model.id)).not.toContain("old-model");
+ });
+
+ test("queued discovery cannot borrow a replacement cache generation", async () => {
+ const row = { ...config(), models: [] };
+ let entered!: () => void;
+ const firstCloseStarted = new Promise(resolve => { entered = resolve; });
+ let release!: () => void;
+ const releaseFirstClose = new Promise(resolve => { release = resolve; });
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["old-model"],
+ prompt: async () => undefined, close: async () => { entered(); await releaseFirstClose; } });
+ const first = fetchProviderModelsWithAuth(
+ captureProviderGather("trae-cn", row, refreshingModelsAuthResolver), 0, undefined, refreshingModelsAuthResolver,
+ );
+ await firstCloseStarted;
+ const second = fetchProviderModelsWithAuth(
+ captureProviderGather("trae-cn", row, refreshingModelsAuthResolver), 0, undefined, refreshingModelsAuthResolver,
+ );
+ clearModelCache("trae-cn");
+ release();
+ const [older, queued] = await Promise.all([first, second]);
+ expect(older.outcome.state).toBe("degraded");
+ expect(queued.outcome.state).toBe("degraded");
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(getPinnedCached("trae-cn")).toBeNull();
+ });
+
+ test("background publication cannot replace an intervening newer roster", async () => {
+ const rowA = { ...config(), models: [] };
+ const rowB = { ...config(), models: [] };
+ let calls = 0;
+ const session = (models: string[]) => ({ models, prompt: async () => undefined, close: async () => undefined });
+ const open = spyOn(traeClient, "openTraeSession").mockImplementation(async () => {
+ calls++;
+ if (calls === 1) return session(["old-model"]);
+ if (calls === 2) {
+ clearModelCache("trae-a");
+ const newer = await fetchProviderModelsWithAuth(
+ captureProviderGather("trae-a", rowA, refreshingModelsAuthResolver), 0, undefined, refreshingModelsAuthResolver,
+ );
+ expect(newer.outcome.state).toBe("authoritative");
+ return session(["second-provider-model"]);
+ }
+ return session(["new-model"]);
+ });
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-a", modelCacheTtlMs: 0,
+ providers: { "trae-a": rowA, "trae-b": rowB } };
+ let published = false;
+ await refreshTraeCnCatalogSources(next, () => true, async () => { published = true; });
+ const observed = await fetchProviderModelsWithAuth(
+ captureProviderGather("trae-a", rowA, observedModelsAuthResolver(null, [])), 0, undefined,
+ observedModelsAuthResolver(null, []),
+ );
+ expect(published).toBe(false);
+ expect(open).toHaveBeenCalledTimes(3);
+ expect(observed.models.map(model => model.id)).toContain("new-model");
+ expect(observed.models.map(model => model.id)).not.toContain("old-model");
+ expect(getPinnedCached("trae-a")).toBeNull();
+ expect(getPinnedCached("trae-b")).toBeNull();
+ });
+
+ for (const stopped of [false, true]) {
+ test("background CLI refresh does not hold management admission; stopped=" + stopped, async () => {
+ let entered!: () => void;
+ const started = new Promise(resolve => { entered = resolve; });
+ let release!: (session: traeClient.TraeSession) => void;
+ const open = spyOn(traeClient, "openTraeSession").mockImplementation(() => {
+ entered(); return new Promise(resolve => { release = resolve; });
+ });
+ const next: OcxConfig = { port: 10100, defaultProvider: "trae-cn", providers: { "trae-cn": config() },
+ clientIntegrations: { codex: false }, catalogAutoRefresh: { enabled: true } };
+ spyOn(configApi, "loadConfig").mockReturnValue(next);
+ const rosters: unknown[] = [];
+ const admission = spyOn(catalogAdmission, "captureCatalogAdmissionSnapshot").mockImplementation(() => {
+ rosters.push(getFreshCached("trae-cn", 60_000, Date.now())?.map(model => model.id) ?? null);
+ throw new Error("fixture stops before any catalog write");
+ });
+ const session = { models: ["background-model"], prompt: async () => undefined, close: async () => undefined };
+ catalogScheduler.resetCatalogAutoRefreshForTests();
+ const tick = catalogScheduler.runCatalogAutoRefreshTickForTests();
+ try {
+ await started;
+ await managementConvergence.createManagementConvergeCodex(next)(catalogAdmission.createCatalogConvergeRequest({ deadlineMs: 1000 }));
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(admission).toHaveBeenCalledTimes(1); expect(rosters).toEqual([null]);
+ if (stopped) catalogScheduler.stopCatalogAutoRefresh();
+ release(session); await tick;
+ expect(rosters).toEqual(stopped ? [null] : [null, ["background-model"]]);
+ } finally { release?.(session); await tick; catalogScheduler.resetCatalogAutoRefreshForTests(); }
+ });
+ }
+
+ for (const failed of [false, true]) {
+ test("discovery losing its generation returns the replacement roster on " + (failed ? "failure" : "success"), async () => {
+ let opened!: () => void;
+ const entered = new Promise(resolve => { opened = resolve; });
+ let resolve!: (session: traeClient.TraeSession) => void;
+ let reject!: (error: Error) => void;
+ spyOn(traeClient, "openTraeSession").mockImplementation(() => { opened(); return new Promise((yes, no) => { resolve = yes; reject = no; }); });
+ setCached("trae-cn", [{ id: "old-account-model", provider: "trae-cn" }], 1, captureModelCacheGeneration("trae-cn"));
+ const captured = captureProviderGather("trae-cn", config(), refreshingModelsAuthResolver);
+ const pending = fetchProviderModelsWithAuth(captured, 60_000, undefined, refreshingModelsAuthResolver);
+ await entered;
+ clearModelCache("trae-cn");
+ setCached("trae-cn", [{ id: "new-account-model", provider: "trae-cn" }], Date.now(), captureModelCacheGeneration("trae-cn"));
+ if (failed) reject(new Error("old discovery failed"));
+ else resolve({ models: ["old-account-model"], prompt: async () => undefined, close: async () => undefined });
+ const result = await pending;
+ expect(result.models.map(model => model.id)).toContain("new-account-model");
+ expect(result.models.map(model => model.id)).not.toContain("old-account-model");
+ expect(result.contentRevision).toBe(observeModelCacheRevision("trae-cn"));
+ });
+ }
+
+ test("discovered models do not advertise unsupported verbosity or native image input", async () => {
+ const fixture = traeFixture();
+ const realOpen = traeClient.openTraeSession;
+ spyOn(traeClient, "openTraeSession").mockImplementation((baseUrl, _deps, signal) => realOpen(baseUrl, fixture.deps, signal));
+ const models = await rawModels({ ...config(), models: [] });
+ expect(models).toMatchObject([{ id: "test-model", inputModalities: ["text"], supportsVerbosity: false }]);
+ expect(fixture.child.killed).toBe(true);
+ });
+
+ test("cold failures use the cooldown without launching a second CLI", async () => {
+ const open = spyOn(traeClient, "openTraeSession").mockRejectedValue(new Error("fixture offline"));
+ expect((await fetchProviderModels("trae-cn", config(), 60_000)).map(row => row.id)).toContain("test-model");
+ await fetchProviderModels("trae-cn", config(), 60_000);
+ expect(open).toHaveBeenCalledTimes(1);
+ });
+
+ for (const warm of [false, true]) {
+ test(`observed catalogs never launch or refresh CLI authentication (warm=${warm})`, async () => {
+ const open = spyOn(traeClient, "openTraeSession").mockRejectedValue(new Error("observed gather must not launch"));
+ if (warm) setCached("trae-cn", [{ id: "cached-model", provider: "trae-cn" }], Date.now(), captureModelCacheGeneration("trae-cn"));
+ const auth = observedModelsAuthResolver(null, []);
+ const captured = captureProviderGather("trae-cn", config(), auth);
+ const result = await fetchProviderModelsWithAuth(captured, 60_000, undefined, auth);
+ expect(open).not.toHaveBeenCalled();
+ expect(result.outcome.state).toBe(warm ? "authoritative" : "degraded");
+ expect(result.models.map(model => model.id)).toContain(warm ? "cached-model" : "test-model");
+ });
+ }
+
+ for (const cacheState of ["fresh", "stale", "cooldown"]) {
+ test(`the ${cacheState} cache reapplies lower caps and retained models`, async () => {
+ spyOn(traeClient, "openTraeSession").mockRejectedValue(new Error("fixture offline"));
+ setCached("trae-cn", [{ id: "test-model", provider: "trae-cn", contextWindow: 120_000 }],
+ cacheState === "fresh" ? Date.now() : 1, captureModelCacheGeneration("trae-cn"));
+ const row = { ...config(), retainModels: ["retained-model"] };
+ if (cacheState === "cooldown") await fetchProviderModels("trae-cn", row, 60_000, 80_000);
+ const models = await rawModels(row, 40_000);
+ expect(models.map(model => model.id)).toContain("retained-model");
+ expect(models.find(model => model.id === "test-model")!.contextWindow).toBeLessThanOrEqual(40_000);
+ for (const model of models) expect(model).toMatchObject({ inputModalities: ["text"], supportsVerbosity: false });
+ });
+ }
+
+ test("connection probe publishes the ACP roster through catalog convergence", async () => {
+ const fixture = traeFixture();
+ setCached("trae-cn", [{ id: "old-account-model", provider: "trae-cn" }], Date.now(), captureModelCacheGeneration("trae-cn"));
+ const converge = mock(async (): Promise => {
+ expect(getFreshCached("trae-cn", 60_000, Date.now())?.map(model => model.id)).toEqual(["test-model"]);
+ return { status: "committed", changed: true, degraded: false, notices: [] };
+ });
+ const refreshConnectedClients = mock(async (admit: () => boolean) => { expect(admit()).toBe(true); return []; });
+ const response = await probeTraeCnProvider("trae-cn", config(), converge, undefined, { ...fixture.deps, refreshConnectedClients });
+ expect(await response.json()).toMatchObject({ ok: true, models: 1, catalogRefresh: { status: "committed" } });
+ expect(fixture.requests.map(request => request.method)).toEqual(["initialize", "session/new"]);
+ expect(converge).toHaveBeenCalledTimes(1);
+ expect(refreshConnectedClients).toHaveBeenCalledTimes(1);
+ expect(fixture.child.killed).toBe(true);
+ });
+
+ test("a connection probe cannot replace a newer cache generation", async () => {
+ let started!: () => void;
+ const waiting = new Promise(resolve => { started = resolve; });
+ let requestId = 0;
+ const fixture = traeFixture(request => {
+ if (request.method !== "session/new") return false;
+ requestId = request.id;
+ started();
+ return true;
+ });
+ const converge = mock(async (): Promise => ({ status: "committed", changed: true, degraded: false, notices: [] }));
+ const probe = probeTraeCnProvider("trae-cn", config(), converge, undefined, fixture.deps);
+ await waiting;
+ clearModelCache("trae-cn");
+ setCached("trae-cn", [{ id: "new-account-model", provider: "trae-cn" }], Date.now(), captureModelCacheGeneration("trae-cn"));
+ fixture.send({ jsonrpc: "2.0", id: requestId, result: { sessionId: "fixture-session", models: { availableModels: [{ modelId: "old-account-model" }] } } });
+ const body = await (await probe).json();
+ expect(body).toMatchObject({ ok: true, rosterReplaced: false, catalogRefresh: { status: "skipped", reason: "stale" } });
+ expect(body.message).toContain("Run Test connection again");
+ expect(getFreshCached("trae-cn", 60_000, Date.now())?.map(model => model.id)).toEqual(["new-account-model"]);
+ expect(converge).not.toHaveBeenCalled();
+ });
+
+ test("a successful probe reports pending catalog publication", async () => {
+ const fixture = traeFixture();
+ const response = await probeTraeCnProvider("trae-cn", config(), async () => ({ status: "skipped", reason: "busy", retryable: true }), undefined, fixture.deps);
+ const body = await response.json();
+ expect(body).toMatchObject({ ok: true, catalogRefresh: { status: "skipped", reason: "busy" } });
+ expect(body.message).toContain("Catalog refresh is pending");
+ });
+
+ test("the management connection test reaches ACP without an HTTP website probe", async () => {
+ const fixture = traeFixture();
+ const realOpen = traeClient.openTraeSession;
+ spyOn(traeClient, "openTraeSession").mockImplementation((baseUrl, _deps, signal) => realOpen(baseUrl, fixture.deps, signal));
+ const outbound = spyOn(globalThis, "fetch").mockRejectedValue(new Error("must not fetch the vendor website"));
+ const req = new Request("http://localhost/api/providers/test?name=trae-cn", { method: "POST" });
+ const refreshOwnedCatalogIntegrations = mock(async () => []);
+ const response = await handleProviderRoutes({ req, url: new URL(req.url), config: { providers: { "trae-cn": config() } }, deps: { refreshOwnedCatalogIntegrations },
+ convergeCodexCatalog: async () => ({ status: "committed", changed: true, degraded: false, notices: [] }),
+ } as Parameters[0]);
+ expect(await response!.json()).toMatchObject({ ok: true, models: 1 });
+ expect(outbound).not.toHaveBeenCalled();
+ expect(refreshOwnedCatalogIntegrations).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts
index 3942ca339bd..12dc10c578a 100644
--- a/tests/adapters/adapter-tool-conformance.test.ts
+++ b/tests/adapters/adapter-tool-conformance.test.ts
@@ -434,7 +434,7 @@ describe("registry-derived routed tool conformance", () => {
// Zed is not tool-less: it forwards client tools through the Anthropic, Google, Responses and
// Chat builders, which this suite covers under their own ids. It is skipped because its
// envelope needs a live account catalog, so read its entry as "not covered here".
- const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder", "claude-cli", "zed"]);
+ const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder", "claude-cli", "trae-cn", "zed"]);
// The Devin adapter is runTurn-only: it streams Connect-RPC from runTurn, so
// buildRequest returns a placeholder and tools never travel the wire path.
// Both Devin provider rows share it and differ only in where the credential
diff --git a/tests/adapters/openai/openai-chat-audio.test.ts b/tests/adapters/openai/openai-chat-audio.test.ts
index 51bdb40d7be..786fccf4ca7 100644
--- a/tests/adapters/openai/openai-chat-audio.test.ts
+++ b/tests/adapters/openai/openai-chat-audio.test.ts
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat";
+import { parseRequest } from "../../../src/responses/parser";
import type { OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../../../src/types";
const provider: OcxProviderConfig = {
@@ -74,6 +75,119 @@ test("user audio maps to a Chat input_audio content part", () => {
}]);
});
+test("developer inline audio retains the instruction slot and uses a user audio carrier", () => {
+ const parsed = parseRequest({ model: "gpt-audio", instructions: "Base", input: [
+ { role: "user", content: "First" },
+ { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
+ { role: "developer", content: [
+ { type: "input_text", text: "Listen to the recording" },
+ { type: "input_audio", audio_url: "data:audio/wav;base64,YXVkaW8=", format: "wav" },
+ ] },
+ { type: "function_call_output", call_id: "call_1", output: "result" },
+ { role: "user", content: "Continue" },
+ ] });
+ const messages = JSON.parse(createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1" })
+ .buildRequest(parsed).body).messages as Array>;
+ expect(messages).toEqual([
+ { role: "system", content: "Base" },
+ { role: "user", content: "First" },
+ { role: "assistant", content: "", tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }] },
+ { role: "tool", tool_call_id: "call_1", content: "result" },
+ { role: "system", content: [{ type: "text", text: "Listen to the recording" }] },
+ { role: "user", content: [{ type: "input_audio", input_audio: { data: "YXVkaW8=", format: "wav" } }] },
+ { role: "user", content: "Continue" },
+ ]);
+ expect(messages.some(message => (message.role === "system" || message.role === "developer")
+ && JSON.stringify(message.content).includes("input_audio"))).toBe(false);
+});
+
+test("interleaved instructions and recordings preserve their adjacent groups", () => {
+ const messages = wire([
+ user("First"),
+ assistantWithCalls([{ id: "call_1", name: "lookup" }]),
+ { role: "developer", content: [
+ { type: "text", text: "Translate the first recording" },
+ { type: "audio", data: "YXVkaW8=", format: "wav", text: "[audio: wav]" },
+ { type: "text", text: "Translate the second recording" },
+ { type: "audio", data: "bW9yZQ==", format: "wav", text: "[audio: wav]" },
+ ], timestamp: 0 },
+ toolResult("call_1", "lookup", "result"),
+ user("Continue"),
+ ]);
+ expect(messages).toEqual([
+ { role: "user", content: "First" },
+ { role: "assistant", content: "", tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }] },
+ { role: "tool", tool_call_id: "call_1", content: "result" },
+ { role: "system", content: [{ type: "text", text: "Translate the first recording" }] },
+ { role: "user", content: [{ type: "input_audio", input_audio: { data: "YXVkaW8=", format: "wav" } }] },
+ { role: "system", content: [{ type: "text", text: "Translate the second recording" }] },
+ { role: "user", content: [{ type: "input_audio", input_audio: { data: "bW9yZQ==", format: "wav" } }] },
+ { role: "user", content: "Continue" },
+ ]);
+});
+
+test("a default-role developer image uses a user carrier after its instruction text", () => {
+ const parsed = parseRequest({ model: "gpt-vision", instructions: "Base", input: [
+ { role: "user", content: "First" },
+ { role: "developer", content: [
+ { type: "input_text", text: "Inspect this image" },
+ { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=", detail: "high" },
+ ] },
+ { role: "user", content: "Continue" },
+ ] });
+ const messages = JSON.parse(createOpenAIChatAdapter(provider).buildRequest(parsed).body).messages;
+ expect(messages).toEqual([
+ { role: "system", content: "Base" },
+ { role: "user", content: "First" },
+ { role: "user", content: [
+ { type: "text", text: "Inspect this image" },
+ { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8=", detail: "high" } },
+ ] },
+ { role: "user", content: "Continue" },
+ ]);
+});
+
+test("a default-role developer document uses a user carrier after its instruction text", () => {
+ const file = { type: "input_file", filename: "spec.pdf", file_data: "data:application/pdf;base64,JVBERi0xLjQ=" };
+ const parsed = parseRequest({ model: "gpt-files", input: [{ role: "developer", content: [
+ { type: "input_text", text: "Read this document" }, file,
+ ] }] });
+ const messages = JSON.parse(createOpenAIChatAdapter(provider).buildRequest(parsed).body).messages;
+ expect(messages).toEqual([
+ { role: "user", content: [
+ { type: "text", text: "Read this document" },
+ { type: "file", file: { filename: file.filename, file_data: file.file_data } },
+ ] },
+ ]);
+});
+
+test("single-leading instructions keep audio and image bytes in one ordered user carrier", () => {
+ const parsed = parseRequest({ model: "gpt-audio", instructions: "Base", input: [
+ { role: "user", content: "First" },
+ { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
+ { role: "developer", content: [
+ { type: "input_text", text: "Listen and inspect" },
+ { type: "input_audio", audio_url: "data:audio/wav;base64,YXVkaW8=", format: "wav" },
+ { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=", detail: "high" },
+ ] },
+ { type: "function_call_output", call_id: "call_1", output: "result" },
+ { role: "user", content: "Continue" },
+ ] });
+ const messages = JSON.parse(createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1",
+ singleLeadingSystemMessage: true }).buildRequest(parsed).body).messages as Array>;
+ expect(messages).toEqual([
+ { role: "system", content: "Base\n\nListen and inspect" },
+ { role: "user", content: "First" },
+ { role: "assistant", content: "", tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }] },
+ { role: "tool", tool_call_id: "call_1", content: "result" },
+ { role: "user", content: [
+ { type: "input_audio", input_audio: { data: "YXVkaW8=", format: "wav" } },
+ { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8=", detail: "high" } },
+ ] },
+ { role: "user", content: "Continue" },
+ ]);
+});
+
test("tool-result audio rides a follow-up user message after the tool round", () => {
const messages = wire([
user("play the clip"),
diff --git a/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts b/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts
index 3b0aa68f36e..20ebe201485 100644
--- a/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts
+++ b/tests/adapters/openai/openai-chat-qwen38-leading-system.test.ts
@@ -17,7 +17,7 @@ const provider: OcxProviderConfig = {
apiKey: "test",
};
-function serialize(modelId: string, target = provider, messages?: OcxParsedRequest["context"]["messages"]): Array<{ role: string; content: string }> {
+function serialize(modelId: string, target = provider, messages?: OcxParsedRequest["context"]["messages"]): Array<{ role: string; content: unknown }> {
const request = createOpenAIChatAdapter(target).buildRequest({
modelId,
context: {
@@ -120,6 +120,22 @@ describe("leading-system chat templates (Qwen3.8-27B, Flash-Next, OrcaSAQ-2-Cybe
expect(() => assertTemplateAccepts(messages)).not.toThrow();
});
+ test("a structured instruction image keeps detail and uses the template-compatible user role", () => {
+ const messages = serialize("Qwen3.8-27B", provider, [
+ { role: "user", content: "First turn.", timestamp: 0 },
+ { role: "developer", content: [
+ { type: "text", text: "Inspect the attachment." },
+ { type: "image", imageUrl: "data:image/png;base64,aGVsbG8=", detail: "high" },
+ ], timestamp: 0 },
+ { role: "user", content: "Second turn.", timestamp: 0 },
+ ]);
+ expect(messages[2]).toEqual({ role: "user", content: [
+ { type: "text", text: "Inspect the attachment." },
+ { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8=", detail: "high" } },
+ ] });
+ expect(() => assertTemplateAccepts(messages)).not.toThrow();
+ });
+
test("other models keep their recorded role and chronological position", () => {
for (const modelId of ["flash-next-other", "Qwen3.8-Flash-Next-FP8", "deepseek-flash-next"]) {
expect(serialize(modelId)[2].role).toBe("system");
diff --git a/tests/ci-workflows/docs-provider-preset-counts.test.ts b/tests/ci-workflows/docs-provider-preset-counts.test.ts
index a849660065b..5ae7b436176 100644
--- a/tests/ci-workflows/docs-provider-preset-counts.test.ts
+++ b/tests/ci-workflows/docs-provider-preset-counts.test.ts
@@ -59,6 +59,32 @@ async function anchoredLine(path: string, anchor: string): Promise {
}
describe("documented provider preset counts match the registry", () => {
+ test("TRAE CN quickstart leaves the key empty and links CLI login setup", async () => {
+ const page = await Bun.file(repoPath(QUICKSTARTS[0].path)).text();
+ const note = page.match(/For \*\*TRAE CN\*\*[^\n]*(?:\n[^\n]+)*/)?.[0] ?? "";
+ expect(note).toMatch(/^For \*\*TRAE CN\*\*, press Enter to leave the wizard’s key prompt empty:/);
+ expect(note).toContain("CLI owns authentication. Install and sign in to the CLI first;");
+ expect(note).toContain("[TRAE CN setup](/guides/providers/#trae-cn-official-acp)");
+ });
+
+ const translatedTraeNotes: Record = {
+ ja: "**TRAE CN** はログイン済みの公式 CLI を使用するため、API キーは不要です。CLI をインストールしてログインし、ウィザードのキー入力欄は空のまま進めてください。",
+ ko: "**TRAE CN**은 로그인된 공식 CLI를 사용하므로 API 키가 필요하지 않습니다. CLI를 설치하고 로그인한 후 마법사의 키 입력란은 비워 두세요.",
+ fr: "**TRAE CN** utilise le CLI officiel déjà connecté et ne nécessite pas de clé API. Installez le CLI, connectez-vous et laissez le champ de clé vide dans l’assistant.",
+ ru: "**TRAE CN** использует официальный CLI с выполненным входом и не требует ключа API. Установите CLI, войдите в аккаунт и оставьте поле ключа в мастере пустым.",
+ tr: "**TRAE CN**, oturum açılmış resmi CLI uygulamasını kullanır; API anahtarı gerekmez. CLI uygulamasını yükleyip oturum açın ve sihirbazdaki anahtar alanını boş bırakın.",
+ "zh-cn": "**TRAE CN** 使用已登录的官方 CLI,无需 API 密钥。请先安装 CLI 并登录;向导若提示输入密钥,可直接留空。",
+ "zh-tw": "**TRAE CN** 使用已登入的官方 CLI,無需 API 金鑰。請先安裝 CLI 並登入;精靈若要求輸入金鑰,可直接留空。",
+ };
+ for (const quickstart of QUICKSTARTS.filter(row => row.locale !== "en")) {
+ test(`${quickstart.locale} quickstart gives affirmative empty-key and CLI sign-in guidance`, async () => {
+ const page = await Bun.file(repoPath(quickstart.path)).text();
+ const note = page.split("\n").map(line => line.trim()).find(line => line.startsWith("**TRAE CN**")) ?? "";
+ expect(note.startsWith(translatedTraeNotes[quickstart.locale]!)).toBe(true);
+ expect(note).toContain("(/guides/providers/#trae-cn-official-acp)");
+ });
+ }
+
test("the registry is the only source of the numbers under test", () => {
// A derived count that collapsed to zero would make every assertion below vacuous.
expect(TOTAL).toBeGreaterThan(0);
@@ -69,21 +95,24 @@ describe("documented provider preset counts match the registry", () => {
for (const guide of GUIDES) {
test(`${guide.locale} provider guide states ${TOTAL} presets and ${KEY_PRESETS} key-based`, async () => {
const line = await anchoredLine(guide.path, guide.anchor);
- expect(line, `${guide.path} total`).toContain(String(TOTAL));
- expect(line, `${guide.path} key-based split`).toContain(String(KEY_PRESETS));
+ const counts = [...line.matchAll(/\d+/g)].map(match => Number(match[0]));
+ expect(counts[0], `${guide.path} total`).toBe(TOTAL);
+ expect(counts[1], `${guide.path} key-based split`).toBe(KEY_PRESETS);
});
}
for (const quickstart of QUICKSTARTS) {
test(`${quickstart.locale} quickstart states ${TOTAL} presets`, async () => {
const line = await anchoredLine(quickstart.path, quickstart.anchor);
- expect(line, `${quickstart.path} total`).toContain(String(TOTAL));
+ const prose = line.replace(/^\s*\d+[.)]\s+/, "");
+ expect(Number(prose.match(/\d+/)?.[0]), `${quickstart.path} total`).toBe(TOTAL);
});
}
test("the structure ops record carries the same split", async () => {
const line = await anchoredLine(STRUCTURE.path, STRUCTURE.anchor);
- expect(line).toContain(String(TOTAL));
- expect(line).toContain(String(KEY_PRESETS));
+ const counts = [...line.matchAll(/\d+/g)].map(match => Number(match[0]));
+ expect(counts[0]).toBe(TOTAL);
+ expect(counts[1]).toBe(KEY_PRESETS);
});
});
diff --git a/tests/codex-integration/app-owned-memory.test.ts b/tests/codex-integration/app-owned-memory.test.ts
index 0f50a2aaa41..9b51ae7eaa3 100644
--- a/tests/codex-integration/app-owned-memory.test.ts
+++ b/tests/codex-integration/app-owned-memory.test.ts
@@ -28,6 +28,7 @@ import {
clearModelCache,
evictOldestModelCacheForBudget,
modelCacheRetainedStoreSnapshot,
+ MODEL_CACHE_MAX_PINNED_BYTES,
setCached,
} from "../../src/codex/model-cache";
import type { PersistedUsageEntry } from "../../src/usage/log";
@@ -88,7 +89,8 @@ describe("app-owned retained memory", () => {
+ IMAGE_NORMALIZE_CACHE_MAX_BYTES
+ VISION_DESCRIPTION_CACHE_MAX_BYTES
+ ANTIGRAVITY_REPLAY_MAX_TOTAL_BYTES
- + MAX_NATIVE_CONTROL_REPLAY_TOTAL_BYTES;
+ + MAX_NATIVE_CONTROL_REPLAY_TOTAL_BYTES
+ + MODEL_CACHE_MAX_PINNED_BYTES;
expect(boundedStoreBytes).toBeLessThan(APP_OWNED_WORST_CASE_PINNED_BYTES);
});
diff --git a/tests/codex-integration/model-cache.test.ts b/tests/codex-integration/model-cache.test.ts
index 58ab24eacff..d67ec54fcb7 100644
--- a/tests/codex-integration/model-cache.test.ts
+++ b/tests/codex-integration/model-cache.test.ts
@@ -1,11 +1,18 @@
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
captureModelCacheGeneration,
clearModelCache,
getStaleCached,
+ getPinnedCached,
+ isModelCacheGenerationCurrent,
+ modelCacheRetainedStoreSnapshot,
+ evictOldestModelCacheForBudget,
+ pinCachedModels,
+ MODEL_CACHE_MAX_PINNED_BYTES,
reconcileModelCacheProviders,
setCached,
} from "../../src/codex/model-cache";
+import { configureAppOwnedMemoryBudget, registerRetainedStore, resetAppOwnedMemoryForTests } from "../../src/lib/app-owned-memory";
const provider = "removed-provider-generation";
@@ -44,3 +51,62 @@ describe("model-cache provider reconciliation", () => {
expect(getStaleCached(provider)).toBeNull();
});
});
+
+describe("model-cache publication leases", () => {
+ const budget = 64 * 1024 * 1024;
+ beforeEach(() => {
+ resetAppOwnedMemoryForTests(); clearModelCache(); configureAppOwnedMemoryBudget(budget);
+ registerRetainedStore({ id: "model_cache", category: "caches",
+ snapshot: modelCacheRetainedStoreSnapshot, evictOldest: evictOldestModelCacheForBudget });
+ });
+ afterEach(() => { clearModelCache(); resetAppOwnedMemoryForTests(); });
+
+ test("authority revocation removes lookup but keeps detached leases accounted until release", () => {
+ const generation = captureModelCacheGeneration(provider);
+ const releaseOld = pinCachedModels(provider, [{ id: "old", provider }], Date.now(), generation)!;
+ const oldBytes = modelCacheRetainedStoreSnapshot().pinnedBytes;
+ let releaseNew: (() => void) | null = null;
+ try {
+ clearModelCache(provider);
+ expect(isModelCacheGenerationCurrent(provider, generation)).toBe(false);
+ expect(getPinnedCached(provider)).toBeNull();
+ expect(getStaleCached(provider)).toBeNull();
+ expect(modelCacheRetainedStoreSnapshot()).toMatchObject({ count: 1, bytes: oldBytes, pinnedBytes: oldBytes, evictableBytes: 0 });
+ releaseNew = pinCachedModels(provider, [{ id: "new", provider }], Date.now(), captureModelCacheGeneration(provider));
+ expect(releaseNew).not.toBeNull();
+ expect(modelCacheRetainedStoreSnapshot().count).toBe(2);
+ releaseOld();
+ const remaining = modelCacheRetainedStoreSnapshot().pinnedBytes;
+ releaseOld();
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(remaining);
+ expect(getPinnedCached(provider)?.[0]?.id).toBe("new");
+ } finally { releaseOld(); releaseNew?.(); }
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ });
+
+ test("entry and byte admission caps bound concurrent publication leases", () => {
+ const releases: (() => void)[] = [];
+ try {
+ for (let index = 0; index < 16; index++) {
+ const name = "lease-" + index;
+ const release = pinCachedModels(name, [{ id: "small", provider: name }], Date.now(), captureModelCacheGeneration(name));
+ expect(release).not.toBeNull(); releases.push(release!);
+ }
+ expect(pinCachedModels("overflow", [], Date.now(), captureModelCacheGeneration("overflow"))).toBeNull();
+ } finally { for (const release of releases) release(); }
+ expect(pinCachedModels("oversize", [{ id: "x".repeat(MODEL_CACHE_MAX_PINNED_BYTES), provider: "oversize" }],
+ Date.now(), captureModelCacheGeneration("oversize"))).toBeNull();
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ });
+
+ test("admission rechecks the generation after reclaiming another store", () => {
+ let bytes = budget;
+ registerRetainedStore({ id: "changing-continuation", category: "continuation",
+ snapshot: () => ({ count: bytes ? 1 : 0, bytes, pinnedBytes: 0, evictableBytes: bytes, oldestAt: bytes ? 1 : null }),
+ evictOldest: () => { const released = bytes; bytes = 0; clearModelCache(provider); return released; },
+ });
+ expect(pinCachedModels(provider, [{ id: "stale", provider }], Date.now(), captureModelCacheGeneration(provider))).toBeNull();
+ expect(getStaleCached(provider)).toBeNull();
+ expect(modelCacheRetainedStoreSnapshot().pinnedBytes).toBe(0);
+ });
+});
diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts
index 354980b3552..aa6cb3225f8 100644
--- a/tests/helpers/responses-core-source.ts
+++ b/tests/helpers/responses-core-source.ts
@@ -35,6 +35,7 @@ export const RESPONSES_CORE_MODULES = [
"core-normalize.ts",
"core-combo.ts",
"deferred-send-reporter.ts",
+ "chat-policy-media.ts",
"combo-requested-effort.ts",
"core-combo-native.ts",
// Reached from core-combo.ts: tool-loop continuations stay on the lane's previous target (#7298).
diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts
index 4221b174921..240e84c77c7 100644
--- a/tests/responses/chat-inline-document-bytes.test.ts
+++ b/tests/responses/chat-inline-document-bytes.test.ts
@@ -185,7 +185,7 @@ describe("inline document bytes reach a wire that can hold them", () => {
]);
});
- test("a developer turn carrying a document keeps its role", () => {
+ test("a developer turn keeps its document in one user-compatible carrier", () => {
const parsed = {
modelId: "local-model",
context: {
@@ -202,7 +202,7 @@ describe("inline document bytes reach a wire that can hold them", () => {
messages: Array<{ role: string; content: unknown }>;
};
expect(outbound.messages).toEqual([{
- role: "developer",
+ role: "user",
content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }],
}]);
});
diff --git a/tests/responses/responses-combo-run-turn-classified-failure.test.ts b/tests/responses/responses-combo-run-turn-classified-failure.test.ts
index 0fd42249fe4..6762d0bba15 100644
--- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts
+++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts
@@ -3,7 +3,7 @@
* The child's status and code drive hop and cooldown decisions; the combo's own failure
* consumer still decides what reaches the client.
*/
-import { afterEach, beforeEach, expect, mock, test } from "bun:test";
+import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -42,6 +42,10 @@ mock.module("../../src/server/adapter-resolve", () => ({
}));
const { handleResponses } = await import("../../src/server/responses");
+const { handleChatCompletions } = await import("../../src/server/chat-completions");
+const { handleResponses: responsesCore } = await import("../../src/server/responses/core");
+const { handleResponsesWithPolicyFallback } = await import("../../src/server/responses/policy-fallback");
+const traeClient = await import("../../src/adapters/trae-cn/client");
const { formatErrorResponse } = await import("../../src/bridge");
const { SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE } = await import("../../src/lib/errors");
const { saveCredential } = await import("../../src/oauth/store");
@@ -93,6 +97,637 @@ async function respond(cfg: OcxConfig, stream: boolean) {
return { status: response.status, text: await response.text() };
}
+test("TRAE user images reach the routed vision sidecar instead of the pre-dispatch refusal", async () => {
+ const routed = await import("../../src/vision/routed-describe");
+ const describer = spyOn(routed, "describeImageRouted").mockResolvedValue({ text: "caption from fixture", elapsedMs: 1 });
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false, noVisionModels: ["test-model"] };
+ cfg.providers.vision = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key",
+ authMode: "key", models: ["vision-model"], inputModalities: ["text", "image"] };
+ cfg.visionSidecar = { enabled: true, backend: "routed", model: "vision/vision-model" };
+ const prompts: string[] = [];
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, text, emit) => { prompts.push(text); emit({ type: "text_delta", text: "described" }); emit({ type: "done" }); },
+ close: async () => undefined });
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false,
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }] }),
+ }), cfg, { model: "", provider: "" });
+ expect(response.status, await response.clone().text()).toBe(200);
+ expect(open).toHaveBeenCalledTimes(1); expect(prompts.join("\n")).toContain("caption from fixture");
+ expect(prompts.join("\n")).not.toContain("aGVsbG8=");
+ } finally { open.mockRestore(); describer.mockRestore(); }
+});
+
+test("TRAE does not treat a credential-free OpenAI preference as a dispatchable vision plan", async () => {
+ const sidecarModule = await import("../../src/providers/openai-sidecar");
+ const resolver = spyOn(sidecarModule, "resolveFirstUsableOpenAiSidecar").mockResolvedValue(undefined);
+ const plannerModule = await import("../../src/vision/plan");
+ const planner = spyOn(plannerModule, "planVisionSidecar").mockReturnValue(undefined);
+ const routed = await import("../../src/vision/routed-describe");
+ const describer = spyOn(routed, "describeImageRouted");
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.visionSidecar = { enabled: true, backend: "openai" };
+ const open = spyOn(traeClient, "openTraeSession");
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false,
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }] }),
+ }), cfg, { model: "", provider: "" });
+ expect(response.status).toBe(400);
+ expect((await response.json() as { error: { code: string } }).error.code).toBe("unsupported_input_modality");
+ expect(describer).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
+ expect(resolver).toHaveBeenCalledTimes(1);
+ } finally { open.mockRestore(); describer.mockRestore(); resolver.mockRestore(); planner.mockRestore(); }
+});
+
+test("TRAE reuses a credentialed OpenAI sidecar for ordinary user images", async () => {
+ const sidecarModule = await import("../../src/providers/openai-sidecar");
+ const resolver = spyOn(sidecarModule, "resolveFirstUsableOpenAiSidecar").mockResolvedValue({
+ providerName: "openai", provider: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" },
+ accountMode: "direct", authContext: { kind: "main", accountId: null }, headers: new Headers(),
+ });
+ const vision = await import("../../src/vision/describe");
+ const describer = spyOn(vision, "describeImage").mockResolvedValue({ text: "caption from OpenAI fixture", elapsedMs: 1 });
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex",
+ authMode: "forward", codexAccountMode: "direct" };
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.visionSidecar = { enabled: true, backend: "openai" };
+ const prompts: string[] = [];
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, text, emit) => { prompts.push(text); emit({ type: "text_delta", text: "described" }); emit({ type: "done" }); },
+ close: async () => undefined });
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false,
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }] }),
+ }), cfg, { model: "", provider: "" });
+ expect(response.status, await response.clone().text()).toBe(200);
+ expect(resolver).toHaveBeenCalledTimes(1); expect(describer).toHaveBeenCalledTimes(1);
+ expect(resolver.mock.calls[0]?.[0]).toHaveLength(1);
+ expect(prompts.join("\n")).toContain("caption from OpenAI fixture");
+ } finally { open.mockRestore(); describer.mockRestore(); resolver.mockRestore(); }
+});
+
+test("TRAE releases a resolved helper probe when image admission is refused", async () => {
+ let released = 0;
+ const sidecarModule = await import("../../src/providers/openai-sidecar");
+ const resolver = spyOn(sidecarModule, "resolveFirstUsableOpenAiSidecar").mockResolvedValue({
+ providerName: "openai", provider: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" },
+ accountMode: "pool", authContext: { kind: "pool", accountId: "fixture" }, headers: new Headers(),
+ releaseProbeLease: () => { released++; },
+ });
+ const plannerModule = await import("../../src/vision/plan");
+ const planner = spyOn(plannerModule, "planVisionSidecar").mockReturnValue(undefined);
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex",
+ authMode: "forward", codexAccountMode: "pool" };
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.visionSidecar = { enabled: true, backend: "openai" };
+ const open = spyOn(traeClient, "openTraeSession");
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false,
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }] }),
+ }), cfg, { model: "", provider: "" });
+ expect(response.status).toBe(400);
+ expect(released).toBe(1); expect(open).not.toHaveBeenCalled(); expect(resolver.mock.calls[0]?.[0]).toHaveLength(1);
+ } finally { open.mockRestore(); resolver.mockRestore(); planner.mockRestore(); }
+});
+
+test("TRAE text-only turns do not resolve an OpenAI helper", async () => {
+ const sidecarModule = await import("../../src/providers/openai-sidecar");
+ const resolver = spyOn(sidecarModule, "resolveFirstUsableOpenAiSidecar");
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex",
+ authMode: "forward", codexAccountMode: "direct" };
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, _text, emit) => { emit({ type: "text_delta", text: "answered" }); emit({ type: "done" }); },
+ close: async () => undefined });
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", input: "plain text", stream: false }),
+ }), cfg, { model: "", provider: "" });
+ expect(response.status, await response.clone().text()).toBe(200);
+ expect(resolver).not.toHaveBeenCalled();
+ } finally { open.mockRestore(); resolver.mockRestore(); }
+});
+
+test("buffered combo binds TRAE error usage before its early failure return", async () => {
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, _text, emit, _signal, dispatch) => {
+ dispatch?.beforeSend(); dispatch?.onSend();
+ emit({ type: "error", status: 502, errorType: "upstream_error", code: "trae_empty_response",
+ message: "fixture failure", retryable: false, usage });
+ },
+ close: async () => undefined });
+ const logCtx: import("../../src/server/request-log").RequestLogContext = { model: "", provider: "" };
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "chain/classified", input: "plain text", stream: false }),
+ }), cfg, logCtx);
+ expect(response.status).toBe(502);
+ expect(logCtx.attempts?.[0]?.usage).toMatchObject(usage);
+ } finally { open.mockRestore(); }
+});
+
+test("TRAE disabled vision refuses images without resolving an OpenAI helper", async () => {
+ const sidecarModule = await import("../../src/providers/openai-sidecar");
+ const resolver = spyOn(sidecarModule, "resolveFirstUsableOpenAiSidecar");
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex",
+ authMode: "forward", codexAccountMode: "direct" };
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.visionSidecar = { enabled: false, backend: "openai" };
+ const open = spyOn(traeClient, "openTraeSession");
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false,
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }] }),
+ }), cfg, { model: "", provider: "" });
+ expect(response.status).toBe(400); expect(resolver).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
+ } finally { open.mockRestore(); resolver.mockRestore(); }
+});
+
+test("TRAE rejects agent and tool-result images before any vision request", async () => {
+ const routed = await import("../../src/vision/routed-describe");
+ const describer = spyOn(routed, "describeImageRouted");
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.providers.vision = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key",
+ authMode: "key", models: ["vision-model"], inputModalities: ["text", "image"] };
+ cfg.visionSidecar = { enabled: true, backend: "routed", model: "vision/vision-model" };
+ const open = spyOn(traeClient, "openTraeSession");
+ const image = { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" };
+ try {
+ for (const item of [
+ { type: "agent_message", role: "user", content: [image] },
+ { type: "function_call_output", call_id: "call_image", output: [image] },
+ ]) {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false, input: [item] }),
+ }), cfg, { model: "", provider: "" });
+ expect(response.status).toBe(400);
+ expect((await response.json() as { error: { code: string } }).error.code).toBe("unsupported_input_modality");
+ }
+ expect(describer).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
+ } finally { open.mockRestore(); describer.mockRestore(); }
+});
+
+test("TRAE rebuilt web-search images still refuse while ordinary search results may be removed", async () => {
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.providers.vision = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key",
+ authMode: "key", models: ["vision-model"], inputModalities: ["text", "image"] };
+ cfg.visionSidecar = { enabled: true, backend: "routed", model: "vision/vision-model" };
+ const routed = await import("../../src/vision/routed-describe");
+ const describer = spyOn(routed, "describeImageRouted");
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, _text, emit) => { emit({ type: "text_delta", text: "answered" }); emit({ type: "done" }); },
+ close: async () => undefined });
+ const search = { type: "function_call", call_id: "ws_fixture", name: "web_search", arguments: "{}" };
+ try {
+ const refused = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false, input: [search,
+ { type: "function_call_output", call_id: "ws_fixture", output: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }] }),
+ }), cfg, { model: "", provider: "" });
+ expect(refused.status).toBe(400);
+ expect(describer).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
+ const admitted = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "trae-cn/test-model", stream: false, input: [search,
+ { type: "function_call_output", call_id: "ws_fixture", output: "result text" },
+ { role: "user", content: "Question" }] }),
+ }), cfg, { model: "", provider: "" });
+ expect(admitted.status, await admitted.clone().text()).toBe(200);
+ } finally { open.mockRestore(); describer.mockRestore(); }
+});
+
+test("Chat policy retry preserves normalized effort and instruction positions", async () => {
+ const { restoreChatInstructionMedia } = await import("../../src/server/responses/chat-policy-media");
+ const { chatToResponsesBody } = await import("../../src/protocols/codecs/chat");
+ const original = { model: "policy/daily--high", stream: false, messages: [
+ { role: "user", content: "earlier question" },
+ { role: "assistant", content: "earlier answer" },
+ { role: "system", content: "first later instruction" },
+ { role: "developer", content: [{ type: "text", text: "later instruction" },
+ { type: "image_url", image_url: { url: "https://fixture.invalid/later.png" } }] },
+ { role: "developer", content: "final later instruction" },
+ { role: "user", content: "current question" },
+ ] };
+ const projected = chatToResponsesBody(original);
+ const restored = restoreChatInstructionMedia(projected, original);
+ const input = restored.input as Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
+ expect(input.map(item => item.role)).toEqual(["user", "assistant", "developer", "developer", "developer", "user"]);
+ expect(input[3]?.content?.some(part => part.type === "input_image")).toBe(true);
+ expect(input[3]?.content?.filter(part => part.type === "input_text")).toHaveLength(1);
+ const settings = { ...projected, model: "policy/daily", reasoning: { effort: "high" }, input: restored.input };
+ expect(settings.reasoning).toEqual({ effort: "high" });
+ expect(settings.model).toBe("policy/daily");
+
+ const leadingOriginal = { model: "policy/daily", messages: [
+ { role: "system", content: [{ type: "text", text: "leading media" },
+ { type: "image_url", image_url: { url: "https://fixture.invalid/leading.png" } }] },
+ { role: "user", content: "question" },
+ ] };
+ const leading = restoreChatInstructionMedia(chatToResponsesBody(leadingOriginal), leadingOriginal);
+ expect((leading.input as Array<{ role: string }>).map(item => item.role)).toEqual(["developer", "user"]);
+ expect(leading.instructions).toBeUndefined();
+
+ const toolOriginal = { model: "policy/daily", messages: [
+ { role: "user", content: "question" },
+ { role: "assistant", tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }] },
+ { role: "developer", content: "wait for the result" },
+ { role: "developer", content: [{ type: "text", text: "inspect this" },
+ { type: "image_url", image_url: { url: "https://fixture.invalid/tool.png" } }] },
+ { role: "tool", tool_call_id: "call_1", content: "result" },
+ { role: "user", content: "next question" },
+ ] };
+ const tool = restoreChatInstructionMedia(chatToResponsesBody(toolOriginal), toolOriginal);
+ expect((tool.input as Array<{ role?: string; type: string }>).map(item => item.role ?? item.type)).toEqual([
+ "user", "function_call", "function_call_output", "developer", "developer", "user",
+ ]);
+});
+
+test("an effort-decorated non-policy Chat request does not retain an original-body clone", async () => {
+ const upstream = Bun.serve({ port: 0, fetch: () => new Response([
+ `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "accepted" } }] })}\n\n`,
+ `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`,
+ "data: [DONE]\n\n",
+ ].join(""), { headers: { "content-type": "text/event-stream" } }) });
+ const cfg = config([]);
+ cfg.defaultProvider = "capable"; cfg.cursorEffortRows = true;
+ cfg.providers.capable = { adapter: "openai-chat", baseUrl: `${upstream.url}v1`, apiKey: "fixture-key",
+ authMode: "key", models: ["accept-model"], allowPrivateNetwork: true };
+ try {
+ const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "capable/accept-model--high", stream: false,
+ messages: [{ role: "user", content: "x".repeat(17 * 1024 * 1024) }] }),
+ }), cfg, { model: "", provider: "" }, {});
+ expect(response.status, await response.clone().text()).toBe(200);
+ } finally { await upstream.stop(true); }
+});
+
+test("the policy-only original Chat clone is admitted by the replay budget", async () => {
+ const cfg = config([]); cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.routingProfiles = { daily: { candidates: [{ provider: "trae-cn", model: "test-model" }] } };
+ const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "policy/daily", stream: false,
+ messages: [{ role: "user", content: "x".repeat(17 * 1024 * 1024) }] }),
+ }), cfg, { model: "", provider: "" }, {});
+ expect(response.status).toBe(413);
+ const json = await response.json() as { error?: { code?: string } };
+ expect(json.error?.code).toBe("translation_buffer_limit");
+});
+
+test("Chat combo fallback retains original developer image media", async () => {
+ const cfg = config([
+ { provider: "trae-cn", model: "test-model" },
+ { provider: "capable", model: "accept-model" },
+ ]);
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ let sent: Record | undefined;
+ cfg.providers.capable = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key",
+ authMode: "key", models: ["accept-model"], inputModalities: ["text", "image"],
+ fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ sent = JSON.parse(String(init?.body));
+ const chunk = (delta: Record, finishReason: string | null = null) =>
+ "data: " + JSON.stringify({ id: "chat-fixture", object: "chat.completion.chunk", model: "accept-model",
+ choices: [{ index: 0, delta, finish_reason: finishReason }] }) + "\n\n";
+ return new Response([chunk({ role: "assistant" }), chunk({ content: "accepted" }),
+ chunk({}, "stop"), "data: [DONE]\n\n"].join(""), { headers: { "content-type": "text/event-stream" } });
+ }) as typeof fetch,
+ };
+ const open = spyOn(traeClient, "openTraeSession");
+ const request = () => new Request("http://localhost/v1/chat/completions", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "chain/classified", stream: false, messages: [
+ { role: "developer", content: [
+ { type: "text", text: "Inspect the attachment" },
+ { type: "image_url", image_url: { url: "https://fixture.invalid/image.png", detail: "high" } },
+ ] },
+ { role: "user", content: "Question" },
+ ] }),
+ });
+ try {
+ const response = await handleChatCompletions(request(), cfg, { model: "", provider: "" }, {});
+ expect(response.status, await response.clone().text()).toBe(200);
+ expect(open).not.toHaveBeenCalled();
+ const wire = JSON.stringify(sent);
+ expect(wire).toContain("https://fixture.invalid/image.png");
+ expect(wire).toContain('\"detail\":\"high\"');
+ expect(wire).toContain("Inspect the attachment");
+ expect(wire.indexOf("Inspect the attachment")).toBe(wire.lastIndexOf("Inspect the attachment"));
+
+ cfg.combos!["classified-chain"]!.imageInput = "disabled";
+ sent = undefined;
+ const denied = await handleChatCompletions(request(), cfg, { model: "", provider: "" }, {});
+ expect(denied.status).toBe(400);
+ expect(sent).toBeUndefined();
+ } finally { open.mockRestore(); }
+});
+
+test("native Chat combo keeps instruction image media for a bridged Google fallback", async () => {
+ const cfg = config([
+ { provider: "trae-cn", model: "test-model" },
+ { provider: "capable", model: "accept-model" },
+ ]);
+ cfg.protocols = { rollout: { nativeChatCombos: true } };
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ let sent: Record | undefined;
+ cfg.providers.capable = { adapter: "google", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key",
+ authMode: "key", models: ["accept-model"], inputModalities: ["text", "image"],
+ fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ sent = JSON.parse(String(init?.body));
+ const frame = (value: unknown) => "data: " + JSON.stringify(value) + "\n\n";
+ return new Response([
+ frame({ candidates: [{ content: { role: "model", parts: [{ text: "accepted" }] } }] }),
+ frame({ candidates: [{ finishReason: "STOP" }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 } }),
+ ].join(""), { headers: { "content-type": "text/event-stream" } });
+ }) as typeof fetch,
+ };
+ const open = spyOn(traeClient, "openTraeSession");
+ try {
+ const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "chain/classified", stream: false, messages: [
+ { role: "developer", content: [
+ { type: "text", text: "Inspect the attachment" },
+ { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8=" } },
+ ] },
+ { role: "user", content: "Question" },
+ ] }),
+ }), cfg, { model: "", provider: "" }, {});
+ expect(response.status, await response.clone().text()).toBe(200);
+ expect(open).not.toHaveBeenCalled();
+ const wire = JSON.stringify(sent);
+ expect(wire).toContain("Inspect the attachment");
+ expect(wire).toContain("aGVsbG8=");
+ } finally { open.mockRestore(); }
+});
+
+for (const media of ["image", "video"]) {
+ test("Chat policy retry retains original instruction media: " + media, async () => {
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false, pinnedReasoningEffort: "low" };
+ cfg.providers.capable = { adapter: media === "video" ? "google" : "openai-chat", baseUrl: "https://fixture.invalid/v1",
+ apiKey: "fixture-key", authMode: "key", models: ["accept-model"], inputModalities: ["text", "image", "video"] };
+ cfg.routingProfiles = { daily: { candidates: [
+ { provider: "trae-cn", model: "test-model" }, { provider: "capable", model: "accept-model" },
+ ] } };
+ cfg.cursorEffortRows = true;
+ const requests: RequestInit[] = [];
+ cfg.providers.capable.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ requests.push(init!);
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const streamed = media === "video"
+ ? new URL(url).pathname.endsWith(":streamGenerateContent")
+ : Boolean(JSON.parse(String(init?.body)).stream);
+ if (media === "video") {
+ if (!streamed) return Response.json({ candidates: [{ content: { role: "model", parts: [{ text: "accepted" }] }, finishReason: "STOP" }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 } });
+ const frame = (value: unknown) => "data: " + JSON.stringify(value) + "\n\n";
+ return new Response([
+ frame({ candidates: [{ content: { role: "model", parts: [{ text: "accepted" }] } }] }),
+ frame({ candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 } }),
+ ].join(""), { headers: { "content-type": "text/event-stream" } });
+ }
+ if (!streamed) return Response.json({ id: "chat-fixture", object: "chat.completion", model: "accept-model",
+ choices: [{ index: 0, message: { role: "assistant", content: "accepted" }, finish_reason: "stop" }],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } });
+ const chunk = (delta: Record, finishReason: string | null = null) =>
+ "data: " + JSON.stringify({ id: "chat-fixture", object: "chat.completion.chunk", model: "accept-model",
+ choices: [{ index: 0, delta, finish_reason: finishReason }] }) + "\n\n";
+ return new Response([chunk({ role: "assistant" }), chunk({ content: "accepted" }), chunk({}, "stop"), "data: [DONE]\n\n"].join(""),
+ { headers: { "content-type": "text/event-stream" } });
+ }) as typeof fetch;
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, _text, emit) => {
+ emit({ type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality",
+ message: "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported.", retryable: false });
+ }, close: async () => undefined });
+ const attachment = media === "image"
+ ? { type: "image_url", image_url: { url: "https://fixture.invalid/image.png", detail: "high" } }
+ : { type: "video_url", video_url: { url: "data:video/mp4;base64,AAAAIGZ0eXBpc29t" } };
+ const messages = [
+ { role: "system", content: "Leading instruction" },
+ { role: "user", content: "Earlier question" },
+ { role: "assistant", content: "Earlier answer" },
+ { role: "developer", content: "First later instruction" },
+ { role: "system", content: [{ type: "text", text: "Inspect the attachment" }, attachment] },
+ { role: "developer", content: "Final later instruction" },
+ { role: "user", content: "Question" },
+ ];
+ try {
+ const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "policy/daily--high", reasoning_effort: "low", stream: false, messages }),
+ }), cfg, { model: "", provider: "" }, {});
+ expect(response.status, await response.clone().text()).toBe(200);
+ expect(requests).toHaveLength(1);
+ const sent = JSON.parse(String(requests[0]?.body)) as {
+ reasoning_effort?: string;
+ messages?: Array<{ role?: string; content?: Array<{ type: string }> }>;
+ };
+ const wire = JSON.stringify(sent);
+ const chronology = ["Earlier answer", "First later instruction", "Inspect the attachment", "Final later instruction", "Question"];
+ const positions = chronology.map(text => wire.indexOf(text));
+ expect(positions.every(position => position >= 0)).toBe(true);
+ expect(positions).toEqual([...positions].sort((left, right) => left - right));
+ expect(wire.indexOf("Inspect the attachment")).toBe(wire.lastIndexOf("Inspect the attachment"));
+ if (media === "image") {
+ expect(sent.reasoning_effort).toBe("high");
+ expect(wire).toContain('"detail":"high"');
+ expect(sent.messages!.some(message => message.role === "user" && Array.isArray(message.content)
+ && message.content.some(part => part.type === "image_url"))).toBe(true);
+ expect(sent.messages!.at(-1)?.content).toBe("Question");
+ } else {
+ expect(JSON.stringify(sent)).toContain("inline_data");
+ expect(JSON.stringify(sent)).toContain("video/mp4");
+ }
+ } finally { open.mockRestore(); }
+ });
+}
+
+for (const media of ["image", "video"]) for (const wire of ["responses", "chat"]) for (const combo of [false, true]) {
+ test("TRAE instruction media are refused at HTTP ingress: media=" + media + ", wire=" + wire + ", combo=" + combo, async () => {
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, _text, emit) => { emit({ type: "text_delta", text: "attachment was lost" }); emit({ type: "done" }); },
+ close: async () => undefined,
+ });
+ const model = combo ? "chain/classified" : "trae-cn/test-model";
+ const image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a/nkAAAAASUVORK5CYII=";
+ const attachment = wire === "responses"
+ ? media === "image" ? { type: "input_image", image_url: image } : { type: "input_video", video_url: "https://fixture.invalid/clip.mp4" }
+ : media === "image" ? { type: "image_url", image_url: { url: image } } : { type: "video_url", video_url: { url: "https://fixture.invalid/clip.mp4" } };
+ const body = wire === "responses" ? { model, stream: false, input: [
+ { role: "system", content: [{ type: "input_text", text: "Describe the attachment" }, attachment] },
+ { role: "user", content: "Question" },
+ ] } : { model, stream: false, messages: [
+ { role: "system", content: [{ type: "text", text: "Describe the attachment" }, attachment] },
+ { role: "user", content: "Question" },
+ ] };
+ try {
+ const request = new Request("http://localhost/v1/" + (wire === "responses" ? "responses" : "chat/completions"), {
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
+ });
+ const response = await (wire === "responses" ? handleResponses : handleChatCompletions)(request, cfg, { model: "", provider: "" });
+ expect(response.status).toBe(400);
+ const refused = await response.json() as { error: { code: string; type: string; message: string } };
+ expect(refused.error).toMatchObject({ type: "invalid_request_error",
+ code: wire === "responses" && !combo ? "unsupported_input_modality" : "invalid_request_error" });
+ expect(refused.error.message).toContain("TRAE CN ACP supports text conversations only");
+ expect(open).not.toHaveBeenCalled(); expect(fallbackCalls).toBe(0);
+ } finally { open.mockRestore(); }
+ });
+}
+
+test("policy can select another eligible target after a pre-dispatch TRAE media refusal", async () => {
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ cfg.providers.capable = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key",
+ authMode: "key", models: ["accept-model"], inputModalities: ["text", "image"] };
+ const trace = { version: 1 as const, decisionId: "trae-media-policy", createdAt: 1, requestedModel: "policy/daily",
+ routeKind: "policy" as const, profile: { id: "daily", revision: "fixture" }, requirements: [],
+ candidates: [
+ { provider: "trae-cn", model: "test-model", eligible: true, exclusions: [], score: { total: 1, components: {} } },
+ { provider: "capable", model: "accept-model", eligible: true, exclusions: [], score: { total: 0.5, components: {} } },
+ ], selected: { candidateIndex: 0, provider: "trae-cn", model: "test-model", reason: "highest-score" } };
+ const input = [{ role: "system", content: [{ type: "input_image", image_url: "https://fixture.invalid/image.png" }] },
+ { role: "user", content: "Question" }];
+ const open = spyOn(traeClient, "openTraeSession").mockRejectedValue(new Error("fixture must not start CLI"));
+ let calls = 0;
+ try {
+ const response = await handleResponsesWithPolicyFallback(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "policy/daily", input, stream: false }),
+ }), cfg, {}, {}, { runCore: async (req, next, context, options) => {
+ const body = await req.json() as Record;
+ options.onRequestBodyParsed?.(body); calls++;
+ if (calls === 1) {
+ const concrete = new Request(req.url, { method: "POST", headers: req.headers,
+ body: JSON.stringify({ ...body, model: "trae-cn/test-model" }) });
+ const refused = await responsesCore(concrete, next, context, options);
+ context.routeDecision = trace; context.requestedModel = "policy/daily";
+ return refused;
+ }
+ expect(body.model).toBe("capable/accept-model"); expect(body.input).toEqual(input);
+ return Response.json({ status: "completed" });
+ } });
+ expect(response.status).toBe(200); expect(calls).toBe(2); expect(open).not.toHaveBeenCalled();
+ await response.text();
+ } finally { open.mockRestore(); }
+});
+
+for (const wire of ["responses", "chat"]) {
+ test("TRAE preserves supported text instruction aliases: wire=" + wire, async () => {
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.defaultProvider = "trae-cn";
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ const prompts: string[] = [];
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, text, emit) => { prompts.push(text); emit({ type: "text_delta", text: "accepted" }); emit({ type: "done" }); },
+ close: async () => undefined,
+ });
+ const content: unknown[] = [{ type: "text", text: "Plain alias" }, { type: "input_text", text: "Input alias" }];
+ if (wire === "chat") content.push({ type: "output_text", text: "Output alias" }, "String instruction");
+ const body = { model: "trae-cn/test-model", stream: false,
+ [wire === "responses" ? "input" : "messages"]: [{ role: "system", content }, { role: "user", content: "Question" }] };
+ try {
+ const request = new Request("http://localhost/v1/" + (wire === "responses" ? "responses" : "chat/completions"), {
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
+ });
+ const response = await (wire === "responses" ? handleResponses : handleChatCompletions)(request, cfg, { model: "", provider: "" });
+ expect(response.status).toBe(200);
+ await response.text();
+ expect(open).toHaveBeenCalledTimes(1); expect(prompts).toHaveLength(1);
+ expect(prompts[0]).toContain("Plain alias"); expect(prompts[0]).toContain("Input alias");
+ if (wire === "chat") { expect(prompts[0]).toContain("Output alias"); expect(prompts[0]).toContain("String instruction"); }
+ } finally { open.mockRestore(); }
+ });
+}
+
+test("cancelled runTurn streams retain received usage after the consumer stops draining", async () => {
+ const controller = new AbortController();
+ const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 };
+ let finishPrompt!: () => void;
+ const pending = new Promise(resolve => { finishPrompt = resolve; });
+ let finished!: () => void;
+ const completed = new Promise(resolve => { finished = resolve; });
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }]);
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true,
+ authMode: "key", models: ["test-model"], liveModels: false };
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({ models: ["test-model"],
+ prompt: async (_model, _text, emit, _signal, dispatch) => {
+ dispatch?.beforeSend(); dispatch?.onSend();
+ emit({ type: "text_delta", text: "received" }); emit({ type: "done", usage });
+ await pending;
+ },
+ close: async () => { finished(); },
+ });
+ const log = { model: "", provider: "" };
+ try {
+ const response = await handleResponses(new Request("http://localhost/v1/responses", {
+ method: "POST", headers: { "content-type": "application/json" }, signal: controller.signal,
+ body: JSON.stringify({ model: "chain/classified", input: "request", stream: true }),
+ }), cfg, log);
+ await response.body!.cancel();
+ controller.abort(); finishPrompt(); await completed;
+ for (let tick = 0; tick < 20 && !(log as unknown as { usage?: unknown }).usage; tick++) await new Promise(resolve => setTimeout(resolve, 5));
+ expect((log as unknown as { usage?: unknown }).usage).toMatchObject(usage);
+ expect(fallbackCalls).toBe(0);
+ } finally { finishPrompt(); open.mockRestore(); }
+});
+
let home = "";
let releaseSpendHome: (() => void) | undefined;
const originalHome = process.env.OPENCODEX_HOME;
@@ -116,6 +751,72 @@ afterEach(() => {
});
for (const stream of [false, true]) {
+ test(`an empty TRAE completion stops the combo after its accepted prompt (stream=${stream})`, async () => {
+ childFailure = { type: "error", status: 502, errorType: "upstream_error", code: "trae_empty_response",
+ message: "TRAE CN ended without a text response.", retryable: false, usage: { inputTokens: 12, outputTokens: 0 } };
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }, { provider: "fallback", model: MODEL }]);
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true, authMode: "key", models: ["test-model"], liveModels: false };
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({
+ models: ["test-model"],
+ async prompt(_model, _text, emit, _signal, dispatch) {
+ runTurnCalls++;
+ dispatch?.beforeSend();
+ dispatch?.onSend();
+ emit(childFailure!);
+ },
+ async close() {},
+ });
+ try {
+ const response = await respond(cfg, stream);
+ expect(response.status).toBe(502);
+ // Client error projection keeps its closed public vocabulary.
+ expect(JSON.parse(response.text).error.code).toBe("upstream_server_error");
+ expect(runTurnCalls).toBe(1);
+ expect(fallbackCalls).toBe(0);
+ } finally { open.mockRestore(); }
+ });
+
+ test(`a TRAE startup failure may fail over before any prompt is dispatched (stream=${stream})`, async () => {
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }, { provider: "fallback", model: MODEL }]);
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true, authMode: "key", models: ["test-model"], liveModels: false };
+ const open = spyOn(traeClient, "openTraeSession").mockRejectedValue(new traeClient.TraeAcpError("TRAE CN CLI not found.", "cli_not_found"));
+ try {
+ const response = await respond(cfg, stream);
+ expect(response.status).toBe(200);
+ expect(response.text).toContain("fallback answered");
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(runTurnCalls).toBe(0);
+ expect(fallbackCalls).toBe(1);
+ } finally { open.mockRestore(); }
+ });
+
+ test(`an unavailable TRAE model may fail over before any prompt is dispatched (stream=${stream})`, async () => {
+ const cfg = config([{ provider: "trae-cn", model: "test-model" }, { provider: "fallback", model: MODEL }]);
+ cfg.providers["trae-cn"] = { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true, authMode: "key", models: ["test-model"], liveModels: false };
+ let promptCalls = 0;
+ let promptSends = 0;
+ const open = spyOn(traeClient, "openTraeSession").mockResolvedValue({
+ models: ["other-model"],
+ async prompt(modelId, _text, _emit, _signal, dispatch) {
+ promptCalls++;
+ if (modelId !== "other-model") {
+ throw new traeClient.TraeAcpError(`TRAE CN model is not available to this account: ${modelId}`, "trae_model_unavailable");
+ }
+ dispatch?.beforeSend(); dispatch?.onSend(); promptSends++;
+ },
+ async close() {},
+ });
+ try {
+ const response = await respond(cfg, stream);
+ expect(response.status).toBe(200);
+ expect(response.text).toContain("fallback answered");
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(promptCalls).toBe(1);
+ expect(promptSends).toBe(0);
+ expect(fallbackCalls).toBe(1);
+ } finally { open.mockRestore(); }
+ });
+
for (const status of [400, 422]) {
for (const code of [undefined, "invalid_argument"]) {
test(`reflected quota prose stays terminal through runTurn (stream=${stream}, status=${status}, code=${code})`, async () => {