From 1e3a5d6fc0a635b267aa90d57f592798d1288b2f Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Sun, 8 Nov 2026 14:58:13 +0800 Subject: [PATCH 01/64] feat(trae-cn): add official ACP provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 Codex 编写 --- .../docs/fr/getting-started/quickstart.md | 2 +- .../src/content/docs/fr/guides/providers.md | 2 +- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/providers.md | 38 ++- .../docs/ja/getting-started/quickstart.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../docs/ru/getting-started/quickstart.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../docs/tr/getting-started/quickstart.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../docs/zh-tw/getting-started/quickstart.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- gui/src/components/add-provider-form-pane.tsx | 4 +- .../provider-workspace/subscription-cli.ts | 7 +- src/adapters/registry.ts | 7 + src/adapters/trae-cn/adapter.ts | 91 +++++++ src/adapters/trae-cn/client.ts | 2 + src/adapters/trae-cn/profile.ts | 24 ++ src/adapters/trae-cn/protocol.ts | 110 ++++++++ src/adapters/trae-cn/session.ts | 248 ++++++++++++++++++ src/codex/catalog/provider-models.ts | 4 + src/codex/catalog/trae-cn-models.ts | 57 ++++ src/providers/registry/entries-extended.ts | 19 ++ src/providers/trae-cn-models.ts | 8 + structure/INDEX.md | 3 + structure/manifest.json | 7 + structure/ops/docs-and-release.md | 2 +- structure/providers/trae-cn.md | 10 + .../adapter-registry-authority.test.ts | 3 + .../adapters/adapter-tool-conformance.test.ts | 2 +- .../provider-registry-parity.test.ts | 2 +- 35 files changed, 654 insertions(+), 24 deletions(-) create mode 100644 src/adapters/trae-cn/adapter.ts create mode 100644 src/adapters/trae-cn/client.ts create mode 100644 src/adapters/trae-cn/profile.ts create mode 100644 src/adapters/trae-cn/protocol.ts create mode 100644 src/adapters/trae-cn/session.ts create mode 100644 src/codex/catalog/trae-cn-models.ts create mode 100644 src/providers/trae-cn-models.ts create mode 100644 structure/providers/trae-cn.md 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 986a1c11ef9..bf01104632d 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 106 préréglages intégrés au registre, ou `custom` pour saisir une +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}`. 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. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 8d8a85f0a8d..dbc026ea5bc 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -310,7 +310,7 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -opencodex fournit 106 préréglages intégrés : 85 à clé, 17 OAuth, trois locaux et un préréglage par défaut de +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 910fb0e181e..cfb968ec901 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -20,7 +20,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 106 built-in registry presets or `custom` to type a base +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}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 380a99d72c1..51a37b49f91 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -779,7 +779,7 @@ On upgrade, startup adds Pro to a saved model list only when it exactly matches ## 3. API-key catalog -opencodex ships 106 built-in presets: 85 key-based, 17 OAuth, three local, and one default +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: @@ -1455,6 +1455,42 @@ 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) + +The `trae-cn` preset uses the signed-in official TRAE CN CLI through +`traecli acp serve`. OpenCodex does not read desktop tokens, private IDE endpoints, or Ferry. + +```json +{ + "providers": { + "trae-cn": { + "adapter": "trae-cn", + "baseUrl": "https://www.trae.cn" + } + } +} +``` + +- **Prerequisites:** Install and sign in to [TRAE CN](https://www.trae.cn/) and its official CLI. + The CLI owns authentication and token refresh; the provider needs no API key. +- **Model discovery:** `session/new` returns the models available to the signed-in account. + Successful discovery is cached by the normal provider catalog mechanism, with the last good + catalog and a small text-only seed as degraded fallbacks. +- **Transport:** Each turn creates a fresh ACP session, selects the requested model with + `session/set_model`, and maps `agent_message_chunk` and `agent_thought_chunk` to OpenCodex text + and reasoning events. Conversation history is replayed as labelled text. +- **Capability boundary:** This initial provider supports text conversations only. It rejects + client tools, tool replay, and image/audio input. Permission requests from the CLI receive an + error. Internal tool notifications are not converted into client tool calls, and a turn that + ends without text is an error rather than an empty successful answer. +- **Process isolation:** The CLI uses an empty temporary workspace, no session MCP servers or + selected skills, and disallow rules for file and command tools. OpenCodex terminates the child + after the turn, on cancellation, or on protocol failure. Any tool notification or permission + request ends the turn with an error instead of being executed by OpenCodex. +- **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`. + ### 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/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index 82f68cdf175..cfd4f95bca9 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 106 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 107 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index e94d4842f7c..1d68dbc3b9c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -210,7 +210,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 106 個含まれています。キー方式 85、OAuth 17、ローカル 3、 +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 8165688935d..bceda554cfe 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 106개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 107개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index aa3b3f3aff1..a0379d62368 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -213,7 +213,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 106개 들어 있습니다. 키 방식 85개, OAuth 17개, 로컬 3개, +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 2769427a577..33aae9d93b5 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 106 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 107 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 526b10bd03e..e3f8880e7d6 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -222,7 +222,7 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -opencodex поставляется с 106 встроенными пресетами: 85 на основе ключей, 17 OAuth, три локальных и +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 61a9c46577b..e346dcca33a 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 106 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 107 önayardan birini 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. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index c25565978bf..a6518b27291 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -342,7 +342,7 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -opencodex 106 yerleşik önayar ile birlikte gelir: 85 anahtar tabanlı, 17 +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, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer 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 66c4c0e6054..dd1dc17cced 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 @@ -18,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 106 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 107 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 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 d6db5751079..629e47937ba 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -222,7 +222,7 @@ API Key 提供商,也不负责让 WorkBuddy 桌面客户端使用 OpenCodex ## 3. API 密钥目录 -opencodex 内置 106 个预设:85 个密钥预设、17 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +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 f7591a8a0ea..1b97eb24aaf 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 @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 106 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 107 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。 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 5f8d0e32108..af01df1a2c7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -264,7 +264,7 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -opencodex 內建 106 個 preset:85 個 key-based、17 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +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..d7bbbcec401 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.note + ? preset.note + : }
) : preset.keyOptional ? ( 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/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..1864a093e02 --- /dev/null +++ b/src/adapters/trae-cn/adapter.ts @@ -0,0 +1,91 @@ +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { SendBudgetExhaustedError } from "../../lib/upstream-retry"; +import type { AdapterRequest, ProviderAdapter } from "../base"; +import { requiresToolCall } from "../../types"; +import { buildSystemPrompt, formatMessageForHistory } from "../coding-agent/protocol"; +import { openTraeSession, TraeAcpError, type TraeAcpDeps } from "./client"; + +export type TraeCnAdapterDeps = TraeAcpDeps; + +function replayText(parsed: OcxParsedRequest): string { + const messages = parsed.context.messages.filter(message => message.role !== "developer"); + const system = buildSystemPrompt(parsed); + const history = messages.slice(0, -1).map(message => formatMessageForHistory(message)).filter(Boolean).join("\n\n"); + const current = messages.at(-1); + const request = current ? formatMessageForHistory(current) : "USER:\n"; + return [ + system ? `\n${system}\n` : "", + "The following labelled blocks replay a conversation. Only the last block is the current request. 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").slice(-1_000_000); +} + +function validateInput(parsed: OcxParsedRequest): { message: string; code: string } | undefined { + 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 (parsed.context.messages.some(message => + (Array.isArray(message.content) && message.content.some(part => part.type === "image" || part.type === "audio")) + || (message.role === "assistant" && message.content.some(part => part.type === "toolCall")) + || message.role === "toolResult")) { + return { message: "TRAE CN ACP currently supports text conversations only; images and tool replay are not supported.", code: "unsupported_input_modality" }; + } + return undefined; +} + +function errorEvent(error: unknown): Extract { + const acp = error instanceof TraeAcpError; + const code = acp ? error.code : "trae_acp_protocol_error"; + const auth = acp && /invalid token|unauthorized|not logged in|authentication/i.test(error.message); + const message = error instanceof Error ? error.message : "TRAE CN ACP ended with an invalid protocol response."; + return { + type: "error", + message: auth ? `${message} Sign in through TRAE CN and retry.` : message.slice(0, 512), + status: code === "trae_model_unavailable" ? 400 : auth ? 401 : 502, + errorType: code === "trae_model_unavailable" ? "invalid_request_error" : auth ? "authentication_error" : "upstream_error", + code: auth ? "trae_cn_not_logged_in" : code, + retryable: false, + }; +} + +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?.(); + try { + const decision = incoming.sendBudget?.reserveDispatch({ sendClass: "initial", targetKey: `trae-cn|${parsed.modelId}` }); + if (decision && !decision.allowed) throw new SendBudgetExhaustedError(); + incoming.beforePhysicalSend?.(); + if (decision?.allowed && !decision.permit.use()) throw new SendBudgetExhaustedError(); + const session = await openTraeSession(provider.baseUrl, deps); + incoming.onPhysicalSend?.({ ordinal: 1 }); + try { + await session.prompt(parsed.modelId, replayText(parsed), emit, incoming.abortSignal); + } finally { + await session.close(); + } + } catch (error) { + if (error instanceof SendBudgetExhaustedError) throw error; + emit(errorEvent(error)); + } 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/profile.ts b/src/adapters/trae-cn/profile.ts new file mode 100644 index 00000000000..536a7477196 --- /dev/null +++ b/src/adapters/trae-cn/profile.ts @@ -0,0 +1,24 @@ +import { existsSync } from "node:fs"; +import { join } 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"; + +export function traeCnDestinationMatches(baseUrl: string | undefined): boolean { + return baseUrl?.replace(/\/+$/, "").toLowerCase() === TRAE_CN_BASE_URL; +} + +export function traeCnChildEnv(): Record { + return { ...baseScopedEnv(), NO_COLOR: "1" }; +} + +export function resolveTraeCli(deps: PathLookupDeps = {}): string | undefined { + const onPath = whichFromPath("traecli", deps); + if (onPath) return onPath; + const platform = deps.platform ?? process.platform; + const home = (deps.env ?? process.env)[platform === "win32" ? "USERPROFILE" : "HOME"]; + if (!home) return undefined; + const local = join(home, ".local", "bin", platform === "win32" ? "traecli.exe" : "traecli"); + return (deps.exists ?? existsSync)(local) ? local : undefined; +} diff --git a/src/adapters/trae-cn/protocol.ts b/src/adapters/trae-cn/protocol.ts new file mode 100644 index 00000000000..0bc429f7169 --- /dev/null +++ b/src/adapters/trae-cn/protocol.ts @@ -0,0 +1,110 @@ +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 }; + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +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 }; +} + +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]; +} + +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; +} + +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 []; +} + +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 === "refusal") return "refusal"; + if (stop === "cancelled") return "cancelled"; + throw new TraeAcpProtocolError(`Unsupported ACP stop reason: ${String(stop)}`); +} + +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..064734e8578 --- /dev/null +++ b/src/adapters/trae-cn/session.ts @@ -0,0 +1,248 @@ +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 } from "../../types"; +import { readJsonLines } from "../coding-agent/protocol"; +import { resolveTraeCli, traeCnChildEnv, traeCnDestinationMatches } from "./profile"; +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; +} + +export class TraeAcpError extends Error { + constructor(message: string, readonly code = "trae_acp_error") { + super(message); + this.name = "TraeAcpError"; + } +} + +type Pending = { resolve: (result: unknown) => void; reject: (error: Error) => void; timer: ReturnType }; + +class TraeAcpClient { + private nextId = 0; + private readonly pending = new Map(); + private readonly updates: TraeAcpFrame[] = []; + private readonly waiters: Array<(frame: TraeAcpFrame | null) => void> = []; + private readonly stderr: string[] = []; + private readonly reader: Promise; + private readonly lifecycle: Promise; + private failure?: Error; + private closed = false; + private closing?: Promise; + + constructor(private readonly child: ChildProcess, private readonly timeoutMs: number) { + this.lifecycle = new Promise(resolve => { + child.once("error", error => { this.failure ??= error; this.fail(error); }); + child.once("close", resolve); + if (child.exitCode !== null) resolve(); + }); + child.stderr?.on("data", (chunk: Uint8Array) => { + this.stderr.push(new TextDecoder().decode(chunk)); + while (this.stderr.join("").length > 8192) this.stderr.shift(); + }); + this.reader = this.read(); + this.reader.catch(() => undefined); + } + + 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); + } + + 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; + } + 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) { + this.fail(error instanceof Error ? error : new Error(String(error))); + } + } + + 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`); + } + + async request(method: string, params: Record): 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, reject, timer }); + }); + this.write({ jsonrpc: "2.0", id, method, params }); + return result; + } + + nextUpdate(): Promise { + const queued = this.updates.shift(); + if (queued) return Promise.resolve(queued); + if (this.closed) return Promise.resolve(null); + return new Promise(resolve => this.waiters.push(resolve)); + } + + diagnostic(): string { + return redactSecretString(this.failure?.message || this.stderr.join("")).trim().slice(0, 512); + } + + async close(): Promise { + if (this.closing) return this.closing; + this.closing = (async () => { + this.child.kill("SIGTERM"); + const timer = setTimeout(() => this.child.kill("SIGKILL"), 2000); + try { await this.lifecycle; } finally { clearTimeout(timer); } + this.fail(new TraeAcpError("TRAE CN ACP session closed", "trae_acp_closed")); + await this.reader; + })(); + return this.closing; + } +} + +export interface TraeSession { + readonly models: string[]; + prompt(modelId: string, text: string, emit: (event: AdapterEvent) => void, signal?: AbortSignal): Promise; + close(): Promise; +} + +export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcpDeps = {}): Promise { + if (!traeCnDestinationMatches(baseUrl)) throw new TraeAcpError("TRAE CN requires its canonical provider destination", "non_canonical_destination"); + const binary = (deps.resolveBinary ?? resolveTraeCli)(); + if (!binary) throw new TraeAcpError("TRAE CN CLI not found. Install traecli and sign in through TRAE CN.", "cli_not_found"); + const env = traeCnChildEnv(); + const ownHome = deps.home === undefined; + const home = deps.home ?? await mkdtemp(join(tmpdir(), "ocx-trae-cn-")); + const work = deps.cwd ?? await mkdtemp(join(tmpdir(), "ocx-trae-cn-work-")); + const cleanup = async (): Promise => { + 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"]; + const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env }); + let child: ChildProcess; + try { + child = (deps.spawn ?? nodeSpawn)(invocation.file, invocation.args, { + ...invocation.options, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, + cwd: deps.cwd ?? work, + }); + } catch (error) { + await cleanup(); + throw error; + } + const client = new TraeAcpClient(child, deps.timeoutMs ?? 300_000); + let opened = false; + try { + await client.request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "opencodex", title: "OpenCodex", version: "1" }, + }); + 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) { + 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(); }; + signal?.addEventListener("abort", abort, { once: true }); + let completion: Promise | undefined; + try { + await client.request("session/set_model", { sessionId, modelId }); + let output = false; + let sawTool = false; + completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }); + while (true) { + const frame = await Promise.race([ + client.nextUpdate(), + completion.then(() => null, () => null), + ]); + if (!frame) break; + if (frame.kind !== "update" || frame.sessionId !== sessionId) continue; + if (frame.update.sessionUpdate === "tool_call" || frame.update.sessionUpdate === "tool_call_update") sawTool = true; + for (const event of traePromptEvents(sessionId, frame.update)) { + if (event.type === "text_delta") output = true; + emit(event); + if (event.type === "error") { + client.write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } }); + await client.close(); + return; + } + } + } + const result = await completion; + const stop = traeStopReason(result); + if (stop === "cancelled") return; + if (stop !== "stop" && stop !== "max_tokens") throw new TraeAcpError(`TRAE CN stopped the turn: ${stop}`, "trae_turn_stopped"); + if (!output) throw new TraeAcpError(sawTool + ? "TRAE CN ended after an internal tool turn without a text response. Retry the text request or select another model." + : "TRAE CN ended without a text response.", "trae_empty_response"); + const usage = traeUsage(result); + emit({ type: "done", stopReason: stop, ...(usage ? { usage } : {}) }); + } finally { + signal?.removeEventListener("abort", abort); + completion?.catch(() => undefined); + } + }, + close: async () => { + await client.close(); + await cleanup(); + }, + }; + } catch (error) { + if (error instanceof TraeAcpError && error.code === "trae_acp_closed") { + const detail = client.diagnostic(); + if (detail && detail !== error.message) throw new TraeAcpError(detail, error.code); + } + throw error; + } finally { + if (!opened) { + await client.close().catch(() => undefined); + await cleanup(); + } + } +} diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 91e8bb9b3fa..a18e47e1327 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -122,6 +122,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"; @@ -243,6 +244,9 @@ export async function fetchProviderModelsWithAuth( } return merged; }; + if (prov.adapter === "trae-cn") { + return fetchTraeCnProviderModels(captured, ttlMs, contextCap, configured, withConfiguredRetention); + } // 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/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts new file mode 100644 index 00000000000..4eda4d5810e --- /dev/null +++ b/src/codex/catalog/trae-cn-models.ts @@ -0,0 +1,57 @@ +import { openTraeSession } from "../../adapters/trae-cn/client"; +import { TRAE_CN_BASE_URL } from "../../adapters/trae-cn/profile"; +import { + captureModelCacheGeneration, + getFreshCached, + getStaleCached, + isModelCacheGenerationCurrent, + isModelsFetchCoolingDown, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + observeModelCacheRevision, + 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"; + +export async function fetchTraeCnProviderModels( + captured: CapturedProviderGather, + ttlMs: number, + contextCap: number | undefined, + configured: CatalogModel[], + withConfiguredRetention: (models: CatalogModel[]) => CatalogModel[], +): Promise { + const { name, provider, metadataModelIdCaseFold, metadataConfigDir } = captured; + const observed = (models: CatalogModel[], state: ProviderModelsResult["outcome"]["state"]): ProviderModelsResult => ({ + models, outcome: { provider: name, state }, contentRevision: observeModelCacheRevision(name), + }); + const fresh = getFreshCached(name, ttlMs, Date.now()); + if (fresh) return observed(withConfiguredRetention(fresh), "authoritative"); + const stale = getStaleCached(name); + if (isModelsFetchCoolingDown(name) && stale) return observed(withConfiguredRetention(stale), "degraded"); + const generation = captureModelCacheGeneration(name); + if (provider.baseUrl?.replace(/\/+$/, "").toLowerCase() !== TRAE_CN_BASE_URL) { + markProviderDiscoveryFailed(name, { reason: "blocked" }); + return observed(stale ?? configured, "degraded"); + } + try { + const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000 }); + await session.close(); + if (!isModelCacheGenerationCurrent(name, generation)) return observed(stale ?? configured, "degraded"); + 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 observed(stale ?? configured, "degraded"); + markProviderDiscoveryOk(name, models.length); + return observed(withConfiguredRetention(models), "authoritative"); + } catch { + if (isModelCacheGenerationCurrent(name, generation)) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); + } + return observed(stale ?? configured, "degraded"); + } +} diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index d81f2694dd5..84434830805 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -25,6 +25,7 @@ import { CODEBUDDY_REASONING_EFFORTS, } from "../codebuddy-models"; import { QODER_CN_MODELS, QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "../qoder-models"; +import { TRAE_CN_MODELS } from "../trae-cn-models"; import type { ProviderRegistryEntry } from "./types"; import { ZAI_GLM_53_MODELS, @@ -129,6 +130,24 @@ import { } from "./model-seeds"; export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ + { + // The official TRAE CN CLI owns authentication and transport through its documented ACP + // server. No desktop credential or private IDE endpoint enters OpenCodex. + id: "trae-cn", + label: "TRAE CN (ACP)", + adapter: "trae-cn", + baseUrl: "https://www.trae.cn", + authKind: "key", + keyOptional: true, + preserveCustomDestination: true, + dashboardUrl: "https://docs.trae.cn/cli_agent-client-protocol", + defaultModel: "Doubao-Seed-1.8", + models: [...TRAE_CN_MODELS], + liveModels: true, + noVisionModels: [...TRAE_CN_MODELS], + reasoningEfforts: [], + note: "Uses the signed-in official traecli through `traecli acp serve`; OpenCodex stores no TRAE token. Requires TRAE CN CLI and a signed-in account. The initial adapter supports text and reasoning streams; client tools and images are rejected explicitly.", + }, { // Verified 2026-09-21: docs.typesafe.ai/introduction/quickstart and /api document the fixed // endpoint, Bearer auth, jev-latest, and TYPESAFE_API_KEY; typesafe.ai/legal/mca permits API integration. 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/structure/INDEX.md b/structure/INDEX.md index 86ee6f30b0e..f6f254dbd0f 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. | @@ -230,6 +231,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/openai-responses/passthrough.ts` | [`transports/responses-citations.md`](transports/responses-citations.md)
[`providers/opengateway.md`](providers/opengateway.md)
[`providers/codex-installation-identity.md`](providers/codex-installation-identity.md) | | `src/adapters/openai-responses/request-strips.ts` | [`transports/responses-citations.md`](transports/responses-citations.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) | @@ -282,6 +284,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) | diff --git a/structure/manifest.json b/structure/manifest.json index 6d04de59b46..65d4c5a67f1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -642,6 +642,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"] + }, { "path": "providers-and-adapters.md", "tier": 4, diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 2e5ade6ecaa..a5c71fb16f5 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -86,7 +86,7 @@ Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a publ sidebar and either add localized copies or intentionally accept Starlight fallback behavior. Provider preset totals are recounted from the current registry when a preset lands. The -documented split is 106 total: 85 key-based, 17 OAuth, three local, and one default +documented split is 107 total: 86 key-based, 17 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight quickstarts carry the same counts. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md new file mode 100644 index 00000000000..f8d6bb09d09 --- /dev/null +++ b/structure/providers/trae-cn.md @@ -0,0 +1,10 @@ +# TRAE CN ACP + +`src/adapters/trae-cn/` runs the official TRAE CN CLI's ACP server for the `trae-cn` registry row. +TRAE owns login and refresh; OpenCodex stores no TRAE token. A fresh session supplies the account +model roster and one selected-model turn. 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, and an internal tool turn without answer text fails with `trae_empty_response`. 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 and uses the shared stale/static fallback path. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index e1873cbed20..a00793f8836 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -27,6 +27,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 +42,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" 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/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 64b76d30744..68abf8a5014 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1211,7 +1211,7 @@ describe("provider registry parity", () => { expect(providerConfigSeed(litellm!).keyOptional).toBe(true); // claude-cli joins them as the first CLI-backed member: its row is `key` because the turn // leaves this machine, and keyless because the Claude Code CLI reads the operator's sign-in. - expect(optionalKeyProviders).toEqual(["litellm", "mimo-free", "claude-cli"]); + expect(optionalKeyProviders).toEqual(["trae-cn", "litellm", "mimo-free", "claude-cli"]); }); test("NVIDIA NIM is free-tier priced but still requires an API key", () => { From 1c9f8f2ce47ae2eda27bd7bf24afb9542c0edf84 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 17:45:29 +0800 Subject: [PATCH 02/64] fix(trae-cn): address ACP lifecycle and catalog review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 Codex 编写 --- .../src/content/docs/fr/guides/providers.md | 2 + .../docs/getting-started/quickstart.md | 2 + .../src/content/docs/guides/providers.md | 12 +- .../src/content/docs/ja/guides/providers.md | 2 + .../src/content/docs/ko/guides/providers.md | 2 + .../src/content/docs/ru/guides/providers.md | 2 + .../src/content/docs/tr/guides/providers.md | 2 + .../content/docs/zh-cn/guides/providers.md | 2 + .../content/docs/zh-tw/guides/providers.md | 2 + gui/src/components/add-provider-form-pane.tsx | 4 +- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + src/adapters/trae-cn/adapter.ts | 36 ++- src/adapters/trae-cn/profile.ts | 5 +- src/adapters/trae-cn/protocol.ts | 7 + src/adapters/trae-cn/session.ts | 102 ++++-- src/codex/catalog/provider-models.ts | 4 +- src/codex/catalog/trae-cn-models.ts | 22 +- src/providers/registry/entries-extended.ts | 2 + src/server/management/provider-routes.ts | 1 + .../management/trae-cn-provider-probe.ts | 28 ++ structure/INDEX.md | 1 + structure/gui-and-management-api.md | 2 +- structure/manifest.json | 2 +- structure/providers/trae-cn.md | 15 +- .../adapter-registry-authority.test.ts | 304 +++++++++++++++++- .../docs-provider-preset-counts.test.ts | 13 +- 36 files changed, 533 insertions(+), 54 deletions(-) create mode 100644 src/server/management/trae-cn-provider-probe.ts diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index dbc026ea5bc..7f2f0c6d2e7 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** 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). + 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 51a37b49f91..6471aedac8e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -779,9 +779,11 @@ 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. The key-based category also includes keyless presets such as TRAE CN, +whose official CLI owns authentication. For providers that require an API key, the dashboard’s +**Add provider** picker 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 @@ -1473,12 +1475,16 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through - **Prerequisites:** Install and sign in to [TRAE CN](https://www.trae.cn/) and its official CLI. The CLI owns authentication and token refresh; the provider needs no API key. + Set `TRAE_HOME` before starting OpenCodex if you use a custom CLI directory. - **Model discovery:** `session/new` returns the models available to the signed-in account. Successful discovery is cached by the normal provider catalog mechanism, with the last good catalog and a small text-only seed as degraded fallbacks. + Set `liveModels: false` to keep a static allowlist and skip CLI discovery. After switching CLI + accounts, run **Test connection** to check the active account and invalidate the previous model cache. - **Transport:** Each turn creates a fresh ACP session, selects the requested model with `session/set_model`, and maps `agent_message_chunk` and `agent_thought_chunk` to OpenCodex text and reasoning events. Conversation history is replayed as labelled text. + Replays longer than 1,000,000 characters are rejected so system instructions cannot be silently lost. - **Capability boundary:** This initial provider supports text conversations only. It rejects client tools, tool replay, and image/audio input. Permission requests from the CLI receive an error. Internal tool notifications are not converted into client tool calls, and a turn that diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 1d68dbc3b9c..1a138e7c5a3 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** はログイン済みの公式 CLI を使用するため、API キーは不要です。CLI をインストールしてログインし、ウィザードのキー入力欄は空のまま進めてください。[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/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index a0379d62368..2b857162e3a 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -213,6 +213,8 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 +**TRAE CN**은 로그인된 공식 CLI를 사용하므로 API 키가 필요하지 않습니다. CLI를 설치하고 로그인한 후 마법사의 키 입력란은 비워 두세요. 자세한 내용은 [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/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index e3f8880e7d6..f75720e54cd 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** использует официальный CLI с выполненным входом и не требует ключа API. Установите CLI, войдите в аккаунт и оставьте поле ключа в мастере пустым. Подробнее: [настройка 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/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index a6518b27291..1f3d557dbdd 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**, 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. + 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/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 629e47937ba..ded59b84238 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** 使用已登录的官方 CLI,无需 API 密钥。请先安装 CLI 并登录;向导若提示输入密钥,可直接留空。详见 [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/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index af01df1a2c7..370971681db 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** 使用已登入的官方 CLI,無需 API 金鑰。請先安裝 CLI 並登入;精靈若要求輸入金鑰,可直接留空。詳見 [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 d7bbbcec401..0bf2d3cc3c6 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -150,8 +150,8 @@ export function AddProviderFormPane({
{t("modal.subscriptionCliTitle")}
- {form.adapter === "trae-cn" && preset.note - ? preset.note + {form.adapter === "trae-cn" + ? : }
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 38ba9f69723..50916b8f62d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2977,6 +2977,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 und melden Sie sich 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 caf0376524b..10b7efef945 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1832,6 +1832,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 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 3a63df42930..0697f393d94 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1805,6 +1805,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 au préalable ; 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 fe424e97713..9ad0f1fe175 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1715,6 +1715,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 c7f3b0695d6..0c2a296c8d1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3024,6 +3024,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 b36665ed9e8..2e504d19598 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -1816,6 +1816,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 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 9f64b2040b1..4e4bd4d3dc8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1791,6 +1791,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 1ba001e0fad..2bf3b804018 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1818,6 +1818,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ı yükleyip 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 16b3f084d4f..0aeed4dad7b 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1804,6 +1804,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ướ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 60db01ed946..63d714e6015 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1496,6 +1496,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 6c2ef994ba8..54dc848b27f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2997,6 +2997,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/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 1864a093e02..0bd8194aaf3 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -1,5 +1,6 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; import { SendBudgetExhaustedError } from "../../lib/upstream-retry"; +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"; @@ -7,20 +8,26 @@ import { openTraeSession, TraeAcpError, type TraeAcpDeps } from "./client"; 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.filter(message => message.role !== "developer"); const system = buildSystemPrompt(parsed); const history = messages.slice(0, -1).map(message => formatMessageForHistory(message)).filter(Boolean).join("\n\n"); const current = messages.at(-1); const request = current ? formatMessageForHistory(current) : "USER:\n"; - return [ + const replay = [ system ? `\n${system}\n` : "", "The following labelled blocks replay a conversation. Only the last block is the current request. 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").slice(-1_000_000); + ].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 (requiresToolCall(parsed.options.toolChoice)) { return { message: "TRAE CN ACP does not currently expose client tools.", code: "unsupported_tool_choice" }; @@ -37,21 +44,24 @@ function validateInput(parsed: OcxParsedRequest): { message: string; code: strin 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 auth = acp && /invalid token|unauthorized|not logged in|authentication/i.test(error.message); const message = error instanceof Error ? error.message : "TRAE CN ACP ended with an invalid protocol response."; + const invalid = code === "trae_model_unavailable" || code === "trae_replay_too_large"; return { type: "error", message: auth ? `${message} Sign in through TRAE CN and retry.` : message.slice(0, 512), - status: code === "trae_model_unavailable" ? 400 : auth ? 401 : 502, - errorType: code === "trae_model_unavailable" ? "invalid_request_error" : auth ? "authentication_error" : "upstream_error", + status: invalid ? 400 : auth ? 401 : 502, + errorType: invalid ? "invalid_request_error" : auth ? "authentication_error" : "upstream_error", code: auth ? "trae_cn_not_logged_in" : code, 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", @@ -68,15 +78,21 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda } if (incoming.abortSignal?.aborted) return; const producer = incoming.sendBudget?.beginSpendProducer?.(); + let permit: SingleUseDispatchPermit | undefined; try { + const replay = replayText(parsed); const decision = incoming.sendBudget?.reserveDispatch({ sendClass: "initial", targetKey: `trae-cn|${parsed.modelId}` }); if (decision && !decision.allowed) throw new SendBudgetExhaustedError(); - incoming.beforePhysicalSend?.(); - if (decision?.allowed && !decision.permit.use()) throw new SendBudgetExhaustedError(); - const session = await openTraeSession(provider.baseUrl, deps); - incoming.onPhysicalSend?.({ ordinal: 1 }); + permit = decision?.allowed ? decision.permit : undefined; + const session = await openTraeSession(provider.baseUrl, deps, incoming.abortSignal); try { - await session.prompt(parsed.modelId, replayText(parsed), emit, incoming.abortSignal); + await session.prompt(parsed.modelId, replay, emit, incoming.abortSignal, { + beforeSend: () => { + incoming.beforePhysicalSend?.(); + if (permit && !permit.use()) throw new SendBudgetExhaustedError(); + }, + onSend: () => incoming.onPhysicalSend?.({ ordinal: 1 }), + }); } finally { await session.close(); } @@ -84,7 +100,7 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda if (error instanceof SendBudgetExhaustedError) throw error; emit(errorEvent(error)); } finally { - producer?.close(); + try { permit?.release(); } finally { producer?.close(); } } }, }; diff --git a/src/adapters/trae-cn/profile.ts b/src/adapters/trae-cn/profile.ts index 536a7477196..c9f22b8358b 100644 --- a/src/adapters/trae-cn/profile.ts +++ b/src/adapters/trae-cn/profile.ts @@ -5,14 +5,17 @@ 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; } +/** Keep the child environment scoped while preserving the selected official CLI directory. */ export function traeCnChildEnv(): Record { - return { ...baseScopedEnv(), NO_COLOR: "1" }; + return { ...baseScopedEnv(), NO_COLOR: "1", ...(process.env.TRAE_HOME ? { TRAE_HOME: process.env.TRAE_HOME } : {}) }; } +/** Find an installed CLI on PATH or at its standard user-local installation path. */ export function resolveTraeCli(deps: PathLookupDeps = {}): string | undefined { const onPath = whichFromPath("traecli", deps); if (onPath) return onPath; diff --git a/src/adapters/trae-cn/protocol.ts b/src/adapters/trae-cn/protocol.ts index 0bc429f7169..b8c5f451b6e 100644 --- a/src/adapters/trae-cn/protocol.ts +++ b/src/adapters/trae-cn/protocol.ts @@ -14,10 +14,12 @@ export type TraeAcpFrame = | { 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"); @@ -44,6 +46,7 @@ export function parseTraeAcpMessage(value: unknown): TraeAcpFrame { 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"); @@ -57,12 +60,14 @@ export function parseTraeModels(result: unknown): string[] { 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") { @@ -89,6 +94,7 @@ export function traePromptEvents(sessionId: string, update: Record void; + makeTempDir?: typeof mkdtemp; } export class TraeAcpError extends Error { @@ -28,6 +32,22 @@ export class TraeAcpError extends Error { 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(); @@ -40,12 +60,13 @@ class TraeAcpClient { private closed = false; private closing?: Promise; - constructor(private readonly child: ChildProcess, private readonly timeoutMs: number) { + constructor(private readonly child: ChildProcess, private readonly timeoutMs: number, private readonly deps: TraeAcpDeps) { this.lifecycle = new Promise(resolve => { child.once("error", error => { this.failure ??= error; this.fail(error); }); child.once("close", resolve); if (child.exitCode !== null) resolve(); }); + child.stdin?.on("error", error => this.fail(error)); child.stderr?.on("data", (chunk: Uint8Array) => { this.stderr.push(new TextDecoder().decode(chunk)); while (this.stderr.join("").length > 8192) this.stderr.shift(); @@ -54,6 +75,7 @@ class TraeAcpClient { this.reader.catch(() => undefined); } + /** Wake every waiter immediately; process reaping is separate from request settlement. */ private fail(error: Error): void { if (this.closed) return; this.closed = true; @@ -63,6 +85,7 @@ class TraeAcpClient { 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"); @@ -91,12 +114,14 @@ class TraeAcpClient { } } + /** 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`); } - async request(method: string, params: Record): Promise { + /** Register before writing so immediate responses and stream errors settle the same request. */ + async request(method: string, params: Record, dispatch?: TraePromptDispatch): 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) => { @@ -106,10 +131,15 @@ class TraeAcpClient { }, this.timeoutMs); this.pending.set(id, { resolve, reject, timer }); }); - this.write({ jsonrpc: "2.0", id, method, params }); + 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); @@ -117,18 +147,40 @@ class TraeAcpClient { return new Promise(resolve => this.waiters.push(resolve)); } + /** Expose only a bounded, redacted diagnostic when the CLI closes unexpectedly. */ diagnostic(): string { return redactSecretString(this.failure?.message || this.stderr.join("")).trim().slice(0, 512); } + /** 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 () => { - this.child.kill("SIGTERM"); - const timer = setTimeout(() => this.child.kill("SIGKILL"), 2000); - try { await this.lifecycle; } finally { clearTimeout(timer); } - this.fail(new TraeAcpError("TRAE CN ACP session closed", "trae_acp_closed")); - await this.reader; + const terminate = (signal: NodeJS.Signals): void => { + const pid = this.child.pid; + if (pid && (this.deps.platform ?? process.platform) === "win32") { + try { + if (this.deps.killWindowsProcessTree) this.deps.killWindowsProcessTree(pid); + else execFileSync(`${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`, ["/PID", String(pid), "/T", "/F"], + { stdio: "ignore", windowsHide: true, timeout: 1000 }); + return; + } catch { /* Fall back to the direct child if tree termination is unavailable. */ } + } else if (pid) { + try { process.kill(-pid, signal); return; } catch { /* The process group may already be gone. */ } + } + try { this.child.kill(signal); } catch { /* Already exited. */ } + }; + terminate("SIGTERM"); + const reapTimeoutMs = this.deps.reapTimeoutMs ?? 2250; + if (!await waitBounded(this.lifecycle, this.deps.killGraceMs ?? 2000)) { + terminate("SIGKILL"); + await waitBounded(this.lifecycle, reapTimeoutMs); + } + this.child.stdin?.destroy(); + this.child.stdout?.destroy(); + this.child.stderr?.destroy(); + await waitBounded(this.reader, reapTimeoutMs); })(); return this.closing; } @@ -136,18 +188,25 @@ class TraeAcpClient { export interface TraeSession { readonly models: string[]; - prompt(modelId: string, text: string, emit: (event: AdapterEvent) => void, signal?: AbortSignal): Promise; + prompt(modelId: string, text: string, emit: (event: AdapterEvent) => void, signal?: AbortSignal, dispatch?: TraePromptDispatch): Promise; close(): Promise; } -export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcpDeps = {}): 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): Promise { + signal?.throwIfAborted(); if (!traeCnDestinationMatches(baseUrl)) throw new TraeAcpError("TRAE CN requires its canonical provider destination", "non_canonical_destination"); const binary = (deps.resolveBinary ?? resolveTraeCli)(); if (!binary) throw new TraeAcpError("TRAE CN CLI not found. Install traecli and sign in through TRAE CN.", "cli_not_found"); const env = traeCnChildEnv(); const ownHome = deps.home === undefined; - const home = deps.home ?? await mkdtemp(join(tmpdir(), "ocx-trae-cn-")); - const work = deps.cwd ?? await mkdtemp(join(tmpdir(), "ocx-trae-cn-work-")); + const makeTempDir = deps.makeTempDir ?? mkdtemp; + const home = deps.home ?? await makeTempDir(join(tmpdir(), "ocx-trae-cn-")); + 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 error; + } const cleanup = async (): Promise => { await Promise.all([ ...(ownHome ? [rm(home, { recursive: true, force: true }).catch(() => undefined)] : []), @@ -161,17 +220,22 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env }); let child: ChildProcess; try { + signal?.throwIfAborted(); child = (deps.spawn ?? nodeSpawn)(invocation.file, invocation.args, { ...invocation.options, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, + detached: (deps.platform ?? process.platform) !== "win32", cwd: deps.cwd ?? work, }); } catch (error) { await cleanup(); throw error; } - const client = new TraeAcpClient(child, deps.timeoutMs ?? 300_000); + const client = new TraeAcpClient(child, deps.timeoutMs ?? 300_000, deps); + const abort = () => { void client.close(); }; + 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 }, @@ -185,7 +249,7 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp opened = true; return { models, - async prompt(modelId, text, emit, signal) { + 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(); }; @@ -193,9 +257,10 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp let completion: Promise | undefined; try { await client.request("session/set_model", { sessionId, modelId }); + if (signal?.aborted) return; let output = false; let sawTool = false; - completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }); + completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, dispatch); while (true) { const frame = await Promise.race([ client.nextUpdate(), @@ -229,8 +294,8 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp } }, close: async () => { - await client.close(); - await cleanup(); + signal?.removeEventListener("abort", abort); + try { await client.close(); } finally { await cleanup(); } }, }; } catch (error) { @@ -241,6 +306,7 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp throw error; } finally { if (!opened) { + signal?.removeEventListener("abort", abort); await client.close().catch(() => undefined); await cleanup(); } diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index a18e47e1327..104c114f0ec 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -214,7 +214,7 @@ export async function fetchProviderModelsWithAuth( ...(prov.retainModels ?? []), ])]; const configured: CatalogModel[] = configuredIds.map(id => applyProviderConfigHints( - name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, + name, prov, { id, provider: name, ...(prov.adapter === "trae-cn" ? { inputModalities: ["text"], supportsVerbosity: false } : {}) }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false, )); const withConfiguredRetention = ( @@ -244,7 +244,7 @@ export async function fetchProviderModelsWithAuth( } return merged; }; - if (prov.adapter === "trae-cn") { + if (prov.adapter === "trae-cn" && prov.liveModels !== false) { return fetchTraeCnProviderModels(captured, ttlMs, contextCap, configured, withConfiguredRetention); } // Static catalogs never need an OAuth refresh or an upstream model request. Clear any diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 4eda4d5810e..16eaecd679c 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -1,5 +1,5 @@ import { openTraeSession } from "../../adapters/trae-cn/client"; -import { TRAE_CN_BASE_URL } from "../../adapters/trae-cn/profile"; +import { traeCnDestinationMatches } from "../../adapters/trae-cn/profile"; import { captureModelCacheGeneration, getFreshCached, @@ -17,6 +17,7 @@ import type { CapturedProviderGather } from "./gather-capture"; import type { ProviderModelsResult } from "./provider-models"; import type { CatalogModel } from "./parsing"; +/** Discover an account roster with bounded failure retries and current hints on every cached return. */ export async function fetchTraeCnProviderModels( captured: CapturedProviderGather, ttlMs: number, @@ -26,17 +27,20 @@ export async function fetchTraeCnProviderModels( ): Promise { const { name, provider, metadataModelIdCaseFold, metadataConfigDir } = captured; const observed = (models: CatalogModel[], state: ProviderModelsResult["outcome"]["state"]): ProviderModelsResult => ({ - models, outcome: { provider: name, state }, contentRevision: observeModelCacheRevision(name), + 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)) { + markProviderDiscoveryFailed(name, { reason: "blocked" }); + return observed(configured, "degraded"); + } const fresh = getFreshCached(name, ttlMs, Date.now()); - if (fresh) return observed(withConfiguredRetention(fresh), "authoritative"); + if (fresh) return observed(fresh, "authoritative"); const stale = getStaleCached(name); - if (isModelsFetchCoolingDown(name) && stale) return observed(withConfiguredRetention(stale), "degraded"); + if (isModelsFetchCoolingDown(name)) return observed(stale ?? configured, "degraded"); const generation = captureModelCacheGeneration(name); - if (provider.baseUrl?.replace(/\/+$/, "").toLowerCase() !== TRAE_CN_BASE_URL) { - markProviderDiscoveryFailed(name, { reason: "blocked" }); - return observed(stale ?? configured, "degraded"); - } try { const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000 }); await session.close(); @@ -46,7 +50,7 @@ export async function fetchTraeCnProviderModels( captured.effectiveAlias, metadataConfigDir, false)); if (!setCached(name, models, Date.now(), generation)) return observed(stale ?? configured, "degraded"); markProviderDiscoveryOk(name, models.length); - return observed(withConfiguredRetention(models), "authoritative"); + return observed(models, "authoritative"); } catch { if (isModelCacheGenerationCurrent(name, generation)) { markModelsFetchFailure(name); diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 84434830805..e0abf3cd8af 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -145,6 +145,8 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ models: [...TRAE_CN_MODELS], liveModels: true, noVisionModels: [...TRAE_CN_MODELS], + inputModalities: ["text"], + supportsVerbosity: false, reasoningEfforts: [], note: "Uses the signed-in official traecli through `traecli acp serve`; OpenCodex stores no TRAE token. Requires TRAE CN CLI and a signed-in account. The initial adapter supports text and reasoning streams; client tools and images are rejected explicitly.", }, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 63e4089e46f..c8f48c10af5 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1653,6 +1653,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const started = Date.now(); + const generation = captureModelCacheGeneration(name); + try { + const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000, ...deps }, signal); + try { + if (isModelCacheGenerationCurrent(name, generation)) { + clearModelCache(name); + clearGatherRoutedModelsInflight(); + } + return jsonResponse({ ok: true, models: session.models.length, latencyMs: Date.now() - started, + message: `Connected. ${session.models.length} models.` }); + } finally { await session.close(); } + } 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/structure/INDEX.md b/structure/INDEX.md index f6f254dbd0f..273a57a0422 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -453,6 +453,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/logs-usage-routes.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | | `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.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/gui-and-management-api.md b/structure/gui-and-management-api.md index 8b9db2eb4a7..92e6fda18e3 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` and invalidates the unchanged provider catalog after success; 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 65d4c5a67f1..d9e6bd1ba6f 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -647,7 +647,7 @@ "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"] + "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", diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index f8d6bb09d09..eed4de83e0a 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -7,4 +7,17 @@ and request time; cancellation and cleanup terminate the child. Text and thought distinct adapter events. Tool input/replay and non-text media are rejected, permission requests are denied and terminate the session, and an internal tool turn without answer text fails with `trae_empty_response`. 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 and uses the shared stale/static fallback path. +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. The CLI inherits `TRAE_HOME` when selected by the operator. + +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 +process tree and bounds both process and stream waits. A failed workspace allocation removes an +already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send; startup +failure or unavailable models return unused reservations. Oversized replay is rejected before startup. + +`src/server/management/trae-cn-provider-probe.ts` opens and closes a direct ACP session for the +connection test and invalidates the provider catalog after success. 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. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index a00793f8836..8c36cea5f75 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1,4 +1,22 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { existsSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { PassThrough, Writable } from "node:stream"; +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { createTraeCnAdapter } from "../../src/adapters/trae-cn/adapter"; +import * as traeClient from "../../src/adapters/trae-cn/client"; +import { traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; +import { fetchProviderModels, fetchProviderModelsWithAuth, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models"; +import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; +import { captureModelCacheGeneration, clearModelCache, getFreshCached, 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 { isVisionSidecarConsumer } from "../../src/vision/eligibility"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { AdapterEvent } from "../../src/types"; +import type { IncomingMeta } from "../../src/adapters/base"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; import { adapterDefinitions, createRegisteredAdapter, @@ -119,3 +137,287 @@ 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) { + 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 deps: traeClient.TraeAcpDeps = { + 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: createTranslatorBudget(), ...extra }; +} + +function fakeSendBudget() { + const state = { used: 0, refunded: 0, closed: 0 }; + const budget = { + beginSpendProducer: () => ({ close: () => { state.closed++; } }), + reserveDispatch: () => ({ allowed: true, permit: { + use: () => { state.used++; return true; }, + release: () => { if (!state.used) state.refunded++; }, + } }), + } 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 }); + beforeEach(() => { testHome = createTempHome("ocx-trae-regression-"); clearModelCache(); }); + afterEach(() => { mock.restore(); clearModelCache(); testHome.remove(); }); + + 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(["thinking_delta", "text_delta", "done"]); + 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 phase of ["initialize", "session/new", "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; + 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++; }, + }), () => undefined); + } finally { clearTimeout(watchdog); } + expect(deadlineReached).toBe(false); + 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: "trae_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", "oversized"]) { + 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 === "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 === "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 }); + }); + } + + 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); + }); + + 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("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 session = await traeClient.openTraeSession(config().baseUrl, fixture.deps); + const turn = session.prompt("test-model", "hello", () => undefined).catch(error => error); + await session.close(); + expect(await turn).toBeInstanceOf(Error); + expect(fixture.child.stdout!.destroyed).toBe(true); + expect(existsSync(String(fixture.options()!.cwd))).toBe(false); + }); + + test("Windows dispatchers use process-tree termination", async () => { + const fixture = traeFixture(); + Object.assign(fixture.child, { pid: 4242 }); + const killed: number[] = []; + const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, platform: "win32", + killWindowsProcessTree: pid => { killed.push(pid); fixture.child.kill(); }, + }); + await session.close(); + expect(killed).toEqual([4242]); + }); + + 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("workspace unavailable"); + 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("workspace unavailable"); + 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("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 }; + 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); + }); + + 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 fetchProviderModels("trae-cn", { ...config(), models: [] }, 60_000); + 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 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 fetchProviderModels("trae-cn", row, 60_000, 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 uses ACP evidence and invalidates the cached roster", async () => { + const fixture = traeFixture(); + setCached("trae-cn", [{ id: "old-account-model", provider: "trae-cn" }], Date.now(), captureModelCacheGeneration("trae-cn")); + const response = await probeTraeCnProvider("trae-cn", config(), undefined, fixture.deps); + expect(await response.json()).toMatchObject({ ok: true, models: 1 }); + expect(fixture.requests.map(request => request.method)).toEqual(["initialize", "session/new"]); + expect(getFreshCached("trae-cn", 60_000, Date.now())).toBeNull(); + expect(fixture.child.killed).toBe(true); + }); + + 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 response = await handleProviderRoutes({ req, url: new URL(req.url), config: { providers: { "trae-cn": config() } }, deps: {}, + } as Parameters[0]); + expect(await response!.json()).toMatchObject({ ok: true, models: 1 }); + expect(outbound).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ci-workflows/docs-provider-preset-counts.test.ts b/tests/ci-workflows/docs-provider-preset-counts.test.ts index a849660065b..bbed0357f53 100644 --- a/tests/ci-workflows/docs-provider-preset-counts.test.ts +++ b/tests/ci-workflows/docs-provider-preset-counts.test.ts @@ -69,21 +69,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); }); }); From bb24343da98f61b51352575f867ada8a07c284a4 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 18:59:19 +0800 Subject: [PATCH 03/64] fix(trae-cn): enforce transport capabilities and refresh catalogs Co-authored-by: Codex --- .../src/content/docs/guides/providers.md | 18 ++- src/adapters/trae-cn/adapter.ts | 9 +- src/adapters/trae-cn/session.ts | 32 +++-- src/codex/catalog/provider-models.ts | 4 +- src/codex/catalog/trae-cn-models.ts | 4 +- src/server/management/provider-routes.ts | 13 +- .../management/trae-cn-provider-probe.ts | 47 +++++-- src/vision/eligibility.ts | 7 +- src/vision/plan.ts | 6 +- structure/gui-and-management-api.md | 2 +- structure/providers/trae-cn.md | 20 ++- .../adapter-registry-authority.test.ts | 128 ++++++++++++++++-- .../docs-provider-preset-counts.test.ts | 8 ++ 13 files changed, 245 insertions(+), 53 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 6471aedac8e..dbe5001dddd 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1480,15 +1480,20 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through Successful discovery is cached by the normal provider catalog mechanism, with the last good catalog and a small text-only seed as degraded fallbacks. Set `liveModels: false` to keep a static allowlist and skip CLI discovery. After switching CLI - accounts, run **Test connection** to check the active account and invalidate the previous model cache. + accounts, run **Test connection** to check the active account, replace the cached roster and + refresh the Codex catalog and previously connected clients. The result reports pending or + failed publication separately from connectivity. Restart running Codex sessions after publication. - **Transport:** Each turn creates a fresh ACP session, selects the requested model with `session/set_model`, and maps `agent_message_chunk` and `agent_thought_chunk` to OpenCodex text and reasoning events. Conversation history is replayed as labelled text. - Replays longer than 1,000,000 characters are rejected so system instructions cannot be silently lost. + Replays longer than 1,000,000 UTF-16 code units (JavaScript string length) are rejected so + system instructions cannot be silently lost. A supplementary character such as an emoji uses two units. - **Capability boundary:** This initial provider supports text conversations only. It rejects - client tools, tool replay, and image/audio input. Permission requests from the CLI receive an - error. Internal tool notifications are not converted into client tool calls, and a turn that - ends without text is an error rather than an empty successful answer. + 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`. A normal `end_turn` or `max_tokens` completion with no text + fails with `trae_empty_response`. Refusals preserve usage and terminate without retry. - **Process isolation:** The CLI uses an empty temporary workspace, no session MCP servers or selected skills, and disallow rules for file and command tools. OpenCodex terminates the child after the turn, on cancellation, or on protocol failure. Any tool notification or permission @@ -1496,6 +1501,9 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through - **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. ### Official Qoder CLI (Global & CN) diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 0bd8194aaf3..a35c1cf04da 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -36,10 +36,9 @@ function validateInput(parsed: OcxParsedRequest): { message: string; code: strin return { message: "TRAE CN ACP does not currently expose client tools.", code: "unsupported_client_tools" }; } if (parsed.context.messages.some(message => - (Array.isArray(message.content) && message.content.some(part => part.type === "image" || part.type === "audio")) - || (message.role === "assistant" && message.content.some(part => part.type === "toolCall")) + (Array.isArray(message.content) && message.content.some(part => part.type !== "text" && part.type !== "thinking")) || message.role === "toolResult")) { - return { message: "TRAE CN ACP currently supports text conversations only; images and tool replay are not supported.", code: "unsupported_input_modality" }; + return { message: "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported.", code: "unsupported_input_modality" }; } return undefined; } @@ -50,7 +49,7 @@ function errorEvent(error: unknown): Extract { const code = acp ? error.code : "trae_acp_protocol_error"; const auth = acp && /invalid token|unauthorized|not logged in|authentication/i.test(error.message); const message = error instanceof Error ? error.message : "TRAE CN ACP ended with an invalid protocol response."; - const invalid = code === "trae_model_unavailable" || code === "trae_replay_too_large"; + const invalid = code === "trae_model_unavailable" || code === "trae_replay_too_large" || code === "trae_proxy_unsupported"; return { type: "error", message: auth ? `${message} Sign in through TRAE CN and retry.` : message.slice(0, 512), @@ -84,7 +83,7 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda 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); + const session = await openTraeSession(provider.baseUrl, deps, incoming.abortSignal, provider); try { await session.prompt(parsed.modelId, replay, emit, incoming.abortSignal, { beforeSend: () => { diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 6a6555cc35d..681e85c4c08 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { commandInvocation } from "../../lib/win-exec"; import { redactSecretString } from "../../lib/redact"; -import type { AdapterEvent } from "../../types"; +import type { AdapterEvent, OcxProviderConfig } from "../../types"; import { readJsonLines } from "../coding-agent/protocol"; import { resolveTraeCli, traeCnChildEnv, traeCnDestinationMatches } from "./profile"; import { parseTraeAcpMessage, parseTraeModels, traePromptEvents, traeSessionId, traeStopReason, traeUsage, TraeAcpProtocolError, type TraeAcpFrame } from "./protocol"; @@ -149,7 +149,7 @@ class TraeAcpClient { /** Expose only a bounded, redacted diagnostic when the CLI closes unexpectedly. */ diagnostic(): string { - return redactSecretString(this.failure?.message || this.stderr.join("")).trim().slice(0, 512); + return redactSecretString(this.stderr.join("").trim() || this.failure?.message || "").trim().slice(0, 512); } /** Stop RPCs first, then terminate the owned process tree and bound all remaining waits. */ @@ -193,8 +193,14 @@ export interface TraeSession { } /** Start a cancellable ACP session in owned temporary directories; callers close successful sessions. */ -export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcpDeps = {}, signal?: AbortSignal): Promise { +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"); const binary = (deps.resolveBinary ?? resolveTraeCli)(); if (!binary) throw new TraeAcpError("TRAE CN CLI not found. Install traecli and sign in through TRAE CN.", "cli_not_found"); @@ -259,7 +265,6 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp await client.request("session/set_model", { sessionId, modelId }); if (signal?.aborted) return; let output = false; - let sawTool = false; completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, dispatch); while (true) { const frame = await Promise.race([ @@ -268,7 +273,6 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp ]); if (!frame) break; if (frame.kind !== "update" || frame.sessionId !== sessionId) continue; - if (frame.update.sessionUpdate === "tool_call" || frame.update.sessionUpdate === "tool_call_update") sawTool = true; for (const event of traePromptEvents(sessionId, frame.update)) { if (event.type === "text_delta") output = true; emit(event); @@ -282,12 +286,21 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp const result = await completion; const stop = traeStopReason(result); if (stop === "cancelled") return; - if (stop !== "stop" && stop !== "max_tokens") throw new TraeAcpError(`TRAE CN stopped the turn: ${stop}`, "trae_turn_stopped"); - if (!output) throw new TraeAcpError(sawTool - ? "TRAE CN ended after an internal tool turn without a text response. Retry the text request or select another model." - : "TRAE CN ended without a text response.", "trae_empty_response"); const usage = traeUsage(result); + if (stop === "refusal") { + emit({ type: "incomplete", reason: "content_filter", retryable: false, + message: "TRAE CN refused the turn.", ...(usage ? { usage } : {}) }); + return; + } + if (stop !== "stop" && stop !== "max_tokens") throw new TraeAcpError(`TRAE CN stopped the turn: ${stop}`, "trae_turn_stopped"); + if (!output) throw new TraeAcpError("TRAE CN ended without a text response.", "trae_empty_response"); emit({ type: "done", stopReason: stop, ...(usage ? { usage } : {}) }); + } catch (error) { + if (error instanceof TraeAcpError && error.code === "trae_acp_closed") { + await client.close(); + throw new TraeAcpError(client.diagnostic() || error.message, error.code); + } + throw error; } finally { signal?.removeEventListener("abort", abort); completion?.catch(() => undefined); @@ -300,6 +313,7 @@ export async function openTraeSession(baseUrl: string | undefined, deps: TraeAcp }; } catch (error) { if (error instanceof TraeAcpError && error.code === "trae_acp_closed") { + await client.close(); const detail = client.diagnostic(); if (detail && detail !== error.message) throw new TraeAcpError(detail, error.code); } diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 104c114f0ec..4b52447cf80 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -214,9 +214,9 @@ export async function fetchProviderModelsWithAuth( ...(prov.retainModels ?? []), ])]; const configured: CatalogModel[] = configuredIds.map(id => applyProviderConfigHints( - name, prov, { id, provider: name, ...(prov.adapter === "trae-cn" ? { inputModalities: ["text"], supportsVerbosity: false } : {}) }, contextCap, metadataModelIdCaseFold, + 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 }, diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 16eaecd679c..a6b2eb0ab64 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -32,7 +32,7 @@ export async function fetchTraeCnProviderModels( .map(model => ({ ...model, inputModalities: ["text"], supportsVerbosity: false })), outcome: { provider: name, state }, contentRevision: observeModelCacheRevision(name), }); - if (!traeCnDestinationMatches(provider.baseUrl)) { + if (!traeCnDestinationMatches(provider.baseUrl) || provider.proxy !== undefined || provider.noProxy !== undefined) { markProviderDiscoveryFailed(name, { reason: "blocked" }); return observed(configured, "degraded"); } @@ -42,7 +42,7 @@ export async function fetchTraeCnProviderModels( if (isModelsFetchCoolingDown(name)) return observed(stale ?? configured, "degraded"); const generation = captureModelCacheGeneration(name); try { - const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000 }); + const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000 }, undefined, provider); await session.close(); if (!isModelCacheGenerationCurrent(name, generation)) return observed(stale ?? configured, "degraded"); const models = session.models.map(id => applyProviderConfigHints(name, provider, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index c8f48c10af5..172aeaa9945 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1653,7 +1653,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + 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 }); + }, + }); + } const { buildModelsRequest, getModelsOAuthAccessSnapshot, resolveModelsAuthToken } = await import("../../oauth"); const antigravity = effectiveGoogleMode(name, prov) === "cloud-code-assist"; const snapshot = prov.authMode === "oauth" && mayResolveModelsOAuth(name, config.providers[name]) diff --git a/src/server/management/trae-cn-provider-probe.ts b/src/server/management/trae-cn-provider-probe.ts index c1a165fa589..dc127cb3073 100644 --- a/src/server/management/trae-cn-provider-probe.ts +++ b/src/server/management/trae-cn-provider-probe.ts @@ -1,26 +1,53 @@ import { openTraeSession, type TraeAcpDeps } from "../../adapters/trae-cn/client"; -import { captureModelCacheGeneration, clearModelCache, isModelCacheGenerationCurrent } from "../../codex/model-cache"; +import { captureModelCacheGeneration, clearModelCache, isModelCacheGenerationCurrent, markProviderDiscoveryOk, setCached } from "../../codex/model-cache"; +import type { CatalogDisposition } from "../../codex/convergence-types"; +import type { OwnedIntegrationRefreshOutcome } from "../../integrations/owned-refresh"; import { clearGatherRoutedModelsInflight } from "../../codex/catalog/routed-gather"; import { redactSecretString } from "../../lib/redact"; import type { OcxProviderConfig } from "../../types"; import { jsonResponse } from "../auth-cors"; +interface TraeCnProbeDeps extends TraeAcpDeps { + refreshConnectedClients?: (admit: () => boolean) => Promise; +} + /** Probe the signed-in CLI directly; cached or seeded model rows are not connection evidence. */ export async function probeTraeCnProvider( - name: string, provider: OcxProviderConfig, signal?: AbortSignal, deps: TraeAcpDeps = {}, + 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); - try { - if (isModelCacheGenerationCurrent(name, generation)) { - clearModelCache(name); - clearGatherRoutedModelsInflight(); + 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 clientIntegrations: OwnedIntegrationRefreshOutcome[] = []; + let clientRefreshPending = false; + if (isModelCacheGenerationCurrent(name, generation)) { + clearModelCache(name); + setCached(name, session.models.map(id => ({ id, provider: name, inputModalities: ["text"], supportsVerbosity: false })), + Date.now(), captureModelCacheGeneration(name)); + markProviderDiscoveryOk(name, session.models.length); + clearGatherRoutedModelsInflight(); + const publishedGeneration = captureModelCacheGeneration(name); + 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; } } - return jsonResponse({ ok: true, models: session.models.length, latencyMs: Date.now() - started, - message: `Connected. ${session.models.length} models.` }); - } finally { await session.close(); } + } + const published = catalogRefresh.status === "committed" && !catalogRefresh.degraded; + return jsonResponse({ ok: true, models: session.models.length, latencyMs: Date.now() - started, catalogRefresh, clientIntegrations, clientRefreshPending, + message: `Connected. ${session.models.length} models. ${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/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/gui-and-management-api.md b/structure/gui-and-management-api.md index 92e6fda18e3..1726e102e3a 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. The [TRAE CN probe](providers/trae-cn.md) uses a bounded ACP session in `src/server/management/trae-cn-provider-probe.ts` and invalidates the unchanged provider catalog after success; static catalogs remain not applicable. +[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/providers/trae-cn.md b/structure/providers/trae-cn.md index eed4de83e0a..e33ca59f4ed 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -5,19 +5,31 @@ TRAE owns login and refresh; OpenCodex stores no TRAE token. A fresh session sup model roster and one selected-model turn. 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, and an internal tool turn without answer text fails with `trae_empty_response`. The CLI uses +denied and terminate the session. Tool notifications fail with `trae_tool_call_unsupported`; only a +normal `end_turn` or `max_tokens` completion without text fails with `trae_empty_response`. Refusals +emit a non-retryable `content_filter` incomplete event with usage. 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. The CLI inherits `TRAE_HOME` when selected by the operator. +`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 `TRAE_HOME` when selected by the operator. Provider `proxy`/`noProxy` overrides +are refused before CLI startup, including explicit direct routing. 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 process tree and bounds both process and stream waits. A failed workspace allocation removes an already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send; startup -failure or unavailable models return unused reservations. Oversized replay is rejected before startup. +failure or unavailable models return unused reservations. 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. `src/server/management/trae-cn-provider-probe.ts` opens and closes a direct ACP session for the -connection test and invalidates the provider catalog after success. It never probes the vendor +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 response separates connectivity from catalog/client publication and notes the running Codex +session restart requirement. A probe overtaken by a cache invalidation cannot replace the new roster. +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. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 8c36cea5f75..97a362d0f89 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -12,7 +12,9 @@ import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; import { captureModelCacheGeneration, clearModelCache, getFreshCached, 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 { isVisionSidecarConsumer } from "../../src/vision/eligibility"; +import { isModelVisionSidecarConsumer, isVisionSidecarConsumer, modelAcceptsImageInput } from "../../src/vision/eligibility"; +import { requiresVisionPreprocessing } from "../../src/vision/plan"; +import type { CatalogDisposition } from "../../src/codex/convergence-types"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { AdapterEvent } from "../../src/types"; import type { IncomingMeta } from "../../src/adapters/base"; @@ -205,6 +207,9 @@ function fakeSendBudget() { 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(); }); @@ -267,7 +272,7 @@ describe("TRAE CN ACP regression contract", () => { expect(budget.state).toEqual({ used: 0, refunded: 1, closed: 1 }); }); - for (const kind of ["tools", "required", "toolCall", "toolResult", "image", "audio", "oversized"]) { + for (const kind of ["tools", "required", "toolCall", "toolResult", "image", "audio", "video", "document", "oversized"]) { 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" } }]; @@ -275,6 +280,8 @@ describe("TRAE CN ACP regression contract", () => { 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[] = []; @@ -285,6 +292,63 @@ describe("TRAE CN ACP regression contract", () => { }); } + 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; @@ -360,20 +424,26 @@ describe("TRAE CN ACP regression contract", () => { 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 }; + 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", id: "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("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 fetchProviderModels("trae-cn", { ...config(), models: [] }, 60_000); + const models = await rawModels({ ...config(), models: [] }); expect(models).toMatchObject([{ id: "test-model", inputModalities: ["text"], supportsVerbosity: false }]); expect(fixture.child.killed).toBe(true); }); @@ -392,32 +462,70 @@ describe("TRAE CN ACP regression contract", () => { 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 fetchProviderModels("trae-cn", row, 60_000, 40_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 uses ACP evidence and invalidates the cached roster", async () => { + 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 response = await probeTraeCnProvider("trae-cn", config(), undefined, fixture.deps); - expect(await response.json()).toMatchObject({ ok: true, models: 1 }); + 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(getFreshCached("trae-cn", 60_000, Date.now())).toBeNull(); + 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" }] } } }); + expect(await (await probe).json()).toMatchObject({ ok: true, catalogRefresh: { status: "skipped", reason: "stale" } }); + 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 response = await handleProviderRoutes({ req, url: new URL(req.url), config: { providers: { "trae-cn": config() } }, deps: {}, + 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/ci-workflows/docs-provider-preset-counts.test.ts b/tests/ci-workflows/docs-provider-preset-counts.test.ts index bbed0357f53..d7b52c3e8de 100644 --- a/tests/ci-workflows/docs-provider-preset-counts.test.ts +++ b/tests/ci-workflows/docs-provider-preset-counts.test.ts @@ -59,6 +59,14 @@ 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(/leave.*key prompt empty/); + expect(note).toMatch(/Install and sign in to the CLI first/); + expect(note).toContain("[TRAE CN setup](/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); From 2a9b9aa5e7cb9f9909258ec13bc71053ef4b0a40 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 19:09:30 +0800 Subject: [PATCH 04/64] fix(trae-cn): close cancellation and discovery edge cases Co-authored-by: Codex --- .../src/content/docs/guides/providers.md | 22 +++--- src/adapters/trae-cn/adapter.ts | 4 ++ src/adapters/trae-cn/profile.ts | 6 +- src/adapters/trae-cn/session.ts | 22 +++++- src/codex/catalog/provider-models.ts | 2 +- src/codex/catalog/trae-cn-models.ts | 3 + structure/providers/trae-cn.md | 9 ++- .../adapter-registry-authority.test.ts | 70 +++++++++++++++++-- 8 files changed, 117 insertions(+), 21 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index dbe5001dddd..86fd9497fc1 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1475,14 +1475,15 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through - **Prerequisites:** Install and sign in to [TRAE CN](https://www.trae.cn/) and its official CLI. The CLI owns authentication and token refresh; the provider needs no API key. - Set `TRAE_HOME` before starting OpenCodex if you use a custom CLI directory. + Set `TRAE_HOME` to your signed-in CLI directory before starting OpenCodex if authentication + depends on that directory, including when the CLI uses the default `~/.trae` location. - **Model discovery:** `session/new` returns the models available to the signed-in account. Successful discovery is cached by the normal provider catalog mechanism, with the last good catalog and a small text-only seed as degraded fallbacks. - Set `liveModels: false` to keep a static allowlist and skip CLI discovery. After switching CLI - accounts, run **Test connection** to check the active account, replace the cached roster and - refresh the Codex catalog and previously connected clients. The result reports pending or - failed publication separately from connectivity. Restart running Codex sessions after publication. + Read-only catalog gathers use cached evidence; they never start the CLI or refresh its sign-in. + Set `liveModels: false` for a static allowlist. After switching CLI accounts, **Test connection** + replaces the roster and refreshes Codex and connected clients. It reports pending or failed + publication separately; restart running Codex sessions after publication. - **Transport:** Each turn creates a fresh ACP session, selects the requested model with `session/set_model`, and maps `agent_message_chunk` and `agent_thought_chunk` to OpenCodex text and reasoning events. Conversation history is replayed as labelled text. @@ -1494,10 +1495,13 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through Permission requests fail with `trae_client_request_unsupported`; internal tool notifications fail with `trae_tool_call_unsupported`. A normal `end_turn` or `max_tokens` completion with no text fails with `trae_empty_response`. Refusals preserve usage and terminate without retry. -- **Process isolation:** The CLI uses an empty temporary workspace, no session MCP servers or - selected skills, and disallow rules for file and command tools. OpenCodex terminates the child - after the turn, on cancellation, or on protocol failure. Any tool notification or permission - request ends the turn with an error instead of being executed by OpenCodex. + Client cancellation is silent; cancellation initiated by the CLI returns an incomplete turn. + Requests with an output-token limit fail with `unsupported_max_output_tokens` because this + adapter has no verified ACP field for that limit. +- **CLI process:** The CLI uses an empty temporary workspace, no session MCP/skills and a finite tool denylist. + OpenCodex terminates its process tree after the turn, cancellation or protocol failure. + 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`. diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index a35c1cf04da..8e9952309d3 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -29,6 +29,9 @@ function replayText(parsed: OcxParsedRequest): string { /** 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" }; } @@ -96,6 +99,7 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda await session.close(); } } catch (error) { + if (incoming.abortSignal?.aborted) return; if (error instanceof SendBudgetExhaustedError) throw error; emit(errorEvent(error)); } finally { diff --git a/src/adapters/trae-cn/profile.ts b/src/adapters/trae-cn/profile.ts index c9f22b8358b..748fede6e3d 100644 --- a/src/adapters/trae-cn/profile.ts +++ b/src/adapters/trae-cn/profile.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, posix, win32 } from "node:path"; import { whichFromPath, type PathLookupDeps } from "../coding-agent/profile"; import { baseScopedEnv } from "../coding-agent/turn"; @@ -17,9 +17,9 @@ export function traeCnChildEnv(): Record { /** Find an installed CLI on PATH or at its standard user-local installation path. */ export function resolveTraeCli(deps: PathLookupDeps = {}): string | undefined { - const onPath = whichFromPath("traecli", deps); - if (onPath) return onPath; 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 local = join(home, ".local", "bin", platform === "win32" ? "traecli.exe" : "traecli"); diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 681e85c4c08..662e765623a 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -20,6 +20,7 @@ export interface TraeAcpDeps { killGraceMs?: number; reapTimeoutMs?: number; killWindowsProcessTree?: (pid: number) => void; + signalPosixProcessGroup?: (pid: number, signal: NodeJS.Signals) => void; makeTempDir?: typeof mkdtemp; } @@ -157,6 +158,10 @@ class TraeAcpClient { if (this.closing) return this.closing; this.fail(new TraeAcpError("TRAE CN ACP session closed", "trae_acp_closed")); this.closing = (async () => { + const signalGroup = (pid: number, signal: NodeJS.Signals): void => { + if (this.deps.signalPosixProcessGroup) this.deps.signalPosixProcessGroup(pid, signal); + else process.kill(-pid, signal); + }; const terminate = (signal: NodeJS.Signals): void => { const pid = this.child.pid; if (pid && (this.deps.platform ?? process.platform) === "win32") { @@ -167,7 +172,7 @@ class TraeAcpClient { return; } catch { /* Fall back to the direct child if tree termination is unavailable. */ } } else if (pid) { - try { process.kill(-pid, signal); return; } catch { /* The process group may already be gone. */ } + try { signalGroup(pid, signal); return; } catch { /* The process group may already be gone. */ } } try { this.child.kill(signal); } catch { /* Already exited. */ } }; @@ -176,6 +181,9 @@ class TraeAcpClient { if (!await waitBounded(this.lifecycle, this.deps.killGraceMs ?? 2000)) { terminate("SIGKILL"); await waitBounded(this.lifecycle, reapTimeoutMs); + } else if (this.child.pid && (this.deps.platform ?? process.platform) !== "win32") { + // The group can outlive its leader, even when the leader closed every pipe. + try { signalGroup(this.child.pid, "SIGKILL"); } catch { /* The group has already exited. */ } } this.child.stdin?.destroy(); this.child.stdout?.destroy(); @@ -273,6 +281,12 @@ export async function openTraeSession( ]); if (!frame) break; if (frame.kind !== "update" || frame.sessionId !== sessionId) continue; + if (frame.update.sessionUpdate === "current_model_update") { + if (frame.update.currentModelId !== modelId) { + throw new TraeAcpError("TRAE CN changed the selected model unexpectedly.", "trae_model_changed"); + } + continue; + } for (const event of traePromptEvents(sessionId, frame.update)) { if (event.type === "text_delta") output = true; emit(event); @@ -285,8 +299,12 @@ export async function openTraeSession( } const result = await completion; const stop = traeStopReason(result); - if (stop === "cancelled") return; const usage = traeUsage(result); + if (stop === "cancelled") { + if (!signal?.aborted) 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 } : {}) }); diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 4b52447cf80..62d956dde85 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -245,7 +245,7 @@ export async function fetchProviderModelsWithAuth( return merged; }; if (prov.adapter === "trae-cn" && prov.liveModels !== false) { - return fetchTraeCnProviderModels(captured, ttlMs, contextCap, configured, withConfiguredRetention); + 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. diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index a6b2eb0ab64..92c3cd8c0ee 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -24,6 +24,7 @@ export async function fetchTraeCnProviderModels( 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 => ({ @@ -39,6 +40,8 @@ export async function fetchTraeCnProviderModels( const fresh = 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); try { diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index e33ca59f4ed..3cd59039636 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -16,14 +16,21 @@ and no-verbosity limits after config hints. Vision classification and preprocess limit before per-model overrides; only sidecar publication can advertise image attachments. The CLI inherits `TRAE_HOME` when selected by the operator. Provider `proxy`/`noProxy` overrides are refused before CLI startup, including explicit direct routing. +Observed-auth gathers use only cached evidence; only refreshing gathers and explicit connection +probes may launch the CLI. Relative PATH entries resolve before the child changes its cwd. 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 -process tree and bounds both process and stream waits. A failed workspace allocation removes an +process tree and bounds both process and stream waits. A final SIGKILL terminates surviving POSIX +group members even after the leader exits. A failed workspace allocation removes an already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send; startup failure or unavailable models return unused reservations. 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. +Client cancellation emits no provider error; an upstream cancellation without client abort emits +an incomplete event with usage. Explicit output-token limits are refused before startup. +Model-selection notifications must match the requested model. 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 diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 97a362d0f89..a4bfe2f10fe 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -6,8 +6,8 @@ import { PassThrough, Writable } from "node:stream"; import type { ChildProcess, SpawnOptions } from "node:child_process"; import { createTraeCnAdapter } from "../../src/adapters/trae-cn/adapter"; import * as traeClient from "../../src/adapters/trae-cn/client"; -import { traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; -import { fetchProviderModels, fetchProviderModelsWithAuth, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models"; +import { resolveTraeCli, traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; +import { fetchProviderModels, fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models"; import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; import { captureModelCacheGeneration, clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; import { probeTraeCnProvider } from "../../src/server/management/trae-cn-provider-probe"; @@ -233,7 +233,7 @@ describe("TRAE CN ACP regression contract", () => { expect(existsSync(String(fixture.options()!.cwd))).toBe(false); }); - for (const phase of ["initialize", "session/new", "session/prompt"]) { + 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 => { @@ -243,14 +243,16 @@ describe("TRAE CN ACP regression contract", () => { }); 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++; }, - }), () => undefined); + }), event => events.push(event)); } finally { clearTimeout(watchdog); } expect(deadlineReached).toBe(false); + expect(events).toEqual([]); expect(fixture.child.killed).toBe(true); expect(existsSync(String(fixture.options()!.env!.HOME))).toBe(false); expect(existsSync(String(fixture.options()!.cwd))).toBe(false); @@ -272,11 +274,12 @@ describe("TRAE CN ACP regression contract", () => { expect(budget.state).toEqual({ used: 0, refunded: 1, closed: 1 }); }); - for (const kind of ["tools", "required", "toolCall", "toolResult", "image", "audio", "video", "document", "oversized"]) { + 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"]; @@ -359,6 +362,35 @@ describe("TRAE CN ACP regression contract", () => { expect(fixture.child.killed).toBe(true); }); + 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: "incomplete", reason: "upstream_cancelled", retryable: false, usage: { inputTokens: 3, outputTokens: 0 } }]); + }); + + for (const selected of ["test-model", "unexpected-model"]) { + test(`model-selection notification validates ${selected}`, async () => { + const fixture = traeFixture(request => { + if (request.method === "session/set_model") 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" }); + }); + } + + test("relative PATH entries resolve before the CLI changes its working directory", () => { + expect(resolveTraeCli({ platform: "linux", env: { PATH: "./node_modules/.bin" }, exists: () => true })) + .toBe(`${process.cwd()}/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 => { @@ -387,6 +419,21 @@ describe("TRAE CN ACP regression contract", () => { expect(existsSync(String(fixture.options()!.cwd))).toBe(false); }); + test("POSIX shutdown kills remaining group members after the leader exits", async () => { + const fixture = traeFixture(); + Object.assign(fixture.child, { pid: 424242 }); + const signals: NodeJS.Signals[] = []; + const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, platform: "linux", + signalPosixProcessGroup: (pid, signal) => { + expect(pid).toBe(424242); + signals.push(signal); + if (signal === "SIGTERM") fixture.child.kill(); + }, + }); + await session.close(); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + }); + test("Windows dispatchers use process-tree termination", async () => { const fixture = traeFixture(); Object.assign(fixture.child, { pid: 4242 }); @@ -455,6 +502,19 @@ describe("TRAE CN ACP regression contract", () => { 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")); From 649d7dd092fd14e9369505b888cba170f9890ae2 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 19:24:50 +0800 Subject: [PATCH 05/64] fix(trae-cn): redact protocol failures and preserve CLI sign-in Co-authored-by: Codex --- .../docs/fr/getting-started/quickstart.md | 3 ++ .../src/content/docs/guides/providers.md | 7 +-- .../docs/ja/getting-started/quickstart.md | 3 ++ .../docs/ko/getting-started/quickstart.md | 3 ++ .../docs/ru/getting-started/quickstart.md | 3 ++ .../docs/tr/getting-started/quickstart.md | 3 ++ .../docs/zh-cn/getting-started/quickstart.md | 3 ++ .../docs/zh-tw/getting-started/quickstart.md | 3 ++ src/adapters/trae-cn/adapter.ts | 2 +- src/adapters/trae-cn/profile.ts | 9 ++-- src/adapters/trae-cn/session.ts | 6 ++- structure/providers/trae-cn.md | 4 +- .../adapter-registry-authority.test.ts | 43 ++++++++++++++++++- .../docs-provider-preset-counts.test.ts | 22 +++++++++- 14 files changed, 100 insertions(+), 14 deletions(-) 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/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 86fd9497fc1..304e3afde53 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1475,13 +1475,14 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through - **Prerequisites:** Install and sign in to [TRAE CN](https://www.trae.cn/) and its official CLI. The CLI owns authentication and token refresh; the provider needs no API key. - Set `TRAE_HOME` to your signed-in CLI directory before starting OpenCodex if authentication - depends on that directory, including when the CLI uses the default `~/.trae` location. + 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:** `session/new` returns the models available to the signed-in account. Successful discovery is cached by the normal provider catalog mechanism, with the last good catalog and a small text-only seed as degraded fallbacks. Read-only catalog gathers use cached evidence; they never start the CLI or refresh its sign-in. - Set `liveModels: false` for a static allowlist. After switching CLI accounts, **Test connection** + 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. - **Transport:** Each turn creates a fresh ACP session, selects the requested model with 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/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/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/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/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-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/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 8e9952309d3..ce4c46f4ec3 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -51,7 +51,7 @@ function errorEvent(error: unknown): Extract { const acp = error instanceof TraeAcpError; const code = acp ? error.code : "trae_acp_protocol_error"; const auth = acp && /invalid token|unauthorized|not logged in|authentication/i.test(error.message); - const message = error instanceof Error ? error.message : "TRAE CN ACP ended with an invalid protocol response."; + 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"; return { type: "error", diff --git a/src/adapters/trae-cn/profile.ts b/src/adapters/trae-cn/profile.ts index 748fede6e3d..283bfe15ea7 100644 --- a/src/adapters/trae-cn/profile.ts +++ b/src/adapters/trae-cn/profile.ts @@ -10,9 +10,12 @@ export function traeCnDestinationMatches(baseUrl: string | undefined): boolean { return baseUrl?.replace(/\/+$/, "").toLowerCase() === TRAE_CN_BASE_URL; } -/** Keep the child environment scoped while preserving the selected official CLI directory. */ -export function traeCnChildEnv(): Record { - return { ...baseScopedEnv(), NO_COLOR: "1", ...(process.env.TRAE_HOME ? { TRAE_HOME: process.env.TRAE_HOME } : {}) }; +/** 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.join(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. */ diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 662e765623a..7e37f49abc3 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -111,7 +111,9 @@ class TraeAcpClient { } this.fail(this.failure ?? new TraeAcpError("TRAE CN ACP closed before the turn completed", "trae_acp_closed")); } catch (error) { - this.fail(error instanceof Error ? error : new Error(String(error))); + // The shared JSONL parser includes stdout snippets, which can contain partial secrets. + this.fail(error instanceof TraeAcpError ? error + : new TraeAcpProtocolError("TRAE CN ACP returned malformed or unsupported protocol data.")); } } @@ -212,7 +214,7 @@ export async function openTraeSession( if (!traeCnDestinationMatches(baseUrl)) throw new TraeAcpError("TRAE CN requires its canonical provider destination", "non_canonical_destination"); const binary = (deps.resolveBinary ?? resolveTraeCli)(); if (!binary) throw new TraeAcpError("TRAE CN CLI not found. Install traecli and sign in through TRAE CN.", "cli_not_found"); - const env = traeCnChildEnv(); + const env = traeCnChildEnv(process.env, deps.platform ?? process.platform); const ownHome = deps.home === undefined; const makeTempDir = deps.makeTempDir ?? mkdtemp; const home = deps.home ?? await makeTempDir(join(tmpdir(), "ocx-trae-cn-")); diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 3cd59039636..021c4e5d8e6 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -14,7 +14,8 @@ cached fallbacks reapply current caps and retention; a cold failure also observe `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 `TRAE_HOME` when selected by the operator. Provider `proxy`/`noProxy` overrides +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 use only cached evidence; only refreshing gathers and explicit connection probes may launch the CLI. Relative PATH entries resolve before the child changes its cwd. @@ -27,6 +28,7 @@ already-created owned HOME. Prompt dispatch alone consumes and reports the reser failure or unavailable models return unused reservations. 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. +Malformed stdout produces fixed protocol diagnostics, never the shared JSONL parser's snippets. Client cancellation emits no provider error; an upstream cancellation without client abort emits an incomplete event with usage. Explicit output-token limits are refused before startup. Model-selection notifications must match the requested model. The finite CLI tool denylist and diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index a4bfe2f10fe..ab08ab13e16 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -12,7 +12,7 @@ import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; import { captureModelCacheGeneration, clearModelCache, getFreshCached, 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 { isModelVisionSidecarConsumer, isVisionSidecarConsumer, modelAcceptsImageInput } from "../../src/vision/eligibility"; +import { customRowInputModalities, isModelVisionSidecarConsumer, isVisionSidecarConsumer, modelAcceptsImageInput } from "../../src/vision/eligibility"; import { requiresVisionPreprocessing } from "../../src/vision/plan"; import type { CatalogDisposition } from "../../src/codex/convergence-types"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; @@ -362,6 +362,29 @@ describe("TRAE CN ACP regression contract", () => { expect(fixture.child.killed).toBe(true); }); + 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: "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; @@ -469,6 +492,13 @@ describe("TRAE CN ACP regression contract", () => { } 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(`${process.cwd()}/selected-trae`); + }); + 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, @@ -481,11 +511,20 @@ describe("TRAE CN ACP regression contract", () => { 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", id: "future-model", inputModalities: ["text", "image"] }] }; + 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); + }); + test("discovered models do not advertise unsupported verbosity or native image input", async () => { const fixture = traeFixture(); const realOpen = traeClient.openTraeSession; diff --git a/tests/ci-workflows/docs-provider-preset-counts.test.ts b/tests/ci-workflows/docs-provider-preset-counts.test.ts index d7b52c3e8de..5ae7b436176 100644 --- a/tests/ci-workflows/docs-provider-preset-counts.test.ts +++ b/tests/ci-workflows/docs-provider-preset-counts.test.ts @@ -62,11 +62,29 @@ 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(/leave.*key prompt empty/); - expect(note).toMatch(/Install and sign in to the CLI first/); + 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); From 62e9e33142a086d33050ae339363b73990dbf85c Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 20:21:36 +0800 Subject: [PATCH 06/64] fix(trae-cn): bound streaming and supervise process cleanup Co-authored-by: Codex --- .../src/content/docs/guides/providers.md | 12 +- src/adapters/trae-cn/adapter.ts | 12 +- src/adapters/trae-cn/process.ts | 47 +++++ src/adapters/trae-cn/session.ts | 93 +++++---- src/codex/catalog/routed-gather.ts | 4 +- src/codex/catalog/trae-cn-models.ts | 21 ++- src/codex/management-convergence.ts | 5 + .../management/trae-cn-provider-probe.ts | 9 +- structure/catalog-vendor-context.md | 6 +- structure/catalog.md | 3 +- structure/providers/trae-cn.md | 17 +- .../adapter-registry-authority.test.ts | 178 ++++++++++++++++-- 12 files changed, 330 insertions(+), 77 deletions(-) create mode 100644 src/adapters/trae-cn/process.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 304e3afde53..512586865c6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1485,11 +1485,10 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through 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. -- **Transport:** Each turn creates a fresh ACP session, selects the requested model with - `session/set_model`, and maps `agent_message_chunk` and `agent_thought_chunk` to OpenCodex text - and reasoning events. Conversation history is replayed as labelled text. - Replays longer than 1,000,000 UTF-16 code units (JavaScript string length) are rejected so - system instructions cannot be silently lost. A supplementary character such as an emoji uses two units. + If the provider changes during the probe, its roster is not replaced; run **Test connection** 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. @@ -1500,7 +1499,8 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through Requests with an output-token limit fail with `unsupported_max_output_tokens` because this adapter has no verified ACP field for that limit. - **CLI process:** The CLI uses an empty temporary workspace, no session MCP/skills and a finite tool denylist. - OpenCodex terminates its process tree after the turn, cancellation or protocol failure. + Shutdown targets the owned POSIX process group or the Windows process tree. 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 diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index ce4c46f4ec3..dabfd5aebc3 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -53,11 +53,12 @@ function errorEvent(error: unknown): Extract { 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 : 502, - errorType: invalid ? "invalid_request_error" : auth ? "authentication_error" : "upstream_error", + 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" : code, retryable: false, }; @@ -87,8 +88,12 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda if (decision && !decision.allowed) throw new SendBudgetExhaustedError(); permit = decision?.allowed ? decision.permit : undefined; const session = await openTraeSession(provider.baseUrl, deps, incoming.abortSignal, provider); + let terminal: AdapterEvent | undefined; try { - await session.prompt(parsed.modelId, replay, emit, incoming.abortSignal, { + 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(); @@ -98,6 +103,7 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda } finally { await session.close(); } + if (terminal && !incoming.abortSignal?.aborted) emit(terminal); } catch (error) { if (incoming.abortSignal?.aborted) return; if (error instanceof SendBudgetExhaustedError) throw error; diff --git a/src/adapters/trae-cn/process.ts b/src/adapters/trae-cn/process.ts new file mode 100644 index 00000000000..647cc52d08a --- /dev/null +++ b/src/adapters/trae-cn/process.ts @@ -0,0 +1,47 @@ +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 3<&- 4<&- +wait "$cli" +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/session.ts b/src/adapters/trae-cn/session.ts index 7e37f49abc3..1101a07d92b 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -5,8 +5,9 @@ import { join } from "node:path"; import { commandInvocation } from "../../lib/win-exec"; import { redactSecretString } from "../../lib/redact"; import type { AdapterEvent, OcxProviderConfig } from "../../types"; -import { readJsonLines } from "../coding-agent/protocol"; +import { CodingAgentStreamLimitError, readJsonLines } from "../coding-agent/protocol"; import { resolveTraeCli, traeCnChildEnv, traeCnDestinationMatches } from "./profile"; +import { traePosixInvocation, traeStopPipe } from "./process"; import { parseTraeAcpMessage, parseTraeModels, traePromptEvents, traeSessionId, traeStopReason, traeUsage, TraeAcpProtocolError, type TraeAcpFrame } from "./protocol"; export type SpawnTraeCli = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; @@ -20,7 +21,6 @@ export interface TraeAcpDeps { killGraceMs?: number; reapTimeoutMs?: number; killWindowsProcessTree?: (pid: number) => void; - signalPosixProcessGroup?: (pid: number, signal: NodeJS.Signals) => void; makeTempDir?: typeof mkdtemp; } @@ -31,6 +31,13 @@ export class TraeAcpError extends Error { } } +/** 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 { @@ -59,15 +66,17 @@ class TraeAcpClient { 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) { this.lifecycle = new Promise(resolve => { - child.once("error", error => { this.failure ??= error; this.fail(error); }); + child.once("error", error => this.fail(startupFailure("spawn", error))); child.once("close", resolve); if (child.exitCode !== null) resolve(); }); child.stdin?.on("error", error => this.fail(error)); + traeStopPipe(child)?.on("error", () => undefined); child.stderr?.on("data", (chunk: Uint8Array) => { this.stderr.push(new TextDecoder().decode(chunk)); while (this.stderr.join("").length > 8192) this.stderr.shift(); @@ -112,7 +121,9 @@ class TraeAcpClient { 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 TraeAcpError ? error + 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.")); } } @@ -146,10 +157,16 @@ class TraeAcpClient { nextUpdate(): Promise { const queued = this.updates.shift(); if (queued) return Promise.resolve(queued); - if (this.closed) return Promise.resolve(null); + 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 { return redactSecretString(this.stderr.join("").trim() || this.failure?.message || "").trim().slice(0, 512); @@ -160,37 +177,39 @@ class TraeAcpClient { if (this.closing) return this.closing; this.fail(new TraeAcpError("TRAE CN ACP session closed", "trae_acp_closed")); this.closing = (async () => { - const signalGroup = (pid: number, signal: NodeJS.Signals): void => { - if (this.deps.signalPosixProcessGroup) this.deps.signalPosixProcessGroup(pid, signal); - else process.kill(-pid, signal); - }; - const terminate = (signal: NodeJS.Signals): void => { + const terminateWindows = (signal: NodeJS.Signals): void => { const pid = this.child.pid; - if (pid && (this.deps.platform ?? process.platform) === "win32") { + if (this.child.exitCode !== null || this.child.signalCode) return; + if (pid) { try { if (this.deps.killWindowsProcessTree) this.deps.killWindowsProcessTree(pid); else execFileSync(`${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`, ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 1000 }); return; } catch { /* Fall back to the direct child if tree termination is unavailable. */ } - } else if (pid) { - try { signalGroup(pid, signal); return; } catch { /* The process group may already be gone. */ } } try { this.child.kill(signal); } catch { /* Already exited. */ } }; - terminate("SIGTERM"); const reapTimeoutMs = this.deps.reapTimeoutMs ?? 2250; - if (!await waitBounded(this.lifecycle, this.deps.killGraceMs ?? 2000)) { - terminate("SIGKILL"); - await waitBounded(this.lifecycle, reapTimeoutMs); - } else if (this.child.pid && (this.deps.platform ?? process.platform) !== "win32") { - // The group can outlive its leader, even when the leader closed every pipe. - try { signalGroup(this.child.pid, "SIGKILL"); } catch { /* The group has already exited. */ } + const graceMs = this.deps.killGraceMs ?? 2000; + let stopped: boolean; + if ((this.deps.platform ?? process.platform) === "win32") { + terminateWindows("SIGTERM"); + stopped = await waitBounded(this.lifecycle, graceMs); + if (!stopped) { + terminateWindows("SIGKILL"); + stopped = await waitBounded(this.lifecycle, reapTimeoutMs); + } + } 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; } @@ -212,16 +231,19 @@ export async function openTraeSession( 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"); - const binary = (deps.resolveBinary ?? resolveTraeCli)(); + 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"); - const env = traeCnChildEnv(process.env, deps.platform ?? process.platform); + 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; - const home = deps.home ?? await makeTempDir(join(tmpdir(), "ocx-trae-cn-")); + 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 error; + throw startupFailure("workspace", error); } const cleanup = async (): Promise => { await Promise.all([ @@ -233,21 +255,24 @@ export async function openTraeSession( 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"]; - const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env }); let child: ChildProcess; try { signal?.throwIfAborted(); - child = (deps.spawn ?? nodeSpawn)(invocation.file, invocation.args, { - ...invocation.options, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, - detached: (deps.platform ?? process.platform) !== "win32", + const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env }); + const windows = (deps.platform ?? process.platform) === "win32"; + const owned = windows ? invocation : traePosixInvocation(invocation.file, invocation.args, deps.killGraceMs ?? 2000); + child = (deps.spawn ?? nodeSpawn)(owned.file, owned.args, { + ...invocation.options, env, stdio: windows ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"], windowsHide: true, + detached: !windows, cwd: deps.cwd ?? work, }); } catch (error) { await cleanup(); - throw error; + if (signal?.aborted) throw error; + throw startupFailure("spawn", error); } const client = new TraeAcpClient(child, deps.timeoutMs ?? 300_000, deps); - const abort = () => { void client.close(); }; + const abort = () => { void client.close().catch(() => undefined); }; signal?.addEventListener("abort", abort, { once: true }); let opened = false; try { @@ -268,7 +293,7 @@ export async function openTraeSession( 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(); }; + const abort = () => { void client.close().catch(() => undefined); }; signal?.addEventListener("abort", abort, { once: true }); let completion: Promise | undefined; try { @@ -276,11 +301,9 @@ export async function openTraeSession( if (signal?.aborted) return; let output = false; completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, dispatch); + void completion.then(() => client.endUpdates(), () => client.endUpdates()); while (true) { - const frame = await Promise.race([ - client.nextUpdate(), - completion.then(() => null, () => null), - ]); + const frame = await client.nextUpdate(); if (!frame) break; if (frame.kind !== "update" || frame.sessionId !== sessionId) continue; if (frame.update.sessionUpdate === "current_model_update") { diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 76e7fe75f75..5573b413660 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 index 92c3cd8c0ee..1c19952dbc5 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -2,6 +2,7 @@ import { openTraeSession } from "../../adapters/trae-cn/client"; import { traeCnDestinationMatches } from "../../adapters/trae-cn/profile"; import { captureModelCacheGeneration, + DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelCacheGenerationCurrent, @@ -16,6 +17,19 @@ 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"; + +/** Refresh CLI-owned evidence before an observed catalog admission freezes its authority. */ +export async function refreshTraeCnCatalogSources(config: Readonly): Promise { + const { fetchProviderModelsWithAuth, refreshingModelsAuthResolver } = await import("./provider-models"); + const { captureProviderGather } = await import("./gather-capture"); + for (const [name, provider] of Object.entries(config.providers)) { + if (provider.adapter !== "trae-cn" || provider.disabled || provider.liveModels === false) continue; + await fetchProviderModelsWithAuth(captureProviderGather(name, provider, refreshingModelsAuthResolver), + config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS, providerContextCap(config, name), refreshingModelsAuthResolver); + } +} /** Discover an account roster with bounded failure retries and current hints on every cached return. */ export async function fetchTraeCnProviderModels( @@ -44,14 +58,15 @@ export async function fetchTraeCnProviderModels( if (!allowDiscovery) return observed(stale ?? configured, "degraded"); if (isModelsFetchCoolingDown(name)) return observed(stale ?? configured, "degraded"); const generation = captureModelCacheGeneration(name); + 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 observed(stale ?? configured, "degraded"); + 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 observed(stale ?? configured, "degraded"); + if (!setCached(name, models, Date.now(), generation)) return currentFallback(); markProviderDiscoveryOk(name, models.length); return observed(models, "authoritative"); } catch { @@ -59,6 +74,6 @@ export async function fetchTraeCnProviderModels( markModelsFetchFailure(name); markProviderDiscoveryFailed(name, { reason: "provider" }); } - return observed(stale ?? configured, "degraded"); + return currentFallback(); } } diff --git a/src/codex/management-convergence.ts b/src/codex/management-convergence.ts index 47f8fbe6a63..4e4ec401d97 100644 --- a/src/codex/management-convergence.ts +++ b/src/codex/management-convergence.ts @@ -165,6 +165,11 @@ export function createManagementConvergeCodex( catalogRefresh: { status: "skipped", reason: "refused", retryable: false }, }); } + if (Object.values(retainedConfig.providers).some(provider => + provider.adapter === "trae-cn" && !provider.disabled && provider.liveModels !== false)) { + const { refreshTraeCnCatalogSources } = await import("./catalog/trae-cn-models"); + await refreshTraeCnCatalogSources(retainedConfig); + } const snapshot = captureCatalogAdmissionSnapshot(retainedConfig); const result = await convergeCodexCatalog(snapshot, request, { onCommitBegin: () => { commitBegan = true; }, diff --git a/src/server/management/trae-cn-provider-probe.ts b/src/server/management/trae-cn-provider-probe.ts index dc127cb3073..d789f7d117c 100644 --- a/src/server/management/trae-cn-provider-probe.ts +++ b/src/server/management/trae-cn-provider-probe.ts @@ -22,12 +22,14 @@ export async function probeTraeCnProvider( 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 clientIntegrations: OwnedIntegrationRefreshOutcome[] = []; let clientRefreshPending = false; if (isModelCacheGenerationCurrent(name, generation)) { clearModelCache(name); setCached(name, session.models.map(id => ({ id, provider: name, inputModalities: ["text"], supportsVerbosity: false })), Date.now(), captureModelCacheGeneration(name)); + rosterReplaced = true; markProviderDiscoveryOk(name, session.models.length); clearGatherRoutedModelsInflight(); const publishedGeneration = captureModelCacheGeneration(name); @@ -44,8 +46,11 @@ export async function probeTraeCnProvider( } } const published = catalogRefresh.status === "committed" && !catalogRefresh.degraded; - return jsonResponse({ ok: true, models: session.models.length, latencyMs: Date.now() - started, catalogRefresh, clientIntegrations, clientRefreshPending, - message: `Connected. ${session.models.length} models. ${published && !clientRefreshPending + const staleProbe = !rosterReplaced; + return jsonResponse({ ok: true, models: session.models.length, latencyMs: Date.now() - started, rosterReplaced, catalogRefresh, clientIntegrations, clientRefreshPending, + message: `Connected. ${session.models.length} models. ${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) { diff --git a/structure/catalog-vendor-context.md b/structure/catalog-vendor-context.md index 4bfbf1b1271..8ed56fe75ff 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 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 f9193633c27..68795a78d95 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 is refreshed in `src/codex/management-convergence.ts` before catalog admission captures evidence. Observed gathers remain cache-only. 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, diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 021c4e5d8e6..1f75860236d 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -18,17 +18,25 @@ The CLI inherits an absolute `TRAE_HOME` when selected by the operator, otherwis user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overrides are refused before CLI startup, including explicit direct routing. Observed-auth gathers use only cached evidence; only refreshing gathers and explicit connection -probes may launch the CLI. Relative PATH entries resolve before the child changes its cwd. +probes may launch the CLI. Management convergence refreshes CLI-owned evidence before catalog +admission, so a cold automatic tick can populate it. Lost-generation discovery rereads the current +cache before attaching its revision. Relative PATH entries resolve before the child changes its cwd. +Custom catalog rows 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 -process tree and bounds both process and stream waits. A final SIGKILL terminates surviving POSIX -group members even after the leader exits. A failed workspace allocation removes an +process tree 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. +Unconfirmed shutdown is an error; the adapter publishes its terminal event after cleanup. A failed workspace allocation removes an already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send; startup failure or unavailable models return unused reservations. 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. 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. +Prompt completion closes the bounded update stream once, without a new promise reaction per chunk. Client cancellation emits no provider error; an upstream cancellation without client abort emits an incomplete event with usage. Explicit output-token limits are refused before startup. Model-selection notifications must match the requested model. The finite CLI tool denylist and @@ -38,7 +46,8 @@ disabled ACP client capabilities do not sandbox CLI-side hooks or unlisted vendo 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 response separates connectivity from catalog/client publication and notes the running Codex -session restart requirement. A probe overtaken by a cache invalidation cannot replace the new roster. +session restart requirement. An overtaken probe reports `rosterReplaced: false` and asks for another +connection test; it cannot replace the new roster. 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. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index ab08ab13e16..1acccb6d889 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1,19 +1,25 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import { EventEmitter } from "node:events"; -import { existsSync } from "node:fs"; +import { EventEmitter, once } from "node:events"; +import { existsSync, readFileSync } from "node:fs"; +import { posix } from "node:path"; import { mkdtemp } from "node:fs/promises"; import { PassThrough, Writable } from "node:stream"; -import type { ChildProcess, SpawnOptions } from "node:child_process"; +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 { resolveTraeCli, traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; import { fetchProviderModels, fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models"; import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; -import { captureModelCacheGeneration, clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; +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 catalogAdmission from "../../src/codex/catalog-admission"; import type { CatalogDisposition } from "../../src/codex/convergence-types"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { AdapterEvent } from "../../src/types"; @@ -174,6 +180,8 @@ function traeFixture(intercept?: (request: AcpRequest, child: ChildProcess) => b 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 = { resolveBinary: () => "/fixture/traecli", timeoutMs: 300, killGraceMs: 2, reapTimeoutMs: 5, spawn: (_command, _args, opts) => { options = opts; return child; }, @@ -362,6 +370,25 @@ describe("TRAE CN ACP regression contract", () => { 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) => { @@ -411,7 +438,7 @@ describe("TRAE CN ACP regression contract", () => { test("relative PATH entries resolve before the CLI changes its working directory", () => { expect(resolveTraeCli({ platform: "linux", env: { PATH: "./node_modules/.bin" }, exists: () => true })) - .toBe(`${process.cwd()}/node_modules/.bin/traecli`); + .toBe(posix.resolve("./node_modules/.bin/traecli")); }); for (const kind of ["tool_call", "session/request_permission"]) { @@ -436,27 +463,87 @@ describe("TRAE CN ACP regression contract", () => { fixture.child.kill = () => true; const session = await traeClient.openTraeSession(config().baseUrl, fixture.deps); const turn = session.prompt("test-model", "hello", () => undefined).catch(error => error); - await session.close(); + 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(false); }); - test("POSIX shutdown kills remaining group members after the leader exits", async () => { + test("POSIX shutdown uses the owned pipe without signalling a possibly reused PID", async () => { const fixture = traeFixture(); Object.assign(fixture.child, { pid: 424242 }); - const signals: NodeJS.Signals[] = []; - const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, platform: "linux", - signalPosixProcessGroup: (pid, signal) => { - expect(pid).toBe(424242); - signals.push(signal); - if (signal === "SIGTERM") fixture.child.kill(); - }, - }); + 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(signals).toEqual(["SIGTERM", "SIGKILL"]); + 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); + test("Windows dispatchers use process-tree termination", async () => { const fixture = traeFixture(); Object.assign(fixture.child, { pid: 4242 }); @@ -476,11 +563,11 @@ describe("TRAE CN ACP regression contract", () => { owned = await mkdtemp(prefix); return owned; }) as typeof mkdtemp; - await expect(traeClient.openTraeSession(config().baseUrl, { resolveBinary: () => "/fixture/traecli", makeTempDir })).rejects.toThrow("workspace unavailable"); + 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("workspace unavailable"); + })).rejects.toThrow("startup failed during workspace"); expect(existsSync(testHome.root)).toBe(true); }); @@ -496,7 +583,7 @@ describe("TRAE CN ACP regression contract", () => { 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(`${process.cwd()}/selected-trae`); + .toBe(posix.resolve("./selected-trae")); }); test("static catalogs never open ACP and retain unknown text-only models", async () => { @@ -525,6 +612,55 @@ describe("TRAE CN ACP regression contract", () => { 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"] }], + }); + expect(models.find(model => model.provider === "trae-cn" && model.id === "test-model")) + .toMatchObject({ inputModalities: enabled ? ["text", "image"] : ["text"], supportsVerbosity: false }); + }); + } + + test("management convergence refreshes CLI evidence before catalog admission without an initial selection", async () => { + const fixture = traeFixture(); + const realOpen = traeClient.openTraeSession; + const open = spyOn(traeClient, "openTraeSession").mockImplementation((baseUrl, _deps, signal) => realOpen(baseUrl, fixture.deps, signal)); + const admission = spyOn(catalogAdmission, "captureCatalogAdmissionSnapshot").mockImplementation(() => { + expect(getFreshCached("trae-cn", 60_000, Date.now())?.map(model => model.id)).toEqual(["test-model"]); + throw new Error("fixture stops before any catalog write"); + }); + const next = { port: 10100, defaultProvider: "trae-cn", providers: { "trae-cn": { ...config(), models: [] } } }; + await managementConvergence.createManagementConvergeCodex(next)(catalogAdmission.createCatalogConvergeRequest({ deadlineMs: 1000 })); + expect(open).toHaveBeenCalledTimes(1); + expect(admission).toHaveBeenCalledTimes(1); + await refreshTraeCnCatalogSources(next); + expect(open).toHaveBeenCalledTimes(1); + }); + + 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; @@ -600,7 +736,9 @@ describe("TRAE CN ACP regression contract", () => { 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" }] } } }); - expect(await (await probe).json()).toMatchObject({ ok: true, catalogRefresh: { status: "skipped", reason: "stale" } }); + 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(); }); From ecfca0c441c5a1d22277a842856bf259265a472e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 21:27:12 +0800 Subject: [PATCH 07/64] fix(trae-cn): retain job ownership and safe diagnostics --- .../src/content/docs/guides/providers.md | 5 +- src/adapters/trae-cn/adapter.ts | 4 +- src/adapters/trae-cn/process.ts | 5 +- src/adapters/trae-cn/session.ts | 106 +++++++---- src/adapters/trae-cn/windows-job-source.ts | 172 ++++++++++++++++++ src/adapters/trae-cn/windows-job.ts | 80 ++++++++ src/codex/catalog/combo-member.ts | 3 + structure/catalog-vendor-context.md | 2 +- structure/providers/trae-cn.md | 21 ++- .../adapter-registry-authority.test.ts | 146 +++++++++++++-- 10 files changed, 486 insertions(+), 58 deletions(-) create mode 100644 src/adapters/trae-cn/windows-job-source.ts create mode 100644 src/adapters/trae-cn/windows-job.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 512586865c6..7193715ecd4 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1485,7 +1485,8 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through 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. - If the provider changes during the probe, its roster is not replaced; run **Test connection** again. + 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. @@ -1499,7 +1500,7 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through Requests with an output-token limit fail with `unsupported_max_output_tokens` because this adapter has no verified ACP field for that limit. - **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 the Windows process tree. POSIX descendants + 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. diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index dabfd5aebc3..fdbc47a6f02 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -82,13 +82,13 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda if (incoming.abortSignal?.aborted) return; const producer = incoming.sendBudget?.beginSpendProducer?.(); let permit: SingleUseDispatchPermit | undefined; + let terminal: Extract | undefined; 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 terminal: AdapterEvent | undefined; try { await session.prompt(parsed.modelId, replay, event => { if (event.type === "done" || event.type === "incomplete" || event.type === "error") terminal = event; @@ -107,7 +107,7 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda } catch (error) { if (incoming.abortSignal?.aborted) return; if (error instanceof SendBudgetExhaustedError) throw error; - emit(errorEvent(error)); + emit({ ...errorEvent(error), ...(terminal?.usage ? { usage: terminal.usage } : {}) }); } finally { try { permit?.release(); } finally { producer?.close(); } } diff --git a/src/adapters/trae-cn/process.ts b/src/adapters/trae-cn/process.ts index 647cc52d08a..e6d6afa1800 100644 --- a/src/adapters/trae-cn/process.ts +++ b/src/adapters/trae-cn/process.ts @@ -27,8 +27,11 @@ exec 4<&0 guard=$! "$@" <&4 3<&- 4<&- & cli=$! -exec 3<&- 4<&- +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 diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 1101a07d92b..e42689e6fc6 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +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"; @@ -8,6 +8,7 @@ import type { AdapterEvent, OcxProviderConfig } 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; @@ -20,7 +21,6 @@ export interface TraeAcpDeps { timeoutMs?: number; killGraceMs?: number; reapTimeoutMs?: number; - killWindowsProcessTree?: (pid: number) => void; makeTempDir?: typeof mkdtemp; } @@ -61,7 +61,12 @@ class TraeAcpClient { private readonly pending = new Map(); private readonly updates: TraeAcpFrame[] = []; private readonly waiters: Array<(frame: TraeAcpFrame | null) => void> = []; - private readonly stderr: string[] = []; + private stderr = ""; + private stderrTruncated = false; + private readonly windowsFrames?: TraeWindowsJobFrames; + private initialized = false; + private launchStatus?: number; + private spawnFailed = false; private readonly reader: Promise; private readonly lifecycle: Promise; private failure?: Error; @@ -70,21 +75,54 @@ class TraeAcpClient { 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.fail(startupFailure("spawn", error))); - child.once("close", resolve); - if (child.exitCode !== null) 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) => { - this.stderr.push(new TextDecoder().decode(chunk)); - while (this.stderr.join("").length > 8192) this.stderr.shift(); + 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; } + + 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); + } + /** Wake every waiter immediately; process reaping is separate from request settlement. */ private fail(error: Error): void { if (this.closed) return; @@ -169,7 +207,8 @@ class TraeAcpClient { /** Expose only a bounded, redacted diagnostic when the CLI closes unexpectedly. */ diagnostic(): string { - return redactSecretString(this.stderr.join("").trim() || this.failure?.message || "").trim().slice(0, 512); + 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); } /** Stop RPCs first, then terminate the owned process tree and bound all remaining waits. */ @@ -177,29 +216,19 @@ class TraeAcpClient { if (this.closing) return this.closing; this.fail(new TraeAcpError("TRAE CN ACP session closed", "trae_acp_closed")); this.closing = (async () => { - const terminateWindows = (signal: NodeJS.Signals): void => { - const pid = this.child.pid; - if (this.child.exitCode !== null || this.child.signalCode) return; - if (pid) { - try { - if (this.deps.killWindowsProcessTree) this.deps.killWindowsProcessTree(pid); - else execFileSync(`${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`, ["/PID", String(pid), "/T", "/F"], - { stdio: "ignore", windowsHide: true, timeout: 1000 }); - return; - } catch { /* Fall back to the direct child if tree termination is unavailable. */ } - } - try { this.child.kill(signal); } catch { /* Already exited. */ } - }; const reapTimeoutMs = this.deps.reapTimeoutMs ?? 2250; const graceMs = this.deps.killGraceMs ?? 2000; let stopped: boolean; if ((this.deps.platform ?? process.platform) === "win32") { - terminateWindows("SIGTERM"); - stopped = await waitBounded(this.lifecycle, graceMs); - if (!stopped) { - terminateWindows("SIGKILL"); - stopped = await waitBounded(this.lifecycle, reapTimeoutMs); + 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); @@ -245,7 +274,9 @@ export async function openTraeSession( 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)] : []), @@ -260,9 +291,13 @@ export async function openTraeSession( signal?.throwIfAborted(); const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env }); const windows = (deps.platform ?? process.platform) === "win32"; - const owned = windows ? invocation : traePosixInvocation(invocation.file, invocation.args, deps.killGraceMs ?? 2000); + 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, { - ...invocation.options, env, stdio: windows ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"], windowsHide: true, + ...owned.options, env, stdio: windows ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"], windowsHide: true, detached: !windows, cwd: deps.cwd ?? work, }); @@ -282,6 +317,7 @@ export async function openTraeSession( 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: [] } } }, }); @@ -341,7 +377,7 @@ export async function openTraeSession( } catch (error) { if (error instanceof TraeAcpError && error.code === "trae_acp_closed") { await client.close(); - throw new TraeAcpError(client.diagnostic() || error.message, error.code); + throw client.closedError(error); } throw error; } finally { @@ -351,21 +387,21 @@ export async function openTraeSession( }, close: async () => { signal?.removeEventListener("abort", abort); - try { await client.close(); } finally { await cleanup(); } + await client.close(); + await cleanup(); }, }; } catch (error) { if (error instanceof TraeAcpError && error.code === "trae_acp_closed") { await client.close(); - const detail = client.diagnostic(); - if (detail && detail !== error.message) throw new TraeAcpError(detail, error.code); + throw client.closedError(error); } throw error; } finally { if (!opened) { signal?.removeEventListener("abort", abort); - await client.close().catch(() => undefined); - await cleanup(); + // 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..58c21678d87 --- /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()); } + catch { } + 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 { } + 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..b3f1a646185 --- /dev/null +++ b/src/adapters/trae-cn/windows-job.ts @@ -0,0 +1,80 @@ +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"; + +/** 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 { + 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(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) {} + + 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); + } + } + } + + 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; + } + + 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/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/structure/catalog-vendor-context.md b/structure/catalog-vendor-context.md index 8ed56fe75ff..c5083d7f11b 100644 --- a/structure/catalog-vendor-context.md +++ b/structure/catalog-vendor-context.md @@ -34,7 +34,7 @@ class accompany every inferred value. These are bundled estimates, not live gate 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. Explicit modalities and reasoning retain their precedence within adapter wire ceilings; TRAE CN -custom rows are text-only with verbosity disabled before optional vision-sidecar publication. +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 diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 1f75860236d..4e9e09357fc 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -21,21 +21,29 @@ Observed-auth gathers use only cached evidence; only refreshing gathers and expl probes may launch the CLI. Management convergence refreshes CLI-owned evidence before catalog admission, so a cold automatic tick can populate it. Lost-generation discovery rereads the current cache before attaching its revision. Relative PATH entries resolve before the child changes its cwd. -Custom catalog rows receive the wire modality/verbosity ceiling before optional sidecar publication. +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 -process tree and bounds both process and stream waits. The POSIX supervisor in +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. -Unconfirmed shutdown is an error; the adapter publishes its terminal event after cleanup. A failed workspace allocation removes an +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. +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 after cleanup, preserving received usage even when cleanup fails. A failed workspace allocation removes an already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send; startup failure or unavailable models return unused reservations. 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. +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; an upstream cancellation without client abort emits an incomplete event with usage. Explicit output-token limits are refused before startup. @@ -46,8 +54,9 @@ disabled ACP client capabilities do not sandbox CLI-side hooks or unlisted vendo 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 response separates connectivity from catalog/client publication and notes the running Codex -session restart requirement. An overtaken probe reports `rosterReplaced: false` and asks for another -connection test; it cannot replace the new roster. +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. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 1acccb6d889..62fbbbcb971 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter, once } from "node:events"; -import { existsSync, readFileSync } from "node:fs"; +import { chmodSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { posix } from "node:path"; import { mkdtemp } from "node:fs/promises"; import { PassThrough, Writable } from "node:stream"; @@ -8,6 +8,9 @@ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:c 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 } from "../../src/adapters/trae-cn/windows-job"; +import { commandInvocation } from "../../src/lib/win-exec"; +import { buildCatalogEntries } from "../../src/codex/catalog"; import { resolveTraeCli, traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; import { fetchProviderModels, fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models"; import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; @@ -157,6 +160,7 @@ function traeFixture(intercept?: (request: AcpRequest, child: ChildProcess) => b 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(); @@ -183,6 +187,7 @@ function traeFixture(intercept?: (request: AcpRequest, child: ChildProcess) => b 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; }, }; @@ -461,14 +466,58 @@ describe("TRAE CN ACP regression contract", () => { 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 session = await traeClient.openTraeSession(config().baseUrl, fixture.deps); + const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, home: testHome.root, cwd: testHome.root }); 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(false); + expect(existsSync(String(fixture.options()!.cwd))).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); }); + 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 }); @@ -544,17 +593,89 @@ describe("TRAE CN ACP regression contract", () => { expect(growth).toBeLessThan(16 * 1024 * 1024); }, 15000); - test("Windows dispatchers use process-tree termination", async () => { - const fixture = traeFixture(); - Object.assign(fixture.child, { pid: 4242 }); - const killed: number[] = []; - const session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, platform: "win32", - killWindowsProcessTree: pid => { killed.push(pid); fixture.child.kill(); }, + 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(); }); - await session.close(); - expect(killed).toEqual([4242]); + } + + 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 launcher preserves cmd escaping and owns only its staging directory", async () => { + const invocation = commandInvocation("C:\\Users\\test\\trae cli.cmd", ["a&b", "with space"], "win32"); + expect(traeWindowsCommandLine(invocation)).toEndWith(invocation.args.join(" ")); + const job = await prepareTraeWindowsJob(invocation, testHome.root, testHome.root, 2000, {}); + try { + expect(job.options.windowsVerbatimArguments).toBeUndefined(); + const loader = Buffer.from(job.args.at(-1)!, "base64").toString("utf16le"); + expect(loader).not.toContain("ExecutionPolicy"); + expect(loader).toContain("Add-Type"); + expect(loader.length).toBeLessThan(10000); + } finally { await job.cleanup(); } + expect(existsSync(testHome.root)).toBe(true); + }); + + for (const inheritedPipes of [true, false]) { + test.skipIf(process.platform !== "win32")("Windows Job reaps a helper after its dispatcher exits: inherited pipes=" + inheritedPipes, async () => { + const heartbeat = testHome.path("heartbeat"); + const dispatcher = testHome.path("dispatcher exited"); + 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';", + "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) + ",'exited');process.exit(0);", + ].join("\n")); + writeFileSync(shim, '@"' + process.execPath + '" "' + script + '" %*\r\n'); + const invocation = commandInvocation(shim, ["acp", "serve"], "win32"); + const job = await prepareTraeWindowsJob(invocation, testHome.root, testHome.root, 2000, process.env as Record); + const child = nodeSpawn(job.file, job.args, { ...job.options, 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 { + await closed; + frames.finish(); + expect(failures).toEqual([]); + expect(diagnostic).toBe(""); + expect(frames.processTreeExited).toBe(true); + expect(existsSync(dispatcher)).toBe(true); + const after = readFileSync(heartbeat, "utf8"); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(readFileSync(heartbeat, "utf8")).toBe(after); + } finally { clearTimeout(watchdog); await job.cleanup(); } + }, 20_000); + } + test("workspace allocation failure removes only an owned temporary HOME", async () => { let owned = ""; let calls = 0; @@ -617,9 +738,12 @@ describe("TRAE CN ACP regression contract", () => { 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"]); }); } From fe468ee658a50ab4ce05a0b1cace925e6ca00444 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 21:49:35 +0800 Subject: [PATCH 08/64] fix(trae-cn): resolve Windows hosts and retain failed-turn usage --- .../src/content/docs/guides/providers.md | 2 +- src/adapters/trae-cn/session.ts | 17 +++++-- src/adapters/trae-cn/windows-job.ts | 18 ++++++- structure/providers/trae-cn.md | 7 ++- .../adapter-registry-authority.test.ts | 48 ++++++++++++++++--- 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7193715ecd4..2ecae320638 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1495,7 +1495,7 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through 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`. A normal `end_turn` or `max_tokens` completion with no text - fails with `trae_empty_response`. Refusals preserve usage and terminate without retry. + fails with `trae_empty_response`. Empty responses and refusals preserve usage and terminate without retry. Client cancellation is silent; cancellation initiated by the CLI returns an incomplete turn. Requests with an output-token limit fail with `unsupported_max_output_tokens` because this adapter has no verified ACP field for that limit. diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index e42689e6fc6..38998bd5852 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -359,8 +359,13 @@ export async function openTraeSession( } } const result = await completion; - const stop = traeStopReason(result); const usage = traeUsage(result); + 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") { if (!signal?.aborted) emit({ type: "incomplete", reason: "upstream_cancelled", retryable: false, message: "TRAE CN cancelled the turn.", ...(usage ? { usage } : {}) }); @@ -371,8 +376,14 @@ export async function openTraeSession( message: "TRAE CN refused the turn.", ...(usage ? { usage } : {}) }); return; } - if (stop !== "stop" && stop !== "max_tokens") throw new TraeAcpError(`TRAE CN stopped the turn: ${stop}`, "trae_turn_stopped"); - if (!output) throw new TraeAcpError("TRAE CN ended without a text response.", "trae_empty_response"); + const terminalError = stop !== "stop" && stop !== "max_tokens" + ? { message: "TRAE CN stopped the turn: " + stop, code: "trae_turn_stopped" } + : !output ? { 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") { diff --git a/src/adapters/trae-cn/windows-job.ts b/src/adapters/trae-cn/windows-job.ts index b3f1a646185..1fa7e4aed2b 100644 --- a/src/adapters/trae-cn/windows-job.ts +++ b/src/adapters/trae-cn/windows-job.ts @@ -3,6 +3,21 @@ 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]; +} + +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') + '"'; @@ -16,6 +31,7 @@ export async function prepareTraeWindowsJob(invocation: SpawnInvocation, home: s 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("'", "''") + "'"; @@ -27,7 +43,7 @@ export async function prepareTraeWindowsJob(invocation: SpawnInvocation, home: s "} 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(env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + 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; } diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 4e9e09357fc..93383f12e72 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -6,7 +6,8 @@ model roster and one selected-model turn. The JSON-RPC reader bounds frames, upd 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`; only a -normal `end_turn` or `max_tokens` completion without text fails with `trae_empty_response`. Refusals +normal `end_turn` or `max_tokens` completion without text fails with `trae_empty_response`. +Empty completions and unsupported stop reasons retain reported usage in their terminal error. Refusals emit a non-retryable `content_filter` incomplete event with usage. 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 @@ -33,7 +34,9 @@ On Windows 10+, `src/adapters/trae-cn/windows-job.ts` launches a PowerShell 5.1 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. -Unsupported supervision fails before launching an unowned CLI. No numeric PID is reopened. +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 after cleanup, preserving received usage even when cleanup fails. A failed workspace allocation removes an already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send; startup diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 62fbbbcb971..a5fc6871ec4 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -8,7 +8,7 @@ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:c 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 } from "../../src/adapters/trae-cn/windows-job"; +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 { resolveTraeCli, traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; @@ -466,12 +466,20 @@ describe("TRAE CN ACP regression contract", () => { 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 session = await traeClient.openTraeSession(config().baseUrl, { ...fixture.deps, home: testHome.root, cwd: testHome.root }); + 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 () => { @@ -484,6 +492,24 @@ describe("TRAE CN ACP regression contract", () => { expect(events.some(event => event.type === "done")).toBe(false); }); + for (const stopReason of ["end_turn", "max_tokens", "max_turn_requests"]) { + test("a failed reasoning-only completion preserves 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)); + expect(events.at(-1)).toMatchObject({ type: "error", code: stopReason === "max_turn_requests" ? "trae_acp_protocol_error" : "trae_empty_response", retryable: false, + usage: { inputTokens: 123, outputTokens: 456, totalTokens: 579 } }); + expect(events.some(event => event.type === "done")).toBe(false); + }); + } + for (const consumer of ["adapter", "probe"]) { test("stderr overflow suppresses a split authorization value in " + consumer, async () => { const fixture = traeFixture((request, child) => { @@ -623,14 +649,21 @@ describe("TRAE CN ACP regression contract", () => { }); test("Windows launcher preserves cmd escaping and owns only its staging directory", async () => { - const invocation = commandInvocation("C:\\Users\\test\\trae cli.cmd", ["a&b", "with space"], "win32"); + 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, {}); + 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); @@ -650,9 +683,10 @@ describe("TRAE CN ACP regression contract", () => { "fs.writeFileSync(" + JSON.stringify(dispatcher) + ",'exited');process.exit(0);", ].join("\n")); writeFileSync(shim, '@"' + process.execPath + '" "' + script + '" %*\r\n'); - const invocation = commandInvocation(shim, ["acp", "serve"], "win32"); - const job = await prepareTraeWindowsJob(invocation, testHome.root, testHome.root, 2000, process.env as Record); - const child = nodeSpawn(job.file, job.args, { ...job.options, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + const env = traeCnChildEnv(process.env, "win32"); + const invocation = commandInvocation(shim, ["acp", "serve"], "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[] = []; From 88c543905d918888344685689ad5ec53aa5e3071 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 22:16:30 +0800 Subject: [PATCH 09/64] fix(trae-cn): prevent prompt replay and preserve ACP stops --- .../src/content/docs/guides/providers.md | 6 +-- src/adapters/trae-cn/adapter.ts | 5 ++- src/adapters/trae-cn/profile.ts | 4 +- src/adapters/trae-cn/protocol.ts | 1 + src/adapters/trae-cn/session.ts | 2 +- structure/providers/trae-cn.md | 12 ++++-- .../adapter-registry-authority.test.ts | 39 ++++++++++++----- ...-combo-run-turn-classified-failure.test.ts | 42 ++++++++++++++++++- 8 files changed, 89 insertions(+), 22 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2ecae320638..a6ed623f724 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1494,11 +1494,11 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through 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`. A normal `end_turn` or `max_tokens` completion with no text - fails with `trae_empty_response`. Empty responses and refusals preserve usage and terminate without retry. + 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 is silent; cancellation initiated by the CLI returns an incomplete turn. Requests with an output-token limit fail with `unsupported_max_output_tokens` because this - adapter has no verified ACP field for that limit. + adapter has no verified ACP field for that limit. ACP `max_turn_requests` returns `trae_turn_stopped`. - **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. diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index fdbc47a6f02..5232352763b 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -98,7 +98,10 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda incoming.beforePhysicalSend?.(); if (permit && !permit.use()) throw new SendBudgetExhaustedError(); }, - onSend: () => incoming.onPhysicalSend?.({ ordinal: 1 }), + onSend: () => { + emit({ type: "heartbeat", replayUnsafe: true }); + incoming.onPhysicalSend?.({ ordinal: 1 }); + }, }); } finally { await session.close(); diff --git a/src/adapters/trae-cn/profile.ts b/src/adapters/trae-cn/profile.ts index 283bfe15ea7..61849f16132 100644 --- a/src/adapters/trae-cn/profile.ts +++ b/src/adapters/trae-cn/profile.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { join, posix, win32 } from "node:path"; +import { posix, win32 } from "node:path"; import { whichFromPath, type PathLookupDeps } from "../coding-agent/profile"; import { baseScopedEnv } from "../coding-agent/turn"; @@ -25,6 +25,6 @@ export function resolveTraeCli(deps: PathLookupDeps = {}): string | undefined { if (onPath) return (platform === "win32" ? win32 : posix).resolve(onPath); const home = (deps.env ?? process.env)[platform === "win32" ? "USERPROFILE" : "HOME"]; if (!home) return undefined; - const local = join(home, ".local", "bin", platform === "win32" ? "traecli.exe" : "traecli"); + const local = (platform === "win32" ? win32 : posix).resolve(home, ".local", "bin", platform === "win32" ? "traecli.exe" : "traecli"); return (deps.exists ?? existsSync)(local) ? local : undefined; } diff --git a/src/adapters/trae-cn/protocol.ts b/src/adapters/trae-cn/protocol.ts index b8c5f451b6e..eb86df9b160 100644 --- a/src/adapters/trae-cn/protocol.ts +++ b/src/adapters/trae-cn/protocol.ts @@ -99,6 +99,7 @@ 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)}`); diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 38998bd5852..179db883043 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -378,7 +378,7 @@ export async function openTraeSession( } const terminalError = stop !== "stop" && stop !== "max_tokens" ? { message: "TRAE CN stopped the turn: " + stop, code: "trae_turn_stopped" } - : !output ? { message: "TRAE CN ended without a text response.", code: "trae_empty_response" } : undefined; + : !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 } : {}) }); diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 93383f12e72..595e0576297 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -6,7 +6,9 @@ model roster and one selected-model turn. The JSON-RPC reader bounds frames, upd 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`; only a -normal `end_turn` or `max_tokens` completion without text fails with `trae_empty_response`. +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` reports `trae_turn_stopped` rather than a protocol parsing error. Empty completions and unsupported stop reasons retain reported usage in their terminal error. Refusals emit a non-retryable `content_filter` incomplete event with usage. The CLI uses an empty temporary workspace with disallow rules and no session MCP/skills. `src/codex/catalog/trae-cn-models.ts` publishes discovered @@ -21,7 +23,7 @@ are refused before CLI startup, including explicit direct routing. Observed-auth gathers use only cached evidence; only refreshing gathers and explicit connection probes may launch the CLI. Management convergence refreshes CLI-owned evidence before catalog admission, so a cold automatic tick can populate it. Lost-generation discovery rereads the current -cache before attaching its revision. Relative PATH entries resolve before the child changes its cwd. +cache before attaching its revision. Relative PATH and user-local fallback entries resolve before the child changes its cwd. 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; @@ -39,8 +41,10 @@ absolute executable/dispatcher path. Unsupported supervision fails before launch No numeric PID is reopened. Unconfirmed shutdown is an error and retains owned temporary files; the adapter publishes its terminal event after cleanup, preserving received usage even when cleanup fails. A failed workspace allocation removes an -already-created owned HOME. Prompt dispatch alone consumes and reports the reserved send; startup -failure or unavailable models return unused reservations. Replay over 1,000,000 UTF-16 code units is +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 and keep their existing routing policy. 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. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index a5fc6871ec4..0a337c3ac0a 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1,7 +1,7 @@ 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 { posix } from "node:path"; +import { 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"; @@ -11,6 +11,7 @@ import { traePosixInvocation, traeStopPipe } from "../../src/adapters/trae-cn/pr 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 { buildResponseJSON } from "../../src/bridge"; import { resolveTraeCli, traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; import { fetchProviderModels, fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models"; import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; @@ -234,7 +235,8 @@ describe("TRAE CN ACP regression contract", () => { 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(["thinking_delta", "text_delta", "done"]); + 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 }); @@ -265,7 +267,7 @@ describe("TRAE CN ACP regression contract", () => { }), event => events.push(event)); } finally { clearTimeout(watchdog); } expect(deadlineReached).toBe(false); - expect(events).toEqual([]); + 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); @@ -414,7 +416,7 @@ describe("TRAE CN ACP regression contract", () => { }); const events: AdapterEvent[] = []; await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event)); - expect(events).toMatchObject([{ type: "error", message: "TRAE CN ACP ended with an invalid protocol response.", retryable: false }]); + 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 () => { @@ -425,7 +427,7 @@ describe("TRAE CN ACP regression contract", () => { }); const events: AdapterEvent[] = []; await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event)); - expect(events).toMatchObject([{ type: "incomplete", reason: "upstream_cancelled", retryable: false, usage: { inputTokens: 3, outputTokens: 0 } }]); + 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"]) { @@ -492,8 +494,8 @@ describe("TRAE CN ACP regression contract", () => { expect(events.some(event => event.type === "done")).toBe(false); }); - for (const stopReason of ["end_turn", "max_tokens", "max_turn_requests"]) { - test("a failed reasoning-only completion preserves usage: " + stopReason, async () => { + 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: { @@ -504,9 +506,16 @@ describe("TRAE CN ACP regression contract", () => { }); const events: AdapterEvent[] = []; await createTraeCnAdapter(config(), fixture.deps).runTurn!(traeRequest(), traeMeta(), event => events.push(event)); - expect(events.at(-1)).toMatchObject({ type: "error", code: stopReason === "max_turn_requests" ? "trae_acp_protocol_error" : "trae_empty_response", retryable: false, - usage: { inputTokens: 123, outputTokens: 456, totalTokens: 579 } }); - expect(events.some(event => event.type === "done")).toBe(false); + 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 { + const code = stopReason === "end_turn" ? "trae_empty_response" : stopReason === "max_turn_requests" ? "trae_turn_stopped" : "trae_acp_protocol_error"; + expect(terminal).toMatchObject({ type: "error", code, retryable: false }); + expect(events.some(event => event.type === "done")).toBe(false); + } }); } @@ -741,6 +750,16 @@ describe("TRAE CN ACP regression contract", () => { .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); + if (platform === "win32") expect(traeWindowsJobExecutable(result!, {})).toBe(expected); + }); + } + 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, 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..c5e59248450 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,7 @@ mock.module("../../src/server/adapter-resolve", () => ({ })); const { handleResponses } = await import("../../src/server/responses"); +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"); @@ -116,6 +117,45 @@ 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", 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", 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(); } + }); + 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 () => { From 1ccbcdbae85abbec1e955a4e6b61ef3b6a9822e8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 22:45:06 +0800 Subject: [PATCH 10/64] fix(trae-cn): reconcile ACP state and launcher discovery --- .../src/content/docs/guides/providers.md | 2 +- src/adapters/trae-cn/profile.ts | 7 +- src/adapters/trae-cn/session.ts | 38 +++++++--- structure/providers/trae-cn.md | 9 ++- .../adapter-registry-authority.test.ts | 72 +++++++++++++++++-- 5 files changed, 107 insertions(+), 21 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a6ed623f724..24ce7405b65 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1498,7 +1498,7 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through These completions and refusals retain usage. OpenCodex does not replay a dispatched TRAE prompt on another provider. Client cancellation is silent; cancellation initiated by the CLI returns an incomplete turn. 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 `trae_turn_stopped`. + 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. diff --git a/src/adapters/trae-cn/profile.ts b/src/adapters/trae-cn/profile.ts index 61849f16132..a5b899c2086 100644 --- a/src/adapters/trae-cn/profile.ts +++ b/src/adapters/trae-cn/profile.ts @@ -1,4 +1,3 @@ -import { existsSync } from "node:fs"; import { posix, win32 } from "node:path"; import { whichFromPath, type PathLookupDeps } from "../coding-agent/profile"; import { baseScopedEnv } from "../coding-agent/turn"; @@ -14,7 +13,7 @@ export function traeCnDestinationMatches(baseUrl: string | undefined): boolean { 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.join(home, ".trae") : undefined; + 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 } : {}) }; } @@ -25,6 +24,6 @@ export function resolveTraeCli(deps: PathLookupDeps = {}): string | undefined { if (onPath) return (platform === "win32" ? win32 : posix).resolve(onPath); const home = (deps.env ?? process.env)[platform === "win32" ? "USERPROFILE" : "HOME"]; if (!home) return undefined; - const local = (platform === "win32" ? win32 : posix).resolve(home, ".local", "bin", platform === "win32" ? "traecli.exe" : "traecli"); - return (deps.exists ?? existsSync)(local) ? local : 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/session.ts b/src/adapters/trae-cn/session.ts index 179db883043..8f2776f495a 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -67,6 +67,7 @@ class TraeAcpClient { 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; @@ -116,6 +117,17 @@ class TraeAcpClient { 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" }); @@ -152,6 +164,13 @@ class TraeAcpClient { 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"); @@ -173,7 +192,7 @@ class TraeAcpClient { } /** Register before writing so immediate responses and stream errors settle the same request. */ - async request(method: string, params: Record, dispatch?: TraePromptDispatch): Promise { + async request(method: string, params: Record, dispatch?: TraePromptDispatch, onSuccess?: () => 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) => { @@ -181,7 +200,9 @@ class TraeAcpClient { this.pending.delete(id); reject(new TraeAcpError(`TRAE CN ACP timed out during ${method}`, "trae_acp_timeout")); }, this.timeoutMs); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(id, { resolve: result => { + try { onSuccess?.(); resolve(result); } catch (error) { reject(error); } + }, reject, timer }); }); try { dispatch?.beforeSend(); @@ -333,7 +354,7 @@ export async function openTraeSession( signal?.addEventListener("abort", abort, { once: true }); let completion: Promise | undefined; try { - await client.request("session/set_model", { sessionId, modelId }); + await client.request("session/set_model", { sessionId, modelId }, undefined, () => client.acknowledgeModelSelection(sessionId, modelId)); if (signal?.aborted) return; let output = false; completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, dispatch); @@ -342,12 +363,6 @@ export async function openTraeSession( const frame = await client.nextUpdate(); if (!frame) break; if (frame.kind !== "update" || frame.sessionId !== sessionId) continue; - if (frame.update.sessionUpdate === "current_model_update") { - if (frame.update.currentModelId !== modelId) { - throw new TraeAcpError("TRAE CN changed the selected model unexpectedly.", "trae_model_changed"); - } - continue; - } for (const event of traePromptEvents(sessionId, frame.update)) { if (event.type === "text_delta") output = true; emit(event); @@ -376,6 +391,11 @@ export async function openTraeSession( 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; diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 595e0576297..24aa57527ec 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -8,7 +8,7 @@ distinct adapter events. Tool input/replay and non-text media are rejected, perm denied and terminate the session. Tool notifications fail with `trae_tool_call_unsupported`; 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` reports `trae_turn_stopped` rather than a protocol parsing error. +`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. The CLI uses an empty temporary workspace with disallow rules and no session MCP/skills. `src/codex/catalog/trae-cn-models.ts` publishes discovered @@ -23,7 +23,8 @@ are refused before CLI startup, including explicit direct routing. Observed-auth gathers use only cached evidence; only refreshing gathers and explicit connection probes may launch the CLI. Management convergence refreshes CLI-owned evidence before catalog admission, so a cold automatic tick can populate it. Lost-generation discovery rereads the current -cache before attaching its revision. Relative PATH and user-local fallback entries resolve before the child changes its cwd. +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; @@ -54,7 +55,9 @@ POSIX launch exits 126/127 before initialization report safe startup codes via t Prompt completion closes the bounded update stream once, without a new promise reaction per chunk. Client cancellation emits no provider error; an upstream cancellation without client abort emits an incomplete event with usage. Explicit output-token limits are refused before startup. -Model-selection notifications must match the requested model. The finite CLI tool denylist and +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 diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 0a337c3ac0a..e6bd6d60d58 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -11,7 +11,7 @@ import { traePosixInvocation, traeStopPipe } from "../../src/adapters/trae-cn/pr 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 { buildResponseJSON } from "../../src/bridge"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; import { resolveTraeCli, traeCnChildEnv } from "../../src/adapters/trae-cn/profile"; import { fetchProviderModels, fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "../../src/codex/catalog/provider-models"; import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; @@ -431,9 +431,9 @@ describe("TRAE CN ACP regression contract", () => { }); for (const selected of ["test-model", "unexpected-model"]) { - test(`model-selection notification validates ${selected}`, async () => { + test(`post-selection model notification validates ${selected}`, async () => { const fixture = traeFixture(request => { - if (request.method === "session/set_model") fixture.send({ jsonrpc: "2.0", method: "session/update", + 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; }); @@ -443,6 +443,30 @@ describe("TRAE CN ACP regression contract", () => { }); } + 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/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")); @@ -511,14 +535,43 @@ describe("TRAE CN ACP regression contract", () => { 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" : stopReason === "max_turn_requests" ? "trae_turn_stopped" : "trae_acp_protocol_error"; + 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) => { @@ -756,10 +809,21 @@ describe("TRAE CN ACP regression contract", () => { 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, From bdc10655b4f4b3945d7dcaee83480ef024d544cb Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 22:54:02 +0800 Subject: [PATCH 11/64] docs(trae-cn): split setup guide to preserve line budget --- .../src/content/docs/guides/providers.md | 53 +----------------- docs-site/src/content/docs/guides/trae-cn.md | 56 +++++++++++++++++++ structure/providers/trae-cn.md | 2 + 3 files changed, 60 insertions(+), 51 deletions(-) create mode 100644 docs-site/src/content/docs/guides/trae-cn.md diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 24ce7405b65..aef85c980b5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1459,57 +1459,8 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C ### TRAE CN (official ACP) -The `trae-cn` preset uses the signed-in official TRAE CN CLI through -`traecli acp serve`. OpenCodex does not read desktop tokens, private IDE endpoints, or Ferry. - -```json -{ - "providers": { - "trae-cn": { - "adapter": "trae-cn", - "baseUrl": "https://www.trae.cn" - } - } -} -``` - -- **Prerequisites:** Install and sign in to [TRAE CN](https://www.trae.cn/) and its official CLI. - 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:** `session/new` returns the models available to the signed-in account. - Successful discovery is cached by the normal provider catalog mechanism, with the last good - catalog and a small text-only seed as degraded fallbacks. - Read-only catalog gathers use cached evidence; they never start the CLI or refresh its sign-in. - 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. - 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 is silent; cancellation initiated by the CLI returns an incomplete turn. - 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. +Use the signed-in official TRAE CN CLI for text and reasoning. Leave the API key empty. +See [TRAE CN setup, model discovery and limits](/guides/trae-cn/). ### Official Qoder CLI (Global & CN) 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..ae30c9e743c --- /dev/null +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -0,0 +1,56 @@ +--- +title: TRAE CN (ACP) +description: Connect OpenCodex to the signed-in official TRAE CN CLI. +--- + +The `trae-cn` preset uses the signed-in official TRAE CN CLI through +`traecli acp serve`. OpenCodex does not read desktop tokens, private IDE endpoints, or Ferry. + +```json +{ + "providers": { + "trae-cn": { + "adapter": "trae-cn", + "baseUrl": "https://www.trae.cn" + } + } +} +``` + +- **Prerequisites:** Install and sign in to [TRAE CN](https://www.trae.cn/) and its official CLI. + 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:** `session/new` returns the models available to the signed-in account. + Successful discovery is cached by the normal provider catalog mechanism, with the last good + catalog and a small text-only seed as degraded fallbacks. + Read-only catalog gathers use cached evidence; they never start the CLI or refresh its sign-in. + 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. + 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 is silent; cancellation initiated by the CLI returns an incomplete turn. + 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/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 24aa57527ec..14db15e47e4 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -70,3 +70,5 @@ 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). From 6132c32cdbb1d88c748afeead2268847f2a75391 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 8 Nov 2026 23:31:27 +0800 Subject: [PATCH 12/64] fix(trae-cn): report Windows forwarding failures --- src/adapters/trae-cn/windows-job-source.ts | 4 ++-- .../adapter-registry-authority.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/adapters/trae-cn/windows-job-source.ts b/src/adapters/trae-cn/windows-job-source.ts index 58c21678d87..770b13fe745 100644 --- a/src/adapters/trae-cn/windows-job-source.ts +++ b/src/adapters/trae-cn/windows-job-source.ts @@ -129,7 +129,6 @@ public static class TraeAcpJob { }); outTask = Task.Run(() => { try { output.CopyTo(Console.OpenStandardOutput()); } - catch { } finally { stop.Set(); } }); errTask = Task.Run(() => { @@ -156,7 +155,8 @@ public static class TraeAcpJob { } 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 { } + 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); diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index e6bd6d60d58..aeae7c0e075 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -710,6 +710,25 @@ describe("TRAE CN ACP regression contract", () => { expect(invalid.processTreeExited).toBe(false); }); + 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 }); From cb4aaacd478ed3e7b16563b5ef23f2c486ebb436 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 9 Nov 2026 00:44:39 +0800 Subject: [PATCH 13/64] test(trae-cn): cover live discovery and dispatch boundaries --- structure/providers/trae-cn.md | 11 +- .../adapter-registry-authority.test.ts | 129 ++++++++++++++++-- 2 files changed, 126 insertions(+), 14 deletions(-) diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 14db15e47e4..f1cd01ea5d1 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -41,7 +41,10 @@ Scoped Windows environment keys are matched case-insensitively, and the native l 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 after cleanup, preserving received usage even when cleanup fails. A failed workspace allocation removes an +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 @@ -72,3 +75,9 @@ website or accepts a cached roster as login evidence. The cache remains provider 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/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index aeae7c0e075..2e339b82ef8 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1,7 +1,7 @@ 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 { posix, win32 } from "node:path"; +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"; @@ -13,6 +13,7 @@ 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 { 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"; @@ -26,6 +27,7 @@ import * as managementConvergence from "../../src/codex/management-convergence"; 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 type { AdapterEvent } from "../../src/types"; import type { IncomingMeta } from "../../src/adapters/base"; import { createTempHome, type TempHome } from "../helpers/temp-home"; @@ -37,7 +39,7 @@ import { } 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 { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget"; const EXPECTED_ADAPTER_NAMES = { "workbuddy-ai": "workbuddy-ai", @@ -203,17 +205,17 @@ function traeRequest(): OcxParsedRequest { } function traeMeta(extra: Partial = {}): IncomingMeta { - return { headers: new Headers(), translatorBudget: createTranslatorBudget(), ...extra }; + return { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), ...extra }; } -function fakeSendBudget() { +function fakeSendBudget({ reserveAllowed = true, useAllowed = true } = {}) { const state = { used: 0, refunded: 0, closed: 0 }; const budget = { beginSpendProducer: () => ({ close: () => { state.closed++; } }), - reserveDispatch: () => ({ allowed: true, permit: { - use: () => { state.used++; return true; }, + 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 }; } @@ -227,6 +229,50 @@ describe("TRAE CN ACP regression contract", () => { beforeEach(() => { testHome = createTempHome("ocx-trae-regression-"); clearModelCache(); }); afterEach(() => { mock.restore(); clearModelCache(); testHome.remove(); }); + // 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("streams text, reasoning and usage; charges only the prompt write", async () => { const fixture = traeFixture(); const budget = fakeSendBudget(); @@ -248,6 +294,23 @@ describe("TRAE CN ACP regression contract", () => { 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(); @@ -463,6 +526,8 @@ describe("TRAE CN ACP regression contract", () => { 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); }); } @@ -710,6 +775,20 @@ describe("TRAE CN ACP regression contract", () => { 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; @@ -750,22 +829,25 @@ describe("TRAE CN ACP regression contract", () => { expect(existsSync(testHome.root)).toBe(true); }); - for (const inheritedPipes of [true, false]) { - test.skipIf(process.platform !== "win32")("Windows Job reaps a helper after its dispatcher exits: inherited pipes=" + inheritedPipes, async () => { + 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 exited"); + 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) + ",'exited');process.exit(0);", + "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 invocation = commandInvocation(shim, ["acp", "serve"], "win32", { env }); + 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"); @@ -778,16 +860,37 @@ describe("TRAE CN ACP regression contract", () => { 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); await job.cleanup(); } + } 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); } From d23e8260b1ee181aff95f60db26947e5fb5353fb Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 13:59:25 +0800 Subject: [PATCH 14/64] docs(trae-cn): qualify observed model discovery and account scope --- docs-site/src/content/docs/guides/trae-cn.md | 4 +++- structure/providers/trae-cn.md | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md index 3b035beabd3..41fdf63c0d5 100644 --- a/docs-site/src/content/docs/guides/trae-cn.md +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -22,7 +22,9 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through 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:** `session/new` returns the models available to the signed-in account. +- **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 a small text-only seed as degraded fallbacks. Read-only catalog gathers use cached evidence; they never start the CLI or refresh its sign-in. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 51534811f29..0c95d29ae89 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -1,8 +1,9 @@ # TRAE CN ACP `src/adapters/trae-cn/` runs the official TRAE CN CLI's ACP server for the `trae-cn` registry row. -TRAE owns login and refresh; OpenCodex stores no TRAE token. A fresh session supplies the account -model roster and one selected-model turn. The JSON-RPC reader bounds frames, update queues, stderr, +TRAE owns login and refresh; OpenCodex stores no TRAE token. A fresh session supplies the CLI-reported +model roster and one selected-model turn. 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`; only a From 094a7d58b621ecfe8f76671db8bfd5f672b46e22 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 14:21:31 +0800 Subject: [PATCH 15/64] fix(trae-cn): preserve local spend-ledger storage refusals --- src/adapters/trae-cn/adapter.ts | 3 ++- structure/providers/trae-cn.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 5232352763b..5e39019c573 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -1,5 +1,6 @@ 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"; @@ -109,7 +110,7 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda if (terminal && !incoming.abortSignal?.aborted) emit(terminal); } catch (error) { if (incoming.abortSignal?.aborted) return; - if (error instanceof SendBudgetExhaustedError) throw error; + if (error instanceof SendBudgetExhaustedError || error instanceof SpendLedgerFileRefusedError) throw error; emit({ ...errorEvent(error), ...(terminal?.usage ? { usage: terminal.usage } : {}) }); } finally { try { permit?.release(); } finally { producer?.close(); } diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 0c95d29ae89..d4c40e6ff34 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -49,7 +49,9 @@ Received usage is preserved even when cleanup fails. A failed workspace allocati 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 and keep their existing routing policy. Replay over 1,000,000 UTF-16 code units is +return unused reservations and keep their existing routing policy. +Local spend-ledger file refusals propagate to the shared local-storage 503 mapping, with producer cleanup, before ACP error classification. +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. From 9f318bbd832e2b23c11c650c4c42d4beb86026c6 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 14:26:47 +0800 Subject: [PATCH 16/64] docs(trae-cn): describe local spend-ledger storage refusals --- docs-site/src/content/docs/guides/trae-cn.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md index 41fdf63c0d5..1379a0a96db 100644 --- a/docs-site/src/content/docs/guides/trae-cn.md +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -46,6 +46,8 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through 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 is silent; 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. From 2dd6fa0e34f437171b48d3a8617613283d4bb891 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 14:59:03 +0800 Subject: [PATCH 17/64] fix(trae-cn): retain terminal usage across cleanup cancellation Emit a usage-bearing client cancellation outcome after bounded cleanup and bind the latest attempt before a cancelled consumer can drop the event. --- docs-site/src/content/docs/guides/trae-cn.md | 3 ++- src/adapters/trae-cn/adapter.ts | 11 +++++++++-- src/server/responses/run-turn-execution.ts | 4 ++++ structure/providers/trae-cn.md | 3 ++- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md index 1379a0a96db..4e16f877713 100644 --- a/docs-site/src/content/docs/guides/trae-cn.md +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -45,7 +45,8 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through 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 is silent; cancellation initiated by the CLI returns an incomplete turn. + 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 diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 5e39019c573..972c7945f7f 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -84,6 +84,12 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda const producer = incoming.sendBudget?.beginSpendProducer?.(); let permit: SingleUseDispatchPermit | undefined; let terminal: Extract | undefined; + let cancelledUsageEmitted = false; + const emitCancelledUsage = () => { + if (!terminal?.usage || cancelledUsageEmitted) return; + cancelledUsageEmitted = true; + emit({ type: "incomplete", reason: "client_cancelled", usage: terminal.usage, retryable: false }); + }; try { const replay = replayText(parsed); const decision = incoming.sendBudget?.reserveDispatch({ sendClass: "initial", targetKey: `trae-cn|${parsed.modelId}` }); @@ -107,9 +113,10 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda } finally { await session.close(); } - if (terminal && !incoming.abortSignal?.aborted) emit(terminal); + if (incoming.abortSignal?.aborted) emitCancelledUsage(); + else if (terminal) emit(terminal); } catch (error) { - if (incoming.abortSignal?.aborted) return; + if (incoming.abortSignal?.aborted) { emitCancelledUsage(); return; } if (error instanceof SendBudgetExhaustedError || error instanceof SpendLedgerFileRefusedError) throw error; emit({ ...errorEvent(error), ...(terminal?.usage ? { usage: terminal.usage } : {}) }); } finally { diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 03c0db9d71c..a80222c3523 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -301,6 +301,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); }; diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index d4c40e6ff34..d8364f2f8af 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -59,7 +59,8 @@ Malformed stdout produces fixed protocol diagnostics, never the shared JSONL par 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; an upstream cancellation without client abort emits +Client cancellation emits no provider error; 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. From 9a0e1e402e031bf5e963e43865217327233b30c8 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 15:22:10 +0800 Subject: [PATCH 18/64] fix(trae-cn): keep provider reference inert and commit regressions --- .../docs/fr/getting-started/quickstart.md | 2 +- .../src/content/docs/fr/guides/providers.md | 4 +- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/providers.md | 2 +- docs-site/src/content/docs/guides/trae-cn.md | 11 +- .../docs/ja/getting-started/quickstart.md | 2 +- .../src/content/docs/ja/guides/providers.md | 4 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../src/content/docs/ko/guides/providers.md | 4 +- .../docs/ru/getting-started/quickstart.md | 2 +- .../src/content/docs/ru/guides/providers.md | 4 +- .../docs/tr/getting-started/quickstart.md | 2 +- .../src/content/docs/tr/guides/providers.md | 4 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 4 +- .../docs/zh-tw/getting-started/quickstart.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 4 +- src/providers/free-directory.ts | 8 +- src/providers/registry/entries-extended.ts | 21 --- structure/ops/docs-and-release.md | 2 +- structure/providers/trae-cn.md | 3 +- .../adapter-registry-authority.test.ts | 131 +++++++++++++++++- .../app-owned-memory.test.ts | 4 +- tests/codex-integration/model-cache.test.ts | 68 ++++++++- .../provider-registry-parity.test.ts | 2 +- ...-combo-run-turn-classified-failure.test.ts | 36 ++++- 26 files changed, 275 insertions(+), 57 deletions(-) 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 f411c23e5d0..072a5dcb00a 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 108 préréglages intégrés au registre, ou `custom` pour saisir une +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}`. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index c7a63e9e362..14d74530d82 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -310,9 +310,9 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -**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). +**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 108 préréglages intégrés : 87 à clé, 17 OAuth, trois locaux et un préréglage par défaut de +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 ad8e8c5c762..56972da1afc 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -20,7 +20,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 108 built-in registry presets or `custom` to type a base +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 diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8447900694d..01ccbfc496b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -785,7 +785,7 @@ On upgrade, startup adds Pro to a saved model list only when it exactly matches ## 3. API-key catalog -opencodex ships 108 built-in presets: 87 in the key-based registry category, 17 OAuth, three local, and one default ChatGPT-forward preset. Keyless presets such as TRAE CN use their official CLI for authentication. For API-key providers, **Add provider** opens the 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 diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md index 4e16f877713..e54e26f6d6a 100644 --- a/docs-site/src/content/docs/guides/trae-cn.md +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -3,15 +3,18 @@ title: TRAE CN (ACP) description: Connect OpenCodex to the signed-in official TRAE CN CLI. --- -The `trae-cn` preset uses the signed-in official TRAE CN CLI through -`traecli acp serve`. OpenCodex does not read desktop tokens, private IDE endpoints, or Ferry. +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. ```json { "providers": { "trae-cn": { "adapter": "trae-cn", - "baseUrl": "https://www.trae.cn" + "baseUrl": "https://www.trae.cn", + "keyOptional": true } } } @@ -26,7 +29,7 @@ The `trae-cn` preset uses the signed-in official TRAE CN CLI through `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 a small text-only seed as degraded fallbacks. + catalog and explicitly configured models as degraded fallbacks. Read-only catalog gathers use cached evidence; they never start the CLI or refresh its sign-in. 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 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 b85746fb893..f64410f9611 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 108 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 107 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 **TRAE CN** はログイン済みの公式 CLI を使用するため、API キーは不要です。CLI をインストールしてログインし、ウィザードのキー入力欄は空のまま進めてください。[TRAE CN の設定手順(英語)](/guides/providers/#trae-cn-official-acp)を参照してください。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 0055dc7fe8b..1724a5123fd 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -210,9 +210,9 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -**TRAE CN** はログイン済みの公式 CLI を使用するため、API キーは不要です。CLI をインストールしてログインし、ウィザードのキー入力欄は空のまま進めてください。[TRAE CN の設定手順(英語)](/guides/providers/#trae-cn-official-acp)を参照してください。 +**TRAE CN** は手動設定用の実験的 ACP アダプターです。ディレクトリには未検証の参照項目のみがあり、組み込みプリセットは有効になっていません。[TRAE CN の設定手順(英語)](/guides/providers/#trae-cn-official-acp)を参照してください。 -opencodex には組み込みプリセットが 108 個含まれています。キー方式 87、OAuth 17、ローカル 3、 +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 f3214c79021..382df986144 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 108개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 107개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. **TRAE CN**은 로그인된 공식 CLI를 사용하므로 API 키가 필요하지 않습니다. CLI를 설치하고 로그인한 후 마법사의 키 입력란은 비워 두세요. 자세한 내용은 [TRAE CN 설정 안내(영문)](/guides/providers/#trae-cn-official-acp)를 참고하세요. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index c8ea09466c7..8ef0ee96249 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -215,9 +215,9 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -**TRAE CN**은 로그인된 공식 CLI를 사용하므로 API 키가 필요하지 않습니다. CLI를 설치하고 로그인한 후 마법사의 키 입력란은 비워 두세요. 자세한 내용은 [TRAE CN 설정 안내(영문)](/guides/providers/#trae-cn-official-acp)를 참고하세요. +**TRAE CN**은 수동으로 구성하는 실험적 ACP 어댑터입니다. 디렉터리에는 미검증 참고 항목만 있으며 내장 프리셋은 활성화되어 있지 않습니다. [TRAE CN 설정 안내(영문)](/guides/providers/#trae-cn-official-acp)를 참고하세요. -opencodex에는 빌트인 프리셋이 108개 들어 있습니다. 키 방식 87개, OAuth 17개, 로컬 3개, +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 a4ec6a1918c..608674d0cb9 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 108 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 107 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index c8a55161313..7d110561114 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -222,9 +222,9 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -**TRAE CN** использует официальный CLI с выполненным входом и не требует ключа API. Установите CLI, войдите в аккаунт и оставьте поле ключа в мастере пустым. Подробнее: [настройка TRAE CN (на английском)](/guides/providers/#trae-cn-official-acp). +**TRAE CN** предоставляет экспериментальный ACP-адаптер для ручной настройки. В каталоге есть только непроверенная справочная запись; встроенный пресет не включён. Подробнее: [настройка TRAE CN (на английском)](/guides/providers/#trae-cn-official-acp). -opencodex поставляется с 108 встроенными пресетами: 87 на основе ключей, 17 OAuth, три локальных и +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 03d905986b7..97df34cf112 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 108 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 107 önayardan birini 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. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index bea209a8661..711af1b2059 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -342,9 +342,9 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -**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. +**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 108 yerleşik önayar ile birlikte gelir: 87 anahtar tabanlı, 17 +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, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer 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 c75618446f7..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 @@ -18,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 108 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +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)。 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 2c00515e485..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,9 +222,9 @@ API Key 提供商,也不负责让 WorkBuddy 桌面客户端使用 OpenCodex ## 3. API 密钥目录 -**TRAE CN** 使用已登录的官方 CLI,无需 API 密钥。请先安装 CLI 并登录;向导若提示输入密钥,可直接留空。详见 [TRAE CN 配置说明(英文)](/guides/providers/#trae-cn-official-acp)。 +**TRAE CN** 提供需手工配置的实验性 ACP adapter。目录中仅有未经验证的参考项,内置预设尚未启用。详见 [TRAE CN 配置说明(英文)](/guides/providers/#trae-cn-official-acp)。 -opencodex 内置 108 个预设:87 个密钥预设、17 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +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 9365dbd4c68..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 @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 108 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 107 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 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 a2a6fa714af..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,9 +264,9 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -**TRAE CN** 使用已登入的官方 CLI,無需 API 金鑰。請先安裝 CLI 並登入;精靈若要求輸入金鑰,可直接留空。詳見 [TRAE CN 設定說明(英文)](/guides/providers/#trae-cn-official-acp)。 +**TRAE CN** 提供需手動設定的實驗性 ACP adapter。目錄中僅有未經驗證的參考項,內建預設尚未啟用。詳見 [TRAE CN 設定說明(英文)](/guides/providers/#trae-cn-official-acp)。 -opencodex 內建 108 個 preset:87 個 key-based、17 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +opencodex 內建 107 個 preset:86 個 key-based、17 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式 依 provider 而異。主要條目如下。 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/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 6630317fc2f..0ce80b84479 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -25,7 +25,6 @@ import { CODEBUDDY_REASONING_EFFORTS, } from "../codebuddy-models"; import { QODER_CN_MODELS, QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "../qoder-models"; -import { TRAE_CN_MODELS } from "../trae-cn-models"; import type { ProviderRegistryEntry } from "./types"; import { ZAI_GLM_53_MODELS, @@ -130,26 +129,6 @@ import { } from "./model-seeds"; export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ - { - // The official TRAE CN CLI owns authentication and transport through its documented ACP - // server. No desktop credential or private IDE endpoint enters OpenCodex. - id: "trae-cn", - label: "TRAE CN (ACP)", - adapter: "trae-cn", - baseUrl: "https://www.trae.cn", - authKind: "key", - keyOptional: true, - preserveCustomDestination: true, - dashboardUrl: "https://docs.trae.cn/cli_agent-client-protocol", - defaultModel: "Doubao-Seed-1.8", - models: [...TRAE_CN_MODELS], - liveModels: true, - noVisionModels: [...TRAE_CN_MODELS], - inputModalities: ["text"], - supportsVerbosity: false, - reasoningEfforts: [], - note: "Uses the signed-in official traecli through `traecli acp serve`; OpenCodex stores no TRAE token. Requires TRAE CN CLI and a signed-in account. The initial adapter supports text and reasoning streams; client tools and images are rejected explicitly.", - }, { // Verified 2026-09-21: docs.typesafe.ai/introduction/quickstart and /api document the fixed // endpoint, Bearer auth, jev-latest, and TYPESAFE_API_KEY; typesafe.ai/legal/mca permits API integration. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 7bd545f8c7f..49e072682bf 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -86,7 +86,7 @@ Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a publ sidebar and either add localized copies or intentionally accept Starlight fallback behavior. Provider preset totals are recounted from the current registry when a preset lands. The -documented split is 108 total: 87 key-based, 17 OAuth, three local, and one default +documented split is 107 total: 86 key-based, 17 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight quickstarts carry the same counts. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index d8364f2f8af..ab84f2e0211 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -1,6 +1,7 @@ # TRAE CN ACP -`src/adapters/trae-cn/` runs the official TRAE CN CLI's ACP server for the `trae-cn` registry row. +`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. The vendor does not document the discovery fields or guarantee their account scope. The JSON-RPC reader bounds frames, update queues, stderr, diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 2e339b82ef8..2047bd48561 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -28,6 +28,13 @@ 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 { buildResponseJSON } from "../../src/bridge"; +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"; @@ -38,7 +45,7 @@ import { getAdapterDefinition, } from "../../src/adapters/registry"; import { resolveAdapter } from "../../src/server/adapter-resolve"; -import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget"; const EXPECTED_ADAPTER_NAMES = { @@ -229,6 +236,128 @@ describe("TRAE CN ACP regression contract", () => { 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 }); + }); + } + // 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", () => { 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/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 68abf8a5014..64b76d30744 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1211,7 +1211,7 @@ describe("provider registry parity", () => { expect(providerConfigSeed(litellm!).keyOptional).toBe(true); // claude-cli joins them as the first CLI-backed member: its row is `key` because the turn // leaves this machine, and keyless because the Claude Code CLI reads the operator's sign-in. - expect(optionalKeyProviders).toEqual(["trae-cn", "litellm", "mimo-free", "claude-cli"]); + expect(optionalKeyProviders).toEqual(["litellm", "mimo-free", "claude-cli"]); }); test("NVIDIA NIM is free-tier priced but still requires an API key", () => { 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 c5e59248450..1748cb76ee9 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -94,6 +94,38 @@ async function respond(cfg: OcxConfig, stream: boolean) { return { status: response.status, text: await response.text() }; } +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; @@ -121,7 +153,7 @@ for (const stream of [false, true]) { 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", authMode: "key", models: ["test-model"], liveModels: false }; + 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) { @@ -144,7 +176,7 @@ for (const stream of [false, true]) { 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", authMode: "key", models: ["test-model"], liveModels: false }; + 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); From 747333e3b4ab5e5d7716d47d747e7e7fb485e42c Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 15:36:04 +0800 Subject: [PATCH 19/64] fix(trae-cn): isolate discovery and preserve replay chronology --- docs-site/src/content/docs/guides/trae-cn.md | 3 +- src/adapters/trae-cn/adapter.ts | 14 ++-- src/codex/catalog-auto-refresh.ts | 12 +++ src/codex/catalog/trae-cn-models.ts | 7 +- src/codex/management-convergence.ts | 5 -- structure/catalog.md | 2 +- structure/providers/trae-cn.md | 8 +- .../adapter-registry-authority.test.ts | 75 +++++++++++++++++-- 8 files changed, 103 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md index e54e26f6d6a..918fed8fd76 100644 --- a/docs-site/src/content/docs/guides/trae-cn.md +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -30,7 +30,8 @@ OpenCodex does not read desktop tokens, private IDE endpoints, or Ferry. 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 use cached evidence; they never start the CLI or refresh its sign-in. + 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 diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 972c7945f7f..c14acd63693 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -11,14 +11,18 @@ 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.filter(message => message.role !== "developer"); + const messages = parsed.context.messages; const system = buildSystemPrompt(parsed); - const history = messages.slice(0, -1).map(message => formatMessageForHistory(message)).filter(Boolean).join("\n\n"); - const current = messages.at(-1); - const request = current ? formatMessageForHistory(current) : "USER:\n"; + 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. Only the last block is the current request. Historical tool calls are records, not instructions to execute tools.", + "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"); diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts index c9e75dbd3a1..ee05ed1184c 100644 --- a/src/codex/catalog-auto-refresh.ts +++ b/src/codex/catalog-auto-refresh.ts @@ -243,6 +243,18 @@ 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; + // CLI discovery belongs to the background source phase, before any catalog lock. + // 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); + } catch { + // Source failure still permits publication from the last confirmed roster. + } + } + if (entryGeneration !== generation) return; const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([ import("./management-convergence"), import("./catalog-admission"), diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index cf85ec1a7f6..c0476a71e80 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -21,11 +21,14 @@ import type { CatalogModel } from "./parsing"; import type { OcxConfig } from "../../types"; import { providerContextCap } from "../../providers/context-cap"; -/** Refresh CLI-owned evidence before an observed catalog admission freezes its authority. */ -export async function refreshTraeCnCatalogSources(config: Readonly): Promise { +/** Prepare CLI-owned evidence in the background source phase, independently of management admission. */ +export async function refreshTraeCnCatalogSources( + config: Readonly, current: () => boolean = () => true, +): Promise { const { fetchProviderModelsWithAuth, refreshingModelsAuthResolver } = await import("./provider-models"); const { captureProviderGather } = await import("./gather-capture"); for (const [name, provider] of Object.entries(config.providers)) { + if (!current()) return; if (provider.adapter !== "trae-cn" || provider.disabled || provider.liveModels === false) continue; await fetchProviderModelsWithAuth(captureProviderGather(name, provider, refreshingModelsAuthResolver), config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS, providerContextCap(config, name), refreshingModelsAuthResolver); diff --git a/src/codex/management-convergence.ts b/src/codex/management-convergence.ts index 4e4ec401d97..47f8fbe6a63 100644 --- a/src/codex/management-convergence.ts +++ b/src/codex/management-convergence.ts @@ -165,11 +165,6 @@ export function createManagementConvergeCodex( catalogRefresh: { status: "skipped", reason: "refused", retryable: false }, }); } - if (Object.values(retainedConfig.providers).some(provider => - provider.adapter === "trae-cn" && !provider.disabled && provider.liveModels !== false)) { - const { refreshTraeCnCatalogSources } = await import("./catalog/trae-cn-models"); - await refreshTraeCnCatalogSources(retainedConfig); - } const snapshot = captureCatalogAdmissionSnapshot(retainedConfig); const result = await convergeCodexCatalog(snapshot, request, { onCommitBegin: () => { commitBegan = true; }, diff --git a/structure/catalog.md b/structure/catalog.md index 1180709fc6c..6b3df3ce528 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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 is refreshed in `src/codex/management-convergence.ts` before catalog admission captures evidence. Observed gathers remain cache-only. A lost cache generation rereads the current roster before recording its revision; see [TRAE CN](providers/trae-cn.md). +TRAE CN discovery runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. `src/codex/management-convergence.ts` and 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 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index ab84f2e0211..4916f6daa7e 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -22,9 +22,9 @@ limit before per-model overrides; only sidecar publication can advertise image a 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 use only cached evidence; only refreshing gathers and explicit connection -probes may launch the CLI. Management convergence refreshes CLI-owned evidence before catalog -admission, so a cold automatic tick can populate it. Lost-generation discovery rereads the current +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, then converges; 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. @@ -52,6 +52,8 @@ also emits a replay-unsafe heartbeat at that write, so combo preflight cannot re after an empty result or another post-dispatch failure. Startup failures and unavailable models return unused reservations and keep their existing routing policy. 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, diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 2047bd48561..c6a35ffe38b 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -24,6 +24,8 @@ 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"; @@ -31,7 +33,6 @@ 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 { buildResponseJSON } from "../../src/bridge"; 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"; @@ -402,6 +403,32 @@ describe("TRAE CN ACP regression contract", () => { 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(); @@ -1115,22 +1142,58 @@ describe("TRAE CN ACP regression contract", () => { }); } - test("management convergence refreshes CLI evidence before catalog admission without an initial selection", async () => { + 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(() => { - expect(getFreshCached("trae-cn", 60_000, Date.now())?.map(model => model.id)).toEqual(["test-model"]); + 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: [] } } }; - await managementConvergence.createManagementConvergeCodex(next)(catalogAdmission.createCatalogConvergeRequest({ deadlineMs: 1000 })); - expect(open).toHaveBeenCalledTimes(1); - expect(admission).toHaveBeenCalledTimes(1); + 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"]]); }); + 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; From 685c347af4d37631fa396bc59c84928a7e27220f Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 15:53:07 +0800 Subject: [PATCH 20/64] docs(trae-cn): document cache accounting and supervisor frames --- src/adapters/trae-cn/adapter.ts | 1 + src/adapters/trae-cn/windows-job.ts | 4 ++++ src/codex/model-cache.ts | 3 +++ 3 files changed, 8 insertions(+) diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index c14acd63693..d770c6fdf44 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -89,6 +89,7 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda let permit: SingleUseDispatchPermit | undefined; let terminal: Extract | undefined; let cancelledUsageEmitted = false; + /** Report received usage once when cancellation replaces the pending terminal outcome. */ const emitCancelledUsage = () => { if (!terminal?.usage || cancelledUsageEmitted) return; cancelledUsageEmitted = true; diff --git a/src/adapters/trae-cn/windows-job.ts b/src/adapters/trae-cn/windows-job.ts index 1fa7e4aed2b..ab7f41b4f3b 100644 --- a/src/adapters/trae-cn/windows-job.ts +++ b/src/adapters/trae-cn/windows-job.ts @@ -8,6 +8,7 @@ function windowsEnv(env: Record, name: string): string | undefin 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)) { @@ -58,6 +59,7 @@ export class TraeWindowsJobFrames { 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) { @@ -75,6 +77,7 @@ export class TraeWindowsJobFrames { } } + /** 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); @@ -87,6 +90,7 @@ export class TraeWindowsJobFrames { 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()); diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index 6b0d390dae8..6b581be9ca5 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -61,6 +61,7 @@ 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; @@ -286,6 +287,7 @@ function modelCacheSizeBytes(provider: string, models: CatalogModel[]): number { + modelCacheEncoder.encode(JSON.stringify(models)).byteLength; } +/** 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), @@ -405,6 +407,7 @@ 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; From 109e1b7027bf0f2eef6c8ef199d1b9e5ef91bc1e Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 16:00:18 +0800 Subject: [PATCH 21/64] fix(trae-cn): reject instruction images and retain cancelled RPC usage --- docs-site/src/content/docs/guides/trae-cn.md | 1 + src/adapters/trae-cn/adapter.ts | 4 +- src/adapters/trae-cn/session.ts | 2 +- src/server/responses/core-pipeline.ts | 8 ++-- src/server/responses/run-turn-execution.ts | 8 +++- structure/providers/trae-cn.md | 7 ++- .../adapter-registry-authority.test.ts | 44 +++++++++++++++++++ ...-combo-run-turn-classified-failure.test.ts | 35 +++++++++++++++ 8 files changed, 100 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md index 918fed8fd76..6d6df9b17d1 100644 --- a/docs-site/src/content/docs/guides/trae-cn.md +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -7,6 +7,7 @@ The experimental `trae-cn` adapter can be configured manually through `custom`. 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. +Images in system or developer instructions are also refused before CLI startup, including Chat and combo requests. ```json { diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index d770c6fdf44..a058e523786 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -6,6 +6,7 @@ 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, responsesHasInstructionImage } from "../claude-cli/instruction-images"; export type TraeCnAdapterDeps = TraeAcpDeps; @@ -43,7 +44,8 @@ function validateInput(parsed: OcxParsedRequest): { message: string; code: strin if (parsed.context.tools?.length) { return { message: "TRAE CN ACP does not currently expose client tools.", code: "unsupported_client_tools" }; } - if (parsed.context.messages.some(message => + if (responsesHasInstructionImage(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" }; diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 8f2776f495a..2a050ac912b 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -382,7 +382,7 @@ export async function openTraeSession( return; } if (stop === "cancelled") { - if (!signal?.aborted) emit({ type: "incomplete", reason: "upstream_cancelled", retryable: false, + emit({ type: "incomplete", reason: "upstream_cancelled", retryable: false, message: "TRAE CN cancelled the turn.", ...(usage ? { usage } : {}) }); return; } diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts index b573a0ffe11..a3b031483ff 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -48,14 +48,16 @@ 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") { + 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 message = transportState.runTurnAdapter.name === "trae-cn" + ? "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported." + : 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, diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index a80222c3523..5c3adffdd74 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -181,7 +181,7 @@ 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) || parsed.context.messages.some(message => message.role === "developer" && Array.isArray(message.content) && message.content.some(part => part.type === "image"))) { @@ -189,11 +189,15 @@ export async function executeResponsesRunTurn( 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); return refusal; } + } + if (transportState.runTurnAdapter.name === "claude-cli") { let usage: Awaited>; try { usage = await claudeUsagePreflight(parsed.modelId); diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 4916f6daa7e..27ba03c8660 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -3,7 +3,9 @@ `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. The vendor does not document the discovery fields or +model roster and one selected-model turn. Original instruction-image and file-image evidence is +rejected before normalized instructions can lose their attachments, including Responses and Chat combo delivery. +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 @@ -62,7 +64,8 @@ Malformed stdout produces fixed protocol diagnostics, never the shared JSONL par 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; received usage survives cleanup cancellation in a +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. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index c6a35ffe38b..f1c81c4b8a5 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -14,6 +14,8 @@ 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"; @@ -359,6 +361,48 @@ describe("TRAE CN ACP regression contract", () => { }); } + for (const image of ["https://fixture.invalid/image.png", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a/nkAAAAASUVORK5CYII="]) { + test("original Responses system images are refused before CLI startup: " + image.split(":")[0], async () => { + const fixture = traeFixture(); + const parsed = parseResponsesRequest({ model: "test-model", input: [ + { role: "system", content: [{ type: "input_text", text: "Describe the attached image" }, { type: "input_image", image_url: image }] }, + { 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 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", () => { 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 1748cb76ee9..0b3e536aac9 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -42,6 +42,7 @@ mock.module("../../src/server/adapter-resolve", () => ({ })); const { handleResponses } = await import("../../src/server/responses"); +const { handleChatCompletions } = await import("../../src/server/chat-completions"); 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"); @@ -94,6 +95,40 @@ async function respond(cfg: OcxConfig, stream: boolean) { return { status: response.status, text: await response.text() }; } +for (const wire of ["responses", "chat"]) for (const combo of [false, true]) { + test("TRAE instruction images are refused at HTTP ingress: wire=" + wire + ", combo=" + combo, async () => { + const cfg = config([{ provider: "trae-cn", model: "test-model" }, { provider: "fallback", model: 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 body = wire === "responses" ? { model, stream: false, input: [ + { role: "system", content: [{ type: "input_text", text: "Describe the attachment" }, { type: "input_image", image_url: image }] }, + { role: "user", content: "Question" }, + ] } : { model, stream: false, messages: [ + { role: "system", content: [{ type: "text", text: "Describe the attachment" }, { type: "image_url", image_url: { url: image } }] }, + { 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("cancelled runTurn streams retain received usage after the consumer stops draining", async () => { const controller = new AbortController(); const usage = { inputTokens: 123, outputTokens: 456, totalTokens: 579 }; From a203033d37961266d4e9ddbbee3fc35b1fe1e053 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 16:42:58 +0800 Subject: [PATCH 22/64] feat(trae-cn): preserve instruction media before projection --- src/adapters/trae-cn/instruction-media.ts | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/adapters/trae-cn/instruction-media.ts diff --git a/src/adapters/trae-cn/instruction-media.ts b/src/adapters/trae-cn/instruction-media.ts new file mode 100644 index 00000000000..afab602194b --- /dev/null +++ b/src/adapters/trae-cn/instruction-media.ts @@ -0,0 +1,25 @@ +/** 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"]); + +/** 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"))); +} + +/** Inspect original Responses instructions, including video, audio, file, and image parts. */ +export function responsesHasUnsupportedInstructionMedia(body: unknown): boolean { + return hasUnsupportedInstructions(body, "input"); +} + +/** Inspect Chat instructions before their non-text parts disappear during Responses projection. */ +export function chatHasUnsupportedInstructionMedia(body: unknown): boolean { + return hasUnsupportedInstructions(body, "messages"); +} From 36094072d14e6569452390879068a3e92ab16c69 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 16:45:46 +0800 Subject: [PATCH 23/64] fix(trae-cn): guard projected media and preserve safe fallback --- docs-site/src/content/docs/guides/trae-cn.md | 3 +- src/adapters/trae-cn/adapter.ts | 5 +- .../{instruction-media.ts => input-media.ts} | 10 ++- src/server/chat-completions.ts | 2 + src/server/responses/core-options.ts | 4 + src/server/responses/core-pipeline.ts | 8 +- src/server/responses/request-prepare.ts | 3 + src/server/responses/run-turn-execution.ts | 6 +- structure/providers/trae-cn.md | 7 +- .../adapter-registry-authority.test.ts | 28 ++++++- ...-combo-run-turn-classified-failure.test.ts | 83 +++++++++++++++++-- 11 files changed, 140 insertions(+), 19 deletions(-) rename src/adapters/trae-cn/{instruction-media.ts => input-media.ts} (63%) diff --git a/docs-site/src/content/docs/guides/trae-cn.md b/docs-site/src/content/docs/guides/trae-cn.md index 6d6df9b17d1..74a1d963d1b 100644 --- a/docs-site/src/content/docs/guides/trae-cn.md +++ b/docs-site/src/content/docs/guides/trae-cn.md @@ -7,7 +7,8 @@ The experimental `trae-cn` adapter can be configured manually through `custom`. 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. -Images in system or developer instructions are also refused before CLI startup, including Chat and combo requests. +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 { diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index a058e523786..0985f809ef2 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -6,7 +6,8 @@ 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, responsesHasInstructionImage } from "../claude-cli/instruction-images"; +import { responsesHasFileImage } from "../claude-cli/instruction-images"; +import { responsesHasUnsupportedInputMedia } from "./input-media"; export type TraeCnAdapterDeps = TraeAcpDeps; @@ -44,7 +45,7 @@ function validateInput(parsed: OcxParsedRequest): { message: string; code: strin if (parsed.context.tools?.length) { return { message: "TRAE CN ACP does not currently expose client tools.", code: "unsupported_client_tools" }; } - if (responsesHasInstructionImage(parsed._rawBody) || responsesHasFileImage(parsed._rawBody) + 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")) { diff --git a/src/adapters/trae-cn/instruction-media.ts b/src/adapters/trae-cn/input-media.ts similarity index 63% rename from src/adapters/trae-cn/instruction-media.ts rename to src/adapters/trae-cn/input-media.ts index afab602194b..2dd0e70de7b 100644 --- a/src/adapters/trae-cn/instruction-media.ts +++ b/src/adapters/trae-cn/input-media.ts @@ -2,6 +2,7 @@ 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 { @@ -14,9 +15,12 @@ function hasUnsupportedInstructions(body: unknown, field: "input" | "messages"): || !TEXT_PART_TYPES.has(part.type) || typeof part.text !== "string"))); } -/** Inspect original Responses instructions, including video, audio, file, and image parts. */ -export function responsesHasUnsupportedInstructionMedia(body: unknown): boolean { - return hasUnsupportedInstructions(body, "input"); +/** Reject media that parsing would erase from instruction or native agent-message inputs. */ +export function responsesHasUnsupportedInputMedia(body: unknown): boolean { + return hasUnsupportedInstructions(body, "input") + || (record(body) && Array.isArray(body.input) && body.input.some(item => + record(item) && item.type === "agent_message" && Array.isArray(item.content) + && item.content.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. */ diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 44691c9e57d..56850f374f6 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, @@ -578,6 +579,7 @@ async function handleChatCompletionsWithBudget( // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", 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/responses/core-options.ts b/src/server/responses/core-options.ts index 976139eb7f8..45dfef30593 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -307,8 +307,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 a3b031483ff..e095b9e5ed1 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -26,6 +26,7 @@ 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 } from "../../adapters/trae-cn/input-media"; import { markResponseNonReplayable } from "../../lib/upstream-retry"; /** Compose request phases while retaining the original admission-finally ownership. */ @@ -53,7 +54,10 @@ export async function handleResponsesInner( || 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 unsupportedTraeMedia = transportState.runTurnAdapter.name === "trae-cn" + && (options.chatUnsupportedInstructionMedia || options.responsesUnsupportedInputMedia + || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody)); + if (instructionImage || options.responsesFileImage || unsupportedTraeMedia) { const message = transportState.runTurnAdapter.name === "trae-cn" ? "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported." : instructionImage ? "Claude Code CLI does not support images in developer instructions." @@ -64,7 +68,7 @@ export async function handleResponsesInner( } }), { 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; } } diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index f3ac72c1e1a..6a60ffc4553 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -82,6 +82,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"; @@ -253,6 +254,7 @@ export async function prepareResponsesRequest( return decodeRequestErrorResponse(err, "responses"); } options.responsesInstructionImage ||= responsesHasInstructionImage(body); + options.responsesUnsupportedInputMedia ||= responsesHasUnsupportedInputMedia(body); options.responsesFileImage ||= responsesHasFileImage(body); // Remove display-only artifact paths before helpers, combo dispatch and upstream replay. const originalSelector = options.mainHardLockFallbackOverride?.sourceModel ?? options.compactionRoutingOverride?.sourceModel @@ -457,6 +459,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); options.responsesFileImage ||= responsesHasFileImage(body); // Spawn-message compatibility (both directions): agent_message task payloads ride in diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 5c3adffdd74..c9f47e083f0 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -60,6 +60,7 @@ 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 } from "../../adapters/trae-cn/input-media"; // LOCAL PATCH (runturn-websearch): top-level fields route binding or the // adapter itself may write during a turn. Iteration-local `turnParsed` objects @@ -183,6 +184,9 @@ export async function executeResponsesRunTurn( // across both Responses and translated Chat instead of presenting a retryable generic 502. if (transportState.runTurnAdapter.name === "claude-cli" || transportState.runTurnAdapter.name === "trae-cn") { if (options.chatInstructionImage || options.responsesInstructionImage || responsesHasInstructionImage(parsed._rawBody) + || (transportState.runTurnAdapter.name === "trae-cn" + && (options.chatUnsupportedInstructionMedia || options.responsesUnsupportedInputMedia + || responsesHasUnsupportedInputMedia(parsed._rawBody))) || parsed.context.messages.some(message => message.role === "developer" && Array.isArray(message.content) && message.content.some(part => part.type === "image"))) { cancelResponseCompletion(); @@ -193,7 +197,7 @@ export async function executeResponsesRunTurn( ? "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; } } diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 27ba03c8660..5789b7f407f 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -3,8 +3,11 @@ `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. Original instruction-image and file-image evidence is -rejected before normalized instructions can lose their attachments, including Responses and Chat combo delivery. +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 diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index f1c81c4b8a5..4cffd17de4a 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -361,11 +361,17 @@ describe("TRAE CN ACP regression contract", () => { }); } - for (const image of ["https://fixture.invalid/image.png", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a/nkAAAAASUVORK5CYII="]) { - test("original Responses system images are refused before CLI startup: " + image.split(":")[0], async () => { + 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 attached image" }, { type: "input_image", image_url: image }] }, + { role: "system", content: [{ type: "input_text", text: "Describe the attachment" }, attachment] }, { role: "user", content: "Question" }, ] }); const events: AdapterEvent[] = []; @@ -375,6 +381,22 @@ describe("TRAE CN ACP regression contract", () => { }); } + 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([]); + }); + } + for (const stop of ["cancelled", "end_turn"]) { test("client abort after a parsed ACP reply retains usage: stop=" + stop, async () => { const controller = new AbortController(); 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 0b3e536aac9..dd188067993 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -43,6 +43,8 @@ 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"); @@ -95,9 +97,9 @@ async function respond(cfg: OcxConfig, stream: boolean) { return { status: response.status, text: await response.text() }; } -for (const wire of ["responses", "chat"]) for (const combo of [false, true]) { - test("TRAE instruction images are refused at HTTP ingress: wire=" + wire + ", combo=" + combo, async () => { - const cfg = config([{ provider: "trae-cn", model: "test-model" }, { provider: "fallback", model: MODEL }]); +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 }; @@ -107,11 +109,14 @@ for (const wire of ["responses", "chat"]) for (const combo of [false, true]) { }); 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" }, { type: "input_image", image_url: image }] }, + { 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" }, { type: "image_url", image_url: { url: image } }] }, + { role: "system", content: [{ type: "text", text: "Describe the attachment" }, attachment] }, { role: "user", content: "Question" }, ] }; try { @@ -129,6 +134,74 @@ for (const wire of ["responses", "chat"]) for (const combo of [false, true]) { }); } +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 }; From 7f998d39fae13ff1571294e132cf51b7f772e0b1 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 18:04:06 +0800 Subject: [PATCH 24/64] test(trae-cn): cover policy media retention --- .../trae-cn-chat-policy-media.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/responses/trae-cn-chat-policy-media.test.ts diff --git a/tests/responses/trae-cn-chat-policy-media.test.ts b/tests/responses/trae-cn-chat-policy-media.test.ts new file mode 100644 index 00000000000..a1e476b0bb0 --- /dev/null +++ b/tests/responses/trae-cn-chat-policy-media.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { OcxConfig } from "../../src/types"; +import type { RouteDecisionTraceV1 } from "../../src/routing/trace"; + +const calls: Array> = []; +const actualResponses = await import("../../src/server/responses"); +const actualPolicy = await import("../../src/server/responses/policy-fallback"); +const actualCore = await import("../../src/server/responses/core"); +let trace: RouteDecisionTraceV1; + +mock.module("../../src/server/responses", () => ({ + ...actualResponses, + handleResponses: async (req: Request, config: OcxConfig, logCtx: Record, options: Record) => { + const body = await req.json() as Record; + options.onRequestBodyParsed?.(body); + calls.push(body); + if (calls.length === 1) { + const concrete = new Request(req.url, { method: "POST", headers: req.headers, + body: JSON.stringify({ ...body, model: "trae-cn/test-model" }) }); + const refused = await actualCore.handleResponses(concrete, config, logCtx, options); + expect(refused.status).toBe(400); + logCtx.routeDecision = trace; + logCtx.requestedModel = "policy/daily"; + return refused; + } + return Response.json({ id: "resp_fixture", object: "response", created_at: 1, status: "completed", model: "accept-model", output: [], usage: null }); + }, +})); + +const { handleChatCompletions } = await import("../../src/server/chat-completions"); + +describe("Chat policy fallback media retention", () => { + afterEach(() => { calls.length = 0; }); + + for (const media of ["image", "video"]) { + test("re-projects original instruction media for the next candidate: " + media, async () => { + const config = { port: 0, defaultProvider: "trae-cn", providers: { + "trae-cn": { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true, + authMode: "key", models: ["test-model"], liveModels: false }, + capable: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key", + authMode: "key", models: ["accept-model"], inputModalities: ["text", "image", "video"] }, + } } as OcxConfig; + trace = { version: 1, decisionId: "chat-media-policy-" + media, createdAt: 1, requestedModel: "policy/daily", + routeKind: "policy", 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 attachment = media === "image" + ? { type: "image_url", image_url: { url: "https://fixture.invalid/image.png" } } + : { type: "video_url", video_url: { url: "https://fixture.invalid/clip.mp4" } }; + const messages = [ + { role: "system", content: [{ type: "text", text: "Inspect the attachment" }, attachment] }, + { role: "user", content: "Question" }, + ]; + 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 }), + }), config, { model: "", provider: "" }); + expect(response.status, await response.clone().text()).toBe(200); + expect(calls).toHaveLength(2); + expect(calls[1]?.model).toBe("capable/accept-model"); + const input = calls[1]?.input as Array<{ role?: string; content?: Array<{ type: string }> }>; + expect(input[0]?.role).toBe("developer"); + expect(input[0]?.content?.some(part => part.type === (media === "image" ? "input_image" : "input_video"))).toBe(true); + expect(input.at(-1)?.content?.some(part => part.type === "input_text")).toBe(true); + }); + } +}); From e574f7f2f461c513dacb8ae9d34032989696683f Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 18:30:09 +0800 Subject: [PATCH 25/64] fix(trae-cn): preserve Chat instruction media across policy retries --- src/adapters/openai-chat/messages.ts | 7 +- src/adapters/trae-cn/input-media.ts | 5 +- src/server/chat-completions.ts | 1 + src/server/responses/chat-policy-media.ts | 51 ++++++++++++++ src/server/responses/core-options.ts | 2 + src/server/responses/policy-fallback.ts | 6 +- .../adapter-registry-authority.test.ts | 12 ++++ ...-combo-run-turn-classified-failure.test.ts | 61 ++++++++++++++++ .../trae-cn-chat-policy-media.test.ts | 69 ------------------- 9 files changed, 139 insertions(+), 75 deletions(-) create mode 100644 src/server/responses/chat-policy-media.ts delete mode 100644 tests/responses/trae-cn-chat-policy-media.test.ts diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index f4c1f3e9222..ff75065f2a6 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -286,11 +286,10 @@ 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. + // A translated Chat instruction with media remains an instruction on this wire. + // Demoting it would discard the role even when the attachment itself is preserved. chatMsg = { - role: msg.role === "developer" && !hasImages && !hasAudio ? developerWireRole : "user", + role: msg.role === "developer" ? developerWireRole : "user", content: chatParts, }; if (provider.singleLeadingSystemMessage === true && chatMsg.role !== "user") { diff --git a/src/adapters/trae-cn/input-media.ts b/src/adapters/trae-cn/input-media.ts index 2dd0e70de7b..bce71e8798f 100644 --- a/src/adapters/trae-cn/input-media.ts +++ b/src/adapters/trae-cn/input-media.ts @@ -16,10 +16,13 @@ function hasUnsupportedInstructions(body: unknown, field: "input" | "messages"): } /** Reject media that parsing would erase from instruction or native agent-message inputs. */ +/** Reject typed media before Responses projection can substitute text markers or drop references. */ export function responsesHasUnsupportedInputMedia(body: unknown): boolean { return hasUnsupportedInstructions(body, "input") || (record(body) && Array.isArray(body.input) && body.input.some(item => - record(item) && item.type === "agent_message" && Array.isArray(item.content) + record(item) && (item.type === "agent_message" + || ((item.type === undefined || item.type === "message") && item.role === "user")) + && Array.isArray(item.content) && item.content.some(part => record(part) && typeof part.type === "string" && MEDIA_PART_TYPES.has(part.type)))); } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 56850f374f6..5a961a1f12d 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -578,6 +578,7 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + policyOriginalInboundBody: structuredClone(chatBody), chatInstructionImage: chatHasInstructionImage(chatBody), chatUnsupportedInstructionMedia: chatHasUnsupportedInstructionMedia(chatBody), chatRawReasoningVisible: rawReasoningVisible, diff --git a/src/server/responses/chat-policy-media.ts b/src/server/responses/chat-policy-media.ts new file mode 100644 index 00000000000..c10ec74f217 --- /dev/null +++ b/src/server/responses/chat-policy-media.ts @@ -0,0 +1,51 @@ +/** Preserve typed instruction content for policy candidates that can actually consume it. */ +export function restoreChatInstructionMedia( + projected: Record, original: unknown, +): Record { + if (typeof original !== "object" || original === null) return projected; + const messages = (original as { messages?: unknown }).messages; + const input = projected.input; + if (!Array.isArray(messages) || !Array.isArray(input)) return projected; + let cursor = 0; + const nextConversation = (): number => input.findIndex((item, index) => index >= cursor + && typeof item === "object" && item !== null && (item as { role?: unknown }).role === "user"); + const restored = [...input]; + for (const message of messages) { + if (typeof message !== "object" || message === null) continue; + const { role, content } = message as { role?: unknown; content?: unknown }; + if ((role !== "system" && role !== "developer") || !Array.isArray(content) + || !content.some(part => typeof part === "object" && part !== null + && ["image_url", "video_url", "input_audio", "file"].includes(String((part as { type?: unknown }).type)))) continue; + const converted = content.flatMap(part => { + if (typeof part === "string") return [{ type: "input_text", text: part }]; + if (typeof part !== "object" || part === null) return []; + const value = part as Record; + if (["text", "input_text", "output_text"].includes(String(value.type)) && typeof value.text === "string") { + return [{ type: "input_text", text: value.text }]; + } + if (value.type === "image_url") { + const image = value.image_url; + const url = typeof image === "string" ? image + : typeof image === "object" && image !== null ? (image as { url?: unknown }).url : undefined; + if (typeof url === "string") return [{ type: "input_image", image_url: url }]; + } + if (value.type === "video_url") { + const video = value.video_url; + const url = typeof video === "string" ? video + : typeof video === "object" && video !== null ? (video as { url?: unknown }).url : undefined; + if (typeof url === "string") return [{ type: "input_video", video_url: url }]; + } + if (value.type === "input_audio" || value.type === "input_file" || value.type === "file") return [structuredClone(value)]; + return []; + }); + const instruction = { type: "message", role: "developer", content: converted }; + if (restored.length === 0 || cursor === 0 && typeof restored[0] === "object" && restored[0] !== null + && (restored[0] as { role?: unknown }).role !== "user") { + restored.unshift(instruction); cursor = 1; continue; + } + const next = nextConversation(); + restored.splice(next < 0 ? restored.length : next, 0, instruction); + cursor = next < 0 ? restored.length : next + 1; + } + return { ...projected, input: restored }; +} diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 45dfef30593..363502eb3b3 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 inbound body available only to policy candidate retry projection. */ + policyOriginalInboundBody?: unknown; forceEmptyResponseId?: boolean; /** Internal, connection-owned control channel; never reconstructed from headers. */ nativeControl?: NativeResponseControl; diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 7db10d29f0f..bb0055fcd73 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,9 @@ 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); + rawBody = options.inboundWire === "chat" && options.policyOriginalInboundBody !== undefined + ? restoreChatInstructionMedia(chatToResponsesBody(options.policyOriginalInboundBody), options.policyOriginalInboundBody) + : structuredClone(body as Record); } }, onStoredPool401ReplayDispatched: () => { diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 4cffd17de4a..ceaab3f344f 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -397,6 +397,18 @@ describe("TRAE CN ACP regression contract", () => { }); } + 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(); 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 dd188067993..5e5198a1f35 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -97,6 +97,67 @@ async function respond(cfg: OcxConfig, stream: boolean) { return { status: response.status, text: await response.text() }; } +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 }; + 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" }, + ] } }; + const requests: RequestInit[] = []; + cfg.providers.capable.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requests.push(init!); + const streamed = Boolean(JSON.parse(String(init?.body)).stream); + if (media === "video") { + if (!streamed) return Response.json({ candidates: [{ content: { parts: [{ text: "accepted" }] }, finishReason: "STOP" }] }); + return new Response("data: " + JSON.stringify({ candidates: [{ content: { parts: [{ text: "accepted" }] }, finishReason: "STOP" }] }) + "\n\n", + { 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" } } + : { type: "video_url", video_url: { url: "data:video/mp4;base64,AAAAIGZ0eXBpc29t" } }; + const messages = [ + { role: "system", content: [{ type: "text", text: "Inspect the attachment" }, attachment] }, + { 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", 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 { messages?: Array<{ role?: string; content?: Array<{ type: string }> }> }; + if (media === "image") { + expect(sent.messages!.some(message => message.role === "system" && 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" }]); diff --git a/tests/responses/trae-cn-chat-policy-media.test.ts b/tests/responses/trae-cn-chat-policy-media.test.ts deleted file mode 100644 index a1e476b0bb0..00000000000 --- a/tests/responses/trae-cn-chat-policy-media.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; -import type { OcxConfig } from "../../src/types"; -import type { RouteDecisionTraceV1 } from "../../src/routing/trace"; - -const calls: Array> = []; -const actualResponses = await import("../../src/server/responses"); -const actualPolicy = await import("../../src/server/responses/policy-fallback"); -const actualCore = await import("../../src/server/responses/core"); -let trace: RouteDecisionTraceV1; - -mock.module("../../src/server/responses", () => ({ - ...actualResponses, - handleResponses: async (req: Request, config: OcxConfig, logCtx: Record, options: Record) => { - const body = await req.json() as Record; - options.onRequestBodyParsed?.(body); - calls.push(body); - if (calls.length === 1) { - const concrete = new Request(req.url, { method: "POST", headers: req.headers, - body: JSON.stringify({ ...body, model: "trae-cn/test-model" }) }); - const refused = await actualCore.handleResponses(concrete, config, logCtx, options); - expect(refused.status).toBe(400); - logCtx.routeDecision = trace; - logCtx.requestedModel = "policy/daily"; - return refused; - } - return Response.json({ id: "resp_fixture", object: "response", created_at: 1, status: "completed", model: "accept-model", output: [], usage: null }); - }, -})); - -const { handleChatCompletions } = await import("../../src/server/chat-completions"); - -describe("Chat policy fallback media retention", () => { - afterEach(() => { calls.length = 0; }); - - for (const media of ["image", "video"]) { - test("re-projects original instruction media for the next candidate: " + media, async () => { - const config = { port: 0, defaultProvider: "trae-cn", providers: { - "trae-cn": { adapter: "trae-cn", baseUrl: "https://www.trae.cn", keyOptional: true, - authMode: "key", models: ["test-model"], liveModels: false }, - capable: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key", - authMode: "key", models: ["accept-model"], inputModalities: ["text", "image", "video"] }, - } } as OcxConfig; - trace = { version: 1, decisionId: "chat-media-policy-" + media, createdAt: 1, requestedModel: "policy/daily", - routeKind: "policy", 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 attachment = media === "image" - ? { type: "image_url", image_url: { url: "https://fixture.invalid/image.png" } } - : { type: "video_url", video_url: { url: "https://fixture.invalid/clip.mp4" } }; - const messages = [ - { role: "system", content: [{ type: "text", text: "Inspect the attachment" }, attachment] }, - { role: "user", content: "Question" }, - ]; - 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 }), - }), config, { model: "", provider: "" }); - expect(response.status, await response.clone().text()).toBe(200); - expect(calls).toHaveLength(2); - expect(calls[1]?.model).toBe("capable/accept-model"); - const input = calls[1]?.input as Array<{ role?: string; content?: Array<{ type: string }> }>; - expect(input[0]?.role).toBe("developer"); - expect(input[0]?.content?.some(part => part.type === (media === "image" ? "input_image" : "input_video"))).toBe(true); - expect(input.at(-1)?.content?.some(part => part.type === "input_text")).toBe(true); - }); - } -}); From f657e5d20da455cc0efdabddd027f0cb1311aa32 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 21:41:40 +0800 Subject: [PATCH 26/64] fix(trae-cn): preserve media and settings on policy retries --- src/adapters/openai-chat/messages.ts | 4 +- src/adapters/trae-cn/input-media.ts | 9 +- src/chat/inbound.ts | 33 +++- src/server/chat-completions.ts | 15 +- src/server/responses/chat-policy-media.ts | 52 +----- src/server/responses/core-pipeline.ts | 12 +- src/server/responses/policy-fallback.ts | 9 +- src/server/responses/request-prepare.ts | 4 +- src/server/responses/run-turn-execution.ts | 11 +- structure/data-planes/images.md | 6 + structure/providers/chat-compat.md | 10 ++ structure/transports/policy-fallback.md | 6 + .../openai-chat-qwen38-leading-system.test.ts | 18 ++- ...-combo-run-turn-classified-failure.test.ts | 149 ++++++++++++++++-- 14 files changed, 260 insertions(+), 78 deletions(-) diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index ff75065f2a6..a054f0f965e 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -286,10 +286,8 @@ 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 translated Chat instruction with media remains an instruction on this wire. - // Demoting it would discard the role even when the attachment itself is preserved. chatMsg = { - role: msg.role === "developer" ? developerWireRole : "user", + role: msg.role === "developer" ? (leadingSystemTemplate ? "user" : developerWireRole) : "user", content: chatParts, }; if (provider.singleLeadingSystemMessage === true && chatMsg.role !== "user") { diff --git a/src/adapters/trae-cn/input-media.ts b/src/adapters/trae-cn/input-media.ts index bce71e8798f..a175c7cb510 100644 --- a/src/adapters/trae-cn/input-media.ts +++ b/src/adapters/trae-cn/input-media.ts @@ -15,15 +15,16 @@ function hasUnsupportedInstructions(body: unknown, field: "input" | "messages"): || !TEXT_PART_TYPES.has(part.type) || typeof part.text !== "string"))); } -/** Reject media that parsing would erase from instruction or native agent-message inputs. */ -/** Reject typed media before Responses projection can substitute text markers or drop references. */ -export function responsesHasUnsupportedInputMedia(body: unknown): boolean { +/** Reject media that cannot survive Responses projection; user images may be captioned first. */ +export function responsesHasUnsupportedInputMedia(body: unknown, options: { captionUserImages?: boolean } = {}): boolean { return hasUnsupportedInstructions(body, "input") || (record(body) && Array.isArray(body.input) && body.input.some(item => record(item) && (item.type === "agent_message" || ((item.type === undefined || item.type === "message") && item.role === "user")) && Array.isArray(item.content) - && item.content.some(part => record(part) && typeof part.type === "string" && MEDIA_PART_TYPES.has(part.type)))); + && item.content.some(part => record(part) && typeof part.type === "string" + && MEDIA_PART_TYPES.has(part.type) + && !(options.captionUserImages === true && part.type === "input_image" && item.role === "user")))); } /** Inspect Chat instructions before their non-text parts disappear during Responses projection. */ 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/server/chat-completions.ts b/src/server/chat-completions.ts index 5a961a1f12d..960242798b5 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -112,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; @@ -402,6 +404,7 @@ async function handleChatCompletionsWithBudget( let internalBody: Rec; let directConversation: ChatIrConversation | undefined; + let policyOriginalInboundBody: 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. @@ -432,6 +435,16 @@ 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) { + // Policy retries need the pre-projection conversation, but ordinary Chat requests do not. + // Admit the serialized clone before allocating it and retain its charge with this request. + translatorBudget.chargeRetained(jsonUtf8Bytes(chatBody), { kind: "request_copies" }); + policyOriginalInboundBody = structuredClone(chatBody); + } } catch (err) { const overflow = isTranslatorBudgetExceededError(err); const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500; @@ -578,7 +591,7 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", - policyOriginalInboundBody: structuredClone(chatBody), + policyOriginalInboundBody, chatInstructionImage: chatHasInstructionImage(chatBody), chatUnsupportedInstructionMedia: chatHasUnsupportedInstructionMedia(chatBody), chatRawReasoningVisible: rawReasoningVisible, diff --git a/src/server/responses/chat-policy-media.ts b/src/server/responses/chat-policy-media.ts index c10ec74f217..1d0dbe33ac2 100644 --- a/src/server/responses/chat-policy-media.ts +++ b/src/server/responses/chat-policy-media.ts @@ -1,51 +1,9 @@ -/** Preserve typed instruction content for policy candidates that can actually consume it. */ +import { chatToResponsesBody } from "../../protocols/codecs/chat"; + +/** Reuse the chronological Chat projection without dropping instruction attachments. */ export function restoreChatInstructionMedia( projected: Record, original: unknown, ): Record { - if (typeof original !== "object" || original === null) return projected; - const messages = (original as { messages?: unknown }).messages; - const input = projected.input; - if (!Array.isArray(messages) || !Array.isArray(input)) return projected; - let cursor = 0; - const nextConversation = (): number => input.findIndex((item, index) => index >= cursor - && typeof item === "object" && item !== null && (item as { role?: unknown }).role === "user"); - const restored = [...input]; - for (const message of messages) { - if (typeof message !== "object" || message === null) continue; - const { role, content } = message as { role?: unknown; content?: unknown }; - if ((role !== "system" && role !== "developer") || !Array.isArray(content) - || !content.some(part => typeof part === "object" && part !== null - && ["image_url", "video_url", "input_audio", "file"].includes(String((part as { type?: unknown }).type)))) continue; - const converted = content.flatMap(part => { - if (typeof part === "string") return [{ type: "input_text", text: part }]; - if (typeof part !== "object" || part === null) return []; - const value = part as Record; - if (["text", "input_text", "output_text"].includes(String(value.type)) && typeof value.text === "string") { - return [{ type: "input_text", text: value.text }]; - } - if (value.type === "image_url") { - const image = value.image_url; - const url = typeof image === "string" ? image - : typeof image === "object" && image !== null ? (image as { url?: unknown }).url : undefined; - if (typeof url === "string") return [{ type: "input_image", image_url: url }]; - } - if (value.type === "video_url") { - const video = value.video_url; - const url = typeof video === "string" ? video - : typeof video === "object" && video !== null ? (video as { url?: unknown }).url : undefined; - if (typeof url === "string") return [{ type: "input_video", video_url: url }]; - } - if (value.type === "input_audio" || value.type === "input_file" || value.type === "file") return [structuredClone(value)]; - return []; - }); - const instruction = { type: "message", role: "developer", content: converted }; - if (restored.length === 0 || cursor === 0 && typeof restored[0] === "object" && restored[0] !== null - && (restored[0] as { role?: unknown }).role !== "user") { - restored.unshift(instruction); cursor = 1; continue; - } - const next = nextConversation(); - restored.splice(next < 0 ? restored.length : next, 0, instruction); - cursor = next < 0 ? restored.length : next + 1; - } - return { ...projected, input: restored }; + const restored = chatToResponsesBody(original, { preserveInstructionMedia: true }); + return { ...projected, input: restored.input, instructions: restored.instructions }; } diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts index e095b9e5ed1..5d4f4691c91 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -27,6 +27,7 @@ import { formatErrorResponse } from "../../bridge"; import { InvalidRetainedCompactionSummary, prepareCompactionRetention } from "./compaction-retention"; import { responsesHasInstructionImage } from "../../adapters/claude-cli/instruction-images"; import { responsesHasUnsupportedInputMedia } from "../../adapters/trae-cn/input-media"; +import { planVisionSidecar, shouldResolveOpenAiVisionSidecar } from "../../vision"; import { markResponseNonReplayable } from "../../lib/upstream-retry"; /** Compose request phases while retaining the original admission-finally ownership. */ @@ -55,8 +56,15 @@ export async function handleResponsesInner( || requestState.parsed.context.messages.some(message => message.role === "developer" && Array.isArray(message.content) && message.content.some(part => part.type === "image")); const unsupportedTraeMedia = transportState.runTurnAdapter.name === "trae-cn" - && (options.chatUnsupportedInstructionMedia || options.responsesUnsupportedInputMedia - || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody)); + && (() => { + const captionUserImages = planVisionSidecar(config, requestState.route.provider, requestState.route.modelId, + requestState.parsed, undefined, { providerName: requestState.route.providerName }) !== undefined + || shouldResolveOpenAiVisionSidecar(config, requestState.route.provider, requestState.route.modelId, + requestState.parsed, requestState.route.providerName); + return options.chatUnsupportedInstructionMedia + || options.responsesUnsupportedInputMedia && !captionUserImages + || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody, { captionUserImages }); + })(); if (instructionImage || options.responsesFileImage || unsupportedTraeMedia) { const message = transportState.runTurnAdapter.name === "trae-cn" ? "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported." diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index bb0055fcd73..2f160540703 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -220,9 +220,12 @@ 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 = options.inboundWire === "chat" && options.policyOriginalInboundBody !== undefined - ? restoreChatInstructionMedia(chatToResponsesBody(options.policyOriginalInboundBody), options.policyOriginalInboundBody) - : structuredClone(body as Record); + if (options.inboundWire === "chat" && options.policyOriginalInboundBody !== undefined) { + const restored = restoreChatInstructionMedia( + chatToResponsesBody(options.policyOriginalInboundBody), options.policyOriginalInboundBody, + ); + rawBody = { ...body, 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 6a60ffc4553..597d574dae6 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -254,7 +254,7 @@ export async function prepareResponsesRequest( return decodeRequestErrorResponse(err, "responses"); } options.responsesInstructionImage ||= responsesHasInstructionImage(body); - options.responsesUnsupportedInputMedia ||= responsesHasUnsupportedInputMedia(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,7 +459,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); + options.responsesUnsupportedInputMedia ||= responsesHasUnsupportedInputMedia(body, { captionUserImages: true }); options.responsesFileImage ||= responsesHasFileImage(body); // Spawn-message compatibility (both directions): agent_message task payloads ride in diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index c9f47e083f0..fc2cdfde8bb 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -61,6 +61,7 @@ import { unboundPoolSpendRefusalMessage } from "../workflow-refusal"; import { claudeUsagePreflight } from "../../adapters/claude-cli/usage-admission"; import { responsesHasInstructionImage } from "../../adapters/claude-cli/instruction-images"; import { responsesHasUnsupportedInputMedia } from "../../adapters/trae-cn/input-media"; +import { planVisionSidecar, shouldResolveOpenAiVisionSidecar } from "../../vision"; // LOCAL PATCH (runturn-websearch): top-level fields route binding or the // adapter itself may write during a turn. Iteration-local `turnParsed` objects @@ -185,8 +186,14 @@ export async function executeResponsesRunTurn( if (transportState.runTurnAdapter.name === "claude-cli" || transportState.runTurnAdapter.name === "trae-cn") { if (options.chatInstructionImage || options.responsesInstructionImage || responsesHasInstructionImage(parsed._rawBody) || (transportState.runTurnAdapter.name === "trae-cn" - && (options.chatUnsupportedInstructionMedia || options.responsesUnsupportedInputMedia - || responsesHasUnsupportedInputMedia(parsed._rawBody))) + && (() => { + const captionUserImages = planVisionSidecar(config, route.provider, route.modelId, parsed, sidecarState.openAiSidecar, + { providerName: route.providerName }) !== undefined + || shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName); + return options.chatUnsupportedInstructionMedia + || options.responsesUnsupportedInputMedia && !captionUserImages + || responsesHasUnsupportedInputMedia(parsed._rawBody, { captionUserImages }); + })()) || parsed.context.messages.some(message => message.role === "developer" && Array.isArray(message.content) && message.content.some(part => part.type === "image"))) { cancelResponseCompletion(); diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index d2ddbc4241e..83b03cb310b 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -18,6 +18,12 @@ 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, video, documents and tool replay. +An ordinary user image may instead proceed when the selected route has a usable routed or OpenAI vision-preprocessing plan; +the sidecar replaces it with a description before ACP dispatch. This exception does not advertise native ACP image support or +bypass the other media refusals. `tests/responses/responses-combo-run-turn-classified-failure.test.ts` covers routed captioning +and the remaining direct and combo refusals. + 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/providers/chat-compat.md b/structure/providers/chat-compat.md index bc46a1ffe47..5e5e9780749 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -19,6 +19,16 @@ 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. +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. + 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/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/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/responses/responses-combo-run-turn-classified-failure.test.ts b/tests/responses/responses-combo-run-turn-classified-failure.test.ts index 5e5198a1f35..6f5c77cbbe0 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -97,6 +97,113 @@ 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("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"); +}); + 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" }]); @@ -108,14 +215,22 @@ for (const media of ["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) => { + cfg.providers.capable.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { requests.push(init!); - const streamed = Boolean(JSON.parse(String(init?.body)).stream); + 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: { parts: [{ text: "accepted" }] }, finishReason: "STOP" }] }); - return new Response("data: " + JSON.stringify({ candidates: [{ content: { parts: [{ text: "accepted" }] }, finishReason: "STOP" }] }) + "\n\n", - { headers: { "content-type": "text/event-stream" } }); + 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" }], @@ -132,21 +247,37 @@ for (const media of ["image", "video"]) { 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" } } + ? { 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", stream: false, messages }), - }), cfg, { model: "", provider: "" }); + 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 { messages?: Array<{ role?: string; content?: Array<{ type: string }> }> }; + 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 === "system" && Array.isArray(message.content) && message.content.some(part => part.type === "image_url"))).toBe(true); expect(sent.messages!.at(-1)?.content).toBe("Question"); From 12bd6c9f3c1c1d60e9151045d27c1f937ecc77a5 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 22:04:27 +0800 Subject: [PATCH 27/64] fix(trae-cn): bound image caption admission --- src/adapters/trae-cn/input-media.ts | 33 ++++++-- src/server/responses/core-pipeline.ts | 9 +-- src/server/responses/run-turn-execution.ts | 8 +- structure/data-planes/images.md | 12 +-- ...-combo-run-turn-classified-failure.test.ts | 81 +++++++++++++++++++ 5 files changed, 121 insertions(+), 22 deletions(-) diff --git a/src/adapters/trae-cn/input-media.ts b/src/adapters/trae-cn/input-media.ts index a175c7cb510..16899c5da3e 100644 --- a/src/adapters/trae-cn/input-media.ts +++ b/src/adapters/trae-cn/input-media.ts @@ -17,14 +17,31 @@ function hasUnsupportedInstructions(body: unknown, field: "input" | "messages"): /** Reject media that cannot survive Responses projection; user images may be captioned first. */ export function responsesHasUnsupportedInputMedia(body: unknown, options: { captionUserImages?: boolean } = {}): boolean { - return hasUnsupportedInstructions(body, "input") - || (record(body) && Array.isArray(body.input) && body.input.some(item => - record(item) && (item.type === "agent_message" - || ((item.type === undefined || item.type === "message") && item.role === "user")) - && 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" && item.role === "user")))); + if (hasUnsupportedInstructions(body, "input")) return true; + if (!record(body) || !Array.isArray(body.input)) return false; + const messageMedia = body.input.some(item => record(item) + && (item.type === "agent_message" || ((item.type === undefined || item.type === "message") && item.role === "user")) + && 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" && item.role === "user" + && (item.type === undefined || item.type === "message")))); + 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. */ diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts index 5d4f4691c91..df0795cfea9 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -26,8 +26,8 @@ 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 } from "../../adapters/trae-cn/input-media"; -import { planVisionSidecar, shouldResolveOpenAiVisionSidecar } from "../../vision"; +import { responsesHasUnsupportedInputMedia, responsesHasUnsupportedToolMedia } from "../../adapters/trae-cn/input-media"; +import { planVisionSidecar } from "../../vision"; import { markResponseNonReplayable } from "../../lib/upstream-retry"; /** Compose request phases while retaining the original admission-finally ownership. */ @@ -58,11 +58,10 @@ export async function handleResponsesInner( const unsupportedTraeMedia = transportState.runTurnAdapter.name === "trae-cn" && (() => { const captionUserImages = planVisionSidecar(config, requestState.route.provider, requestState.route.modelId, - requestState.parsed, undefined, { providerName: requestState.route.providerName }) !== undefined - || shouldResolveOpenAiVisionSidecar(config, requestState.route.provider, requestState.route.modelId, - requestState.parsed, requestState.route.providerName); + requestState.parsed, undefined, { providerName: requestState.route.providerName }) !== undefined; return options.chatUnsupportedInstructionMedia || options.responsesUnsupportedInputMedia && !captionUserImages + || responsesHasUnsupportedToolMedia(requestState.parsed._rawBody) || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody, { captionUserImages }); })(); if (instructionImage || options.responsesFileImage || unsupportedTraeMedia) { diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index fc2cdfde8bb..830b00f74a5 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -60,8 +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 } from "../../adapters/trae-cn/input-media"; -import { planVisionSidecar, shouldResolveOpenAiVisionSidecar } from "../../vision"; +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 @@ -188,10 +188,10 @@ export async function executeResponsesRunTurn( || (transportState.runTurnAdapter.name === "trae-cn" && (() => { const captionUserImages = planVisionSidecar(config, route.provider, route.modelId, parsed, sidecarState.openAiSidecar, - { providerName: route.providerName }) !== undefined - || shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName); + { 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" diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 83b03cb310b..20696a21868 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -18,11 +18,13 @@ 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, video, documents and tool replay. -An ordinary user image may instead proceed when the selected route has a usable routed or OpenAI vision-preprocessing plan; -the sidecar replaces it with a description before ACP dispatch. This exception does not advertise native ACP image support or -bypass the other media refusals. `tests/responses/responses-combo-run-turn-classified-failure.test.ts` covers routed captioning -and the remaining direct and combo refusals. +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. +`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`). 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 6f5c77cbbe0..6b32bdfcf9b 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -123,6 +123,87 @@ test("TRAE user images reach the routed vision sidecar instead of the pre-dispat } finally { open.mockRestore(); describer.mockRestore(); } }); +test("TRAE does not treat a credential-free OpenAI preference as a dispatchable vision plan", 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.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(); + } finally { open.mockRestore(); describer.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"); From e1e5059cb5ed8482687efad5fa6942beca3231bb Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 22:11:00 +0800 Subject: [PATCH 28/64] fix(trae-cn): keep instruction audio in user content --- src/adapters/openai-chat/messages.ts | 30 ++++++++++++++----- structure/providers/chat-compat.md | 3 ++ .../adapters/openai/openai-chat-audio.test.ts | 27 +++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index a054f0f965e..25397032417 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -290,14 +290,30 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv role: msg.role === "developer" ? (leadingSystemTemplate ? "user" : 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); + if (msg.role === "developer" && chatMsg.role !== "user" + && (hasAudio || provider.singleLeadingSystemMessage === true)) { + if (hasAudio) { + const audioParts = chatParts.filter(p => p.type === "input_audio"); + const instructionParts = chatParts.filter(p => p.type !== "input_audio"); + // Inline audio is user-content-only on this wire. Keep its instruction text in the + // original slot, then carry the attachment in a user message after the tool barrier. + chatMsg = { role: "user", content: audioParts }; + if (instructionParts.length > 0) { + const instruction = { role: leadingSystemTemplate ? "user" : developerWireRole, + content: instructionParts.filter(p => p.type === "text") }; + const attachments = instructionParts.filter(p => p.type !== "text"); + if (attachments.length > 0) instruction.content = [...instruction.content, ...attachments]; + if (instruction.content.length > 0) { + (pendingToolCalls.length > 0 ? deferredBarrierMessages : out).push(instruction); + } + } + } else { + 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.filter(p => p.type !== "text") }; } - chatMsg = { role: "user", content: chatParts.filter(p => p.type !== "text") }; } } if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 5e5e9780749..a609241209b 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -28,6 +28,9 @@ and Google video targets. 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. 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 diff --git a/tests/adapters/openai/openai-chat-audio.test.ts b/tests/adapters/openai/openai-chat-audio.test.ts index 51bdb40d7be..72f6c7a63d6 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,32 @@ 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("tool-result audio rides a follow-up user message after the tool round", () => { const messages = wire([ user("play the clip"), From 28e74d48e460c69538098ed1156c089bb9967a09 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 22:26:22 +0800 Subject: [PATCH 29/64] fix(trae-cn): reuse OpenAI vision credentials --- src/adapters/trae-cn/input-media.ts | 9 +-- src/server/responses/core-pipeline.ts | 40 ++++++++++--- src/server/responses/request-sidecar-auth.ts | 56 +++++++++++++------ structure/data-planes/images.md | 4 ++ structure/transports/responses.md | 2 +- ...-combo-run-turn-classified-failure.test.ts | 36 +++++++++++- 6 files changed, 116 insertions(+), 31 deletions(-) diff --git a/src/adapters/trae-cn/input-media.ts b/src/adapters/trae-cn/input-media.ts index 16899c5da3e..3582b8d066d 100644 --- a/src/adapters/trae-cn/input-media.ts +++ b/src/adapters/trae-cn/input-media.ts @@ -18,13 +18,14 @@ function hasUnsupportedInstructions(body: unknown, field: "input" | "messages"): /** 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)) return false; + 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" || ((item.type === undefined || item.type === "message") && item.role === "user")) + && (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" && item.role === "user" - && (item.type === undefined || item.type === "message")))); + && !(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" diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts index df0795cfea9..1dec197fbf1 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -11,7 +11,7 @@ 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 { createResponsesEffects } from "./response-effects"; import { createResponsesSendBudget } from "./request-send-budget"; import { executePassthroughResponse } from "./passthrough-execution"; @@ -30,6 +30,16 @@ import { responsesHasUnsupportedInputMedia, responsesHasUnsupportedToolMedia } f 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, @@ -50,6 +60,23 @@ export async function handleResponsesInner( if (requestState instanceof Response) return requestState; const transportState = await prepareResponsesTransport(requestContext, admissionState, requestState); if (transportState instanceof Response) return markProxyError(transportState); + 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 openAiSidecar = await resolveResponsesOpenAiSidecar(requestContext, requestState, transportState, true); + 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) { + 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) @@ -58,16 +85,15 @@ export async function handleResponsesInner( const unsupportedTraeMedia = transportState.runTurnAdapter.name === "trae-cn" && (() => { const captionUserImages = planVisionSidecar(config, requestState.route.provider, requestState.route.modelId, - requestState.parsed, undefined, { providerName: requestState.route.providerName }) !== undefined; + requestState.parsed, sidecarState?.openAiSidecar, { providerName: requestState.route.providerName }) !== undefined; return options.chatUnsupportedInstructionMedia || options.responsesUnsupportedInputMedia && !captionUserImages - || responsesHasUnsupportedToolMedia(requestState.parsed._rawBody) || responsesHasUnsupportedInputMedia(requestState.parsed._rawBody, { captionUserImages }); })(); if (instructionImage || options.responsesFileImage || unsupportedTraeMedia) { - const message = transportState.runTurnAdapter.name === "trae-cn" - ? "TRAE CN ACP supports text conversations only; media, documents and tool replay are not supported." - : instructionImage ? "Claude Code CLI does not support images in developer instructions." + 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, @@ -87,7 +113,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/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/structure/data-planes/images.md b/structure/data-planes/images.md index 20696a21868..733b2cae19e 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -23,6 +23,10 @@ TRAE CN retains its pre-dispatch refusal for instruction media, native agent-mes 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 require a vision plan. 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. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 8f75fe93644..9dd8ad79ebb 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. | | `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/responses/responses-combo-run-turn-classified-failure.test.ts b/tests/responses/responses-combo-run-turn-classified-failure.test.ts index 6b32bdfcf9b..ee7d596b964 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -124,6 +124,10 @@ test("TRAE user images reach the routed vision sidecar instead of the pre-dispat }); 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" }]); @@ -141,7 +145,37 @@ test("TRAE does not treat a credential-free OpenAI preference as a dispatchable 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(); } + 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["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(prompts.join("\n")).toContain("caption from OpenAI fixture"); + } finally { open.mockRestore(); describer.mockRestore(); resolver.mockRestore(); } }); test("TRAE rejects agent and tool-result images before any vision request", async () => { From e58e6d9e0baaa67a418ce670c2c5580631e6887b Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Wed, 11 Nov 2026 22:33:05 +0800 Subject: [PATCH 30/64] fix(trae-cn): keep single-leading attachments in user content --- src/adapters/openai-chat/messages.ts | 16 +++++++---- structure/providers/chat-compat.md | 2 ++ .../adapters/openai/openai-chat-audio.test.ts | 27 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 25397032417..6f2264d9de9 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -287,12 +287,19 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv return { type: "text", text: (p as OcxTextContent).text }; }); chatMsg = { - role: msg.role === "developer" ? (leadingSystemTemplate ? "user" : developerWireRole) : "user", + role: msg.role === "developer" && !(provider.singleLeadingSystemMessage === true && hasAudio) + ? (leadingSystemTemplate ? "user" : developerWireRole) : "user", content: chatParts, }; - if (msg.role === "developer" && chatMsg.role !== "user" + if (msg.role === "developer" && (hasAudio || chatMsg.role !== "user") && (hasAudio || provider.singleLeadingSystemMessage === true)) { - if (hasAudio) { + if (provider.singleLeadingSystemMessage === true) { + 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.filter(p => p.type !== "text") }; + } else if (hasAudio) { const audioParts = chatParts.filter(p => p.type === "input_audio"); const instructionParts = chatParts.filter(p => p.type !== "input_audio"); // Inline audio is user-content-only on this wire. Keep its instruction text in the @@ -301,8 +308,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv if (instructionParts.length > 0) { const instruction = { role: leadingSystemTemplate ? "user" : developerWireRole, content: instructionParts.filter(p => p.type === "text") }; - const attachments = instructionParts.filter(p => p.type !== "text"); - if (attachments.length > 0) instruction.content = [...instruction.content, ...attachments]; + instruction.content = [...instruction.content, ...instructionParts.filter(p => p.type !== "text")]; if (instruction.content.length > 0) { (pendingToolCalls.length > 0 ? deferredBarrierMessages : out).push(instruction); } diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index a609241209b..56f3fb6d90b 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -31,6 +31,8 @@ role rule as text-only instructions, so pinned templates receive a chronological 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. +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 diff --git a/tests/adapters/openai/openai-chat-audio.test.ts b/tests/adapters/openai/openai-chat-audio.test.ts index 72f6c7a63d6..e5d4e32ac3b 100644 --- a/tests/adapters/openai/openai-chat-audio.test.ts +++ b/tests/adapters/openai/openai-chat-audio.test.ts @@ -101,6 +101,33 @@ test("developer inline audio retains the instruction slot and uses a user audio && JSON.stringify(message.content).includes("input_audio"))).toBe(false); }); +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"), From 290688146e97337e17ac7998e4e256f396530a39 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 00:06:59 +0800 Subject: [PATCH 31/64] fix: address TRAE review follow-ups --- src/adapters/openai-chat/messages.ts | 57 +++++++++++------------- src/codex/catalog-auto-refresh.ts | 64 ++++++++++++++++++--------- src/codex/catalog/trae-cn-models.ts | 25 ++++++++++- src/server/responses/core-pipeline.ts | 6 ++- structure/catalog.md | 2 +- structure/data-planes/images.md | 3 +- structure/providers/chat-compat.md | 4 +- structure/providers/trae-cn.md | 4 +- structure/transports/responses.md | 2 +- 9 files changed, 107 insertions(+), 60 deletions(-) diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 6f2264d9de9..aefe2ad629e 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -286,40 +286,35 @@ 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 }; }); - chatMsg = { - role: msg.role === "developer" && !(provider.singleLeadingSystemMessage === true && hasAudio) - ? (leadingSystemTemplate ? "user" : developerWireRole) : "user", - content: chatParts, - }; - if (msg.role === "developer" && (hasAudio || chatMsg.role !== "user") - && (hasAudio || provider.singleLeadingSystemMessage === true)) { - if (provider.singleLeadingSystemMessage === true) { - 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.filter(p => p.type !== "text") }; - } else if (hasAudio) { - const audioParts = chatParts.filter(p => p.type === "input_audio"); - const instructionParts = chatParts.filter(p => p.type !== "input_audio"); - // Inline audio is user-content-only on this wire. Keep its instruction text in the - // original slot, then carry the attachment in a user message after the tool barrier. - chatMsg = { role: "user", content: audioParts }; - if (instructionParts.length > 0) { - const instruction = { role: leadingSystemTemplate ? "user" : developerWireRole, - content: instructionParts.filter(p => p.type === "text") }; - instruction.content = [...instruction.content, ...instructionParts.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 { - const instruction = { role: developerWireRole, content: chatParts.filter(p => p.type === "text") }; - if (instruction.content.length > 0) { - (pendingToolCalls.length > 0 ? deferredBarrierMessages : out).push(instruction); + // 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); } - chatMsg = { role: "user", content: chatParts.filter(p => p.type !== "text") }; + if (role !== undefined) destination.push({ role, content }); } + break; } } if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts index ee05ed1184c..82d30a66967 100644 --- a/src/codex/catalog-auto-refresh.ts +++ b/src/codex/catalog-auto-refresh.ts @@ -243,27 +243,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; - // CLI discovery belongs to the background source phase, before any catalog lock. + const managementConvergenceModule = import("./management-convergence"); + const catalogAdmissionModule = import("./catalog-admission"); + const [managementConvergence, catalogAdmission] = await Promise.all([ + managementConvergenceModule, catalogAdmissionModule, + ]); + 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; + 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); + 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; - const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([ - import("./management-convergence"), - import("./catalog-admission"), - ]); - // 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. - if (entryGeneration !== generation) return; - const converge = createManagementConvergeCodex(config); - const outcome = await converge(createCatalogConvergeRequest({ deadlineMs: TICK_DEADLINE_MS })); + 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. @@ -279,17 +309,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/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index c0476a71e80..44445b49bcd 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -12,6 +12,7 @@ import { markProviderDiscoveryFailed, markProviderDiscoveryOk, observeModelCacheRevision, + pinCachedModels, setCached, } from "../model-cache"; import { applyProviderConfigHints } from "./model-hints"; @@ -24,14 +25,36 @@ import { providerContextCap } from "../../providers/context-cap"; /** 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 refreshed: Array<{ name: string; generation: string; models: NonNullable> }> = []; for (const [name, provider] of Object.entries(config.providers)) { if (!current()) return; if (provider.adapter !== "trae-cn" || provider.disabled || provider.liveModels === false) continue; - await fetchProviderModelsWithAuth(captureProviderGather(name, provider, refreshingModelsAuthResolver), + const gathered = await fetchProviderModelsWithAuth(captureProviderGather(name, provider, refreshingModelsAuthResolver), config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS, providerContextCap(config, name), refreshingModelsAuthResolver); + if (!current()) return; + const models = getStaleCached(name); + if (gathered.outcome.state === "authoritative" && models) { + refreshed.push({ name, models, generation: captureModelCacheGeneration(name) }); + } + } + if (!after || refreshed.length === 0 || !current()) return; + const releases: Array<() => void> = []; + for (const roster of refreshed) { + const release = pinCachedModels(roster.name, roster.models, Date.now(), roster.generation); + if (!release) { + for (const acquired of releases.reverse()) acquired(); + return; + } + releases.push(release); + } + try { + await after(); + } finally { + for (const release of releases.reverse()) release(); } } diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts index 1dec197fbf1..b05c481830c 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -12,6 +12,7 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; import { prepareResponsesRequest } from "./request-prepare"; import { prepareResponsesTransport } from "./request-transport"; 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"; @@ -68,11 +69,14 @@ export async function handleResponsesInner( let sidecarState: Awaited> | undefined; if (unsupportedTraeReplayMedia) return traeMediaRefusal(logCtx); if (transportState.runTurnAdapter.name === "trae-cn") { - const openAiSidecar = await resolveResponsesOpenAiSidecar(requestContext, requestState, transportState, true); + 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); diff --git a/structure/catalog.md b/structure/catalog.md index 6b3df3ce528..452352d1e51 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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 runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. `src/codex/management-convergence.ts` and 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). +TRAE CN discovery runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. A successful round pins its discovered rosters through convergence and connected-client fan-out, so even `modelCacheTtlMs: 0` remains authoritative until publication completes; the round discovers each roster once and then releases every pin. `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 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. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 733b2cae19e..74ff1cbbc68 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -25,7 +25,8 @@ not dispatchability. The sidecar replaces that image with a description before A 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 require a vision plan. The credentialed and credential-free OpenAI cases and the resolver's single invocation +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. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 56f3fb6d90b..7601d12a685 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -30,7 +30,9 @@ role rule as text-only instructions, so pinned templates receive a chronological `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. +covers the default folded role and this tool-result boundary. Default developer images and documents use the same user +carrier rule. Contiguous text and attachment 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 5789b7f407f..7adade0dcf6 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -29,7 +29,9 @@ user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overri 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, then converges; cold or stalled discovery cannot delay unrelated management responses. Lost-generation discovery rereads the current +source phase, then pins every successfully discovered roster through catalog convergence and connected-client +refresh before releasing the publication leases. 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. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9dd8ad79ebb..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. `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. | +| `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. | From b95efea1a8c86458bd674bd5630c1151dc1ae435 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 00:07:28 +0800 Subject: [PATCH 32/64] test: cover TRAE review regressions --- .../adapter-registry-authority.test.ts | 19 +++++ .../adapters/openai/openai-chat-audio.test.ts | 58 ++++++++++++++ ...-combo-run-turn-classified-failure.test.ts | 77 ++++++++++++++++++- 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index ceaab3f344f..64748c3900b 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1241,6 +1241,25 @@ describe("TRAE CN ACP regression contract", () => { 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(); + }); + for (const stopped of [false, true]) { test("background CLI refresh does not hold management admission; stopped=" + stopped, async () => { let entered!: () => void; diff --git a/tests/adapters/openai/openai-chat-audio.test.ts b/tests/adapters/openai/openai-chat-audio.test.ts index e5d4e32ac3b..7fcbee7e401 100644 --- a/tests/adapters/openai/openai-chat-audio.test.ts +++ b/tests/adapters/openai/openai-chat-audio.test.ts @@ -101,6 +101,64 @@ test("developer inline audio retains the instruction slot and uses a user audio && 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: "system", content: [{ type: "text", text: "Inspect this image" }] }, + { role: "user", content: [ + { 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: "system", content: [{ type: "text", text: "Read this document" }] }, + { role: "user", content: [{ 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" }, 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 ee7d596b964..433c5e79716 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -159,6 +159,8 @@ test("TRAE reuses a credentialed OpenAI sidecar for ordinary user images", async 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" }; @@ -174,10 +176,83 @@ test("TRAE reuses a credentialed OpenAI sidecar for ordinary user images", async }), 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("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"); @@ -393,7 +468,7 @@ for (const media of ["image", "video"]) { if (media === "image") { expect(sent.reasoning_effort).toBe("high"); expect(wire).toContain('"detail":"high"'); - expect(sent.messages!.some(message => message.role === "system" && Array.isArray(message.content) + 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 { From d285dee06b6901e52cd1528a1add5953b63d4a31 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 01:11:38 +0800 Subject: [PATCH 33/64] fix: retain discovered TRAE roster under pressure --- src/codex/catalog/trae-cn-models.ts | 7 +++---- structure/catalog.md | 2 +- structure/providers/trae-cn.md | 5 +++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 44445b49bcd..4ec3ae7250d 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -29,16 +29,15 @@ export async function refreshTraeCnCatalogSources( ): Promise { const { fetchProviderModelsWithAuth, refreshingModelsAuthResolver } = await import("./provider-models"); const { captureProviderGather } = await import("./gather-capture"); - const refreshed: Array<{ name: string; generation: string; models: NonNullable> }> = []; + const refreshed: Array<{ name: string; generation: string; models: CatalogModel[] }> = []; 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; - const models = getStaleCached(name); - if (gathered.outcome.state === "authoritative" && models) { - refreshed.push({ name, models, generation: captureModelCacheGeneration(name) }); + if (gathered.outcome.state === "authoritative") { + refreshed.push({ name, models: gathered.models, generation: captureModelCacheGeneration(name) }); } } if (!after || refreshed.length === 0 || !current()) return; diff --git a/structure/catalog.md b/structure/catalog.md index 452352d1e51..18b540353c7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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 runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. A successful round pins its discovered rosters through convergence and connected-client fan-out, so even `modelCacheTtlMs: 0` remains authoritative until publication completes; the round discovers each roster once and then releases every pin. `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). +TRAE CN discovery runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. A successful round pins the authoritative gather result directly through convergence and connected-client fan-out, so ordinary cache eviction and `modelCacheTtlMs: 0` cannot discard that roster before publication completes; the round discovers each roster once and then releases every pin. `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 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 7adade0dcf6..94495ecc906 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -29,8 +29,9 @@ user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overri 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, then pins every successfully discovered roster through catalog convergence and connected-client -refresh before releasing the publication leases. This keeps a zero-TTL roster authoritative for the full publication +source phase, then pins every authoritative gather result through catalog convergence and connected-client +refresh before releasing the publication leases. The pin does not depend on the ordinary cache entry surviving +shared-memory eviction. 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`. From 9de51c0a52557abaffdd78276e9495feacf39972 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 01:12:33 +0800 Subject: [PATCH 34/64] test: cover TRAE roster pressure publication --- .../adapters/adapter-registry-authority.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 64748c3900b..822669b7ee4 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1260,6 +1260,22 @@ describe("TRAE CN ACP regression contract", () => { expect(getPinnedCached("trae-cn")).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); + }); + }); + for (const stopped of [false, true]) { test("background CLI refresh does not hold management admission; stopped=" + stopped, async () => { let entered!: () => void; From 515d14a944e54ac61c1f7dd4dc5660b3c6039cc9 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 01:38:59 +0800 Subject: [PATCH 35/64] fix: revalidate TRAE roster revisions before pinning --- src/codex/catalog/trae-cn-models.ts | 6 ++++-- structure/catalog.md | 2 +- structure/providers/trae-cn.md | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 4ec3ae7250d..7ad311c251b 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -29,7 +29,7 @@ export async function refreshTraeCnCatalogSources( ): Promise { const { fetchProviderModelsWithAuth, refreshingModelsAuthResolver } = await import("./provider-models"); const { captureProviderGather } = await import("./gather-capture"); - const refreshed: Array<{ name: string; generation: string; models: CatalogModel[] }> = []; + const refreshed: Array<{ name: string; generation: string; contentRevision: string; models: CatalogModel[] }> = []; for (const [name, provider] of Object.entries(config.providers)) { if (!current()) return; if (provider.adapter !== "trae-cn" || provider.disabled || provider.liveModels === false) continue; @@ -37,10 +37,12 @@ export async function refreshTraeCnCatalogSources( config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS, providerContextCap(config, name), refreshingModelsAuthResolver); if (!current()) return; if (gathered.outcome.state === "authoritative") { - refreshed.push({ name, models: gathered.models, generation: captureModelCacheGeneration(name) }); + refreshed.push({ name, models: gathered.models, generation: captureModelCacheGeneration(name), + contentRevision: gathered.contentRevision }); } } if (!after || refreshed.length === 0 || !current()) return; + if (refreshed.some(roster => observeModelCacheRevision(roster.name) !== roster.contentRevision)) return; const releases: Array<() => void> = []; for (const roster of refreshed) { const release = pinCachedModels(roster.name, roster.models, Date.now(), roster.generation); diff --git a/structure/catalog.md b/structure/catalog.md index 18b540353c7..54e1fe57628 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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 runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. A successful round pins the authoritative gather result directly through convergence and connected-client fan-out, so ordinary cache eviction and `modelCacheTtlMs: 0` cannot discard that roster before publication completes; the round discovers each roster once and then releases every pin. `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). +TRAE CN discovery runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. A successful round pins the authoritative gather result directly through convergence and connected-client fan-out, so ordinary cache eviction and `modelCacheTtlMs: 0` cannot discard that roster before publication completes. The round revalidates each captured content revision immediately before synchronous pin admission; an intervening discovery retires the whole publication round rather than restoring an older roster. Each roster is discovered once per round and 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 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 94495ecc906..d2ccc9f7e32 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -31,7 +31,8 @@ Observed-auth gathers and management admission use only cached evidence; only re probes may launch the CLI. Automatic refresh prepares CLI-owned evidence in its independent background source phase, then pins every authoritative gather result through catalog convergence and connected-client refresh before releasing the publication leases. The pin does not depend on the ordinary cache entry surviving -shared-memory eviction. This keeps a zero-TTL roster authoritative for the full publication +shared-memory eviction. Every captured content revision is revalidated before the synchronous pin sequence; +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`. From e88fd227cbb712f6cf4adb971cb5818c8a184982 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 01:39:59 +0800 Subject: [PATCH 36/64] test: cover concurrent TRAE roster refresh --- .../adapter-registry-authority.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 822669b7ee4..872025989c6 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1276,6 +1276,39 @@ describe("TRAE CN ACP regression contract", () => { }); }); + 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) { + 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; From 0c864ca332250631bcf8ec5bb81983f8bafe7ab2 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 02:05:47 +0800 Subject: [PATCH 37/64] fix: preserve combo media and TRAE roster pins --- src/codex/catalog/trae-cn-models.ts | 33 ++++++++++++------------- src/server/chat-completions.ts | 11 +++++---- src/server/responses/core-combo.ts | 6 ++++- src/server/responses/core-options.ts | 4 +-- src/server/responses/policy-fallback.ts | 4 +-- structure/catalog.md | 2 +- structure/providers/chat-compat.md | 2 ++ structure/providers/trae-cn.md | 6 ++--- 8 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 7ad311c251b..652e22ec954 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -29,33 +29,32 @@ export async function refreshTraeCnCatalogSources( ): Promise { const { fetchProviderModelsWithAuth, refreshingModelsAuthResolver } = await import("./provider-models"); const { captureProviderGather } = await import("./gather-capture"); - const refreshed: Array<{ name: string; generation: string; contentRevision: string; models: CatalogModel[] }> = []; + const retained: Array<{ name: string; contentRevision: string; release: () => void }> = []; + const releaseRetained = (): void => { + for (const roster of retained.reverse()) roster.release(); + retained.length = 0; + }; for (const [name, provider] of Object.entries(config.providers)) { - if (!current()) return; + if (!current()) { releaseRetained(); 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 (gathered.outcome.state === "authoritative") { - refreshed.push({ name, models: gathered.models, generation: captureModelCacheGeneration(name), - contentRevision: gathered.contentRevision }); + if (!current()) { releaseRetained(); return; } + if (after && gathered.outcome.state === "authoritative") { + const release = pinCachedModels(name, gathered.models, Date.now(), captureModelCacheGeneration(name)); + if (!release) { releaseRetained(); return; } + retained.push({ name, release, contentRevision: observeModelCacheRevision(name) }); } } - if (!after || refreshed.length === 0 || !current()) return; - if (refreshed.some(roster => observeModelCacheRevision(roster.name) !== roster.contentRevision)) return; - const releases: Array<() => void> = []; - for (const roster of refreshed) { - const release = pinCachedModels(roster.name, roster.models, Date.now(), roster.generation); - if (!release) { - for (const acquired of releases.reverse()) acquired(); - return; - } - releases.push(release); + if (!after || retained.length === 0) return; + if (!current() || retained.some(roster => observeModelCacheRevision(roster.name) !== roster.contentRevision)) { + releaseRetained(); + return; } try { await after(); } finally { - for (const release of releases.reverse()) release(); + releaseRetained(); } } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 960242798b5..8e8c3d995fd 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -404,7 +404,7 @@ async function handleChatCompletionsWithBudget( let internalBody: Rec; let directConversation: ChatIrConversation | undefined; - let policyOriginalInboundBody: Rec | 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. @@ -439,11 +439,12 @@ async function handleChatCompletionsWithBudget( const mayRetryPolicy = settledRoute?.routeKind === "policy" || Boolean(intercept?.enabled && intercept.model && isShadowSourceModel(requestedModel, intercept.sourceModels)); - if (mayRetryPolicy) { - // Policy retries need the pre-projection conversation, but ordinary Chat requests do not. + if (mayRetryPolicy || Boolean(settledRoute?.combo && !nativeChatCombos)) { + // Policy retries and translated combo children need the pre-projection conversation, but + // ordinary Chat requests and native combo delivery do not. // Admit the serialized clone before allocating it and retain its charge with this request. translatorBudget.chargeRetained(jsonUtf8Bytes(chatBody), { kind: "request_copies" }); - policyOriginalInboundBody = structuredClone(chatBody); + chatReplayOriginalInboundBody = structuredClone(chatBody); } } catch (err) { const overflow = isTranslatorBudgetExceededError(err); @@ -591,7 +592,7 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", - policyOriginalInboundBody, + chatReplayOriginalInboundBody, chatInstructionImage: chatHasInstructionImage(chatBody), chatUnsupportedInstructionMedia: chatHasUnsupportedInstructionMedia(chatBody), chatRawReasoningVisible: rawReasoningVisible, diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index a340f386742..086130fb89b 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -66,6 +66,7 @@ import { previousResponseProviderState, } from "../../responses/state"; import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload"; +import { restoreChatInstructionMedia } from "./chat-policy-media"; import { captureRouteStaticPolicy, routeConcreteModel, comboRouteDecisionTrace } from "../../router"; import { memoryModelRouteReason } from "./memory-models"; import { baseProviderLabel, poolAccountProviderLabel } from "../../providers/label"; @@ -1082,7 +1083,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), @@ -1092,6 +1093,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 363502eb3b3..34719e8ca68 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -163,8 +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 inbound body available only to policy candidate retry projection. */ - policyOriginalInboundBody?: unknown; + /** 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; diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 2f160540703..b44313e69ea 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -220,9 +220,9 @@ 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. - if (options.inboundWire === "chat" && options.policyOriginalInboundBody !== undefined) { + if (options.inboundWire === "chat" && options.chatReplayOriginalInboundBody !== undefined) { const restored = restoreChatInstructionMedia( - chatToResponsesBody(options.policyOriginalInboundBody), options.policyOriginalInboundBody, + chatToResponsesBody(options.chatReplayOriginalInboundBody), options.chatReplayOriginalInboundBody, ); rawBody = { ...body, input: restored.input, instructions: restored.instructions }; } else rawBody = structuredClone(body as Record); diff --git a/structure/catalog.md b/structure/catalog.md index 54e1fe57628..fdd8e797019 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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 runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. A successful round pins the authoritative gather result directly through convergence and connected-client fan-out, so ordinary cache eviction and `modelCacheTtlMs: 0` cannot discard that roster before publication completes. The round revalidates each captured content revision immediately before synchronous pin admission; an intervening discovery retires the whole publication round rather than restoring an older roster. Each roster is discovered once per round and 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). +TRAE CN discovery runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. Each authoritative gather result is pinned before the next provider can evict its ordinary cache row, and 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 before publication; an intervening discovery or authority change retires the whole round rather than restoring an older roster. Each roster is discovered once per round and 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 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. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 7601d12a685..1f927274bc1 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -25,6 +25,8 @@ several consecutive instructions and the existing deferred tool-result boundary. 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. Native Chat combo delivery retains its existing envelope. 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index d2ccc9f7e32..56267d5c3f5 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -29,9 +29,9 @@ user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overri 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, then pins every authoritative gather result through catalog convergence and connected-client -refresh before releasing the publication leases. The pin does not depend on the ordinary cache entry surviving -shared-memory eviction. Every captured content revision is revalidated before the synchronous pin sequence; +source phase, then pins each authoritative gather result before 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. From 127b45ad3b008e5ab4b0fd12552156c5687f9718 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 02:06:51 +0800 Subject: [PATCH 38/64] test: cover combo media and multi-roster pressure --- .../adapter-registry-authority.test.ts | 21 ++++++++++ ...-combo-run-turn-classified-failure.test.ts | 40 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 872025989c6..4d47e3ca8aa 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1276,6 +1276,26 @@ describe("TRAE CN ACP regression contract", () => { }); }); + 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 publication cannot replace an intervening newer roster", async () => { const rowA = { ...config(), models: [] }; const rowB = { ...config(), models: [] }; @@ -1285,6 +1305,7 @@ describe("TRAE CN ACP regression contract", () => { 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, ); 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 433c5e79716..9bf637ae146 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -394,6 +394,46 @@ test("the policy-only original Chat clone is admitted by the replay budget", asy 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"); + 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: "https://fixture.invalid/image.png", detail: "high" } }, + ] }, + { 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("https://fixture.invalid/image.png"); + expect(wire).toContain('\"detail\":\"high\"'); + expect(wire.indexOf("Inspect the attachment")).toBe(wire.lastIndexOf("Inspect the attachment")); + } 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" }]); From 6ef68f0a2061acd3429070e77172980ae155efcb Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 02:29:53 +0800 Subject: [PATCH 39/64] fix: close combo media and roster cleanup gaps --- src/codex/catalog/trae-cn-models.ts | 31 +++++++++++++---------------- src/server/chat-completions.ts | 6 +++--- src/server/responses/core-combo.ts | 5 +++-- structure/providers/chat-compat.md | 4 +++- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 652e22ec954..8bacd8b642a 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -34,24 +34,21 @@ export async function refreshTraeCnCatalogSources( for (const roster of retained.reverse()) roster.release(); retained.length = 0; }; - for (const [name, provider] of Object.entries(config.providers)) { - if (!current()) { releaseRetained(); 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()) { releaseRetained(); return; } - if (after && gathered.outcome.state === "authoritative") { - const release = pinCachedModels(name, gathered.models, Date.now(), captureModelCacheGeneration(name)); - if (!release) { releaseRetained(); return; } - retained.push({ name, release, contentRevision: observeModelCacheRevision(name) }); - } - } - if (!after || retained.length === 0) return; - if (!current() || retained.some(roster => observeModelCacheRevision(roster.name) !== roster.contentRevision)) { - releaseRetained(); - return; - } 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") { + const release = pinCachedModels(name, gathered.models, 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(); diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 8e8c3d995fd..872d114914b 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -439,9 +439,9 @@ async function handleChatCompletionsWithBudget( const mayRetryPolicy = settledRoute?.routeKind === "policy" || Boolean(intercept?.enabled && intercept.model && isShadowSourceModel(requestedModel, intercept.sourceModels)); - if (mayRetryPolicy || Boolean(settledRoute?.combo && !nativeChatCombos)) { - // Policy retries and translated combo children need the pre-projection conversation, but - // ordinary Chat requests and native combo delivery do not. + 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); diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 086130fb89b..98063ede114 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -442,7 +442,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); @@ -473,7 +474,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"); } const comboReplaySnapshot = { diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 1f927274bc1..8857389da15 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -26,7 +26,9 @@ while the normalized effort and other retry settings remain authoritative. Ordin `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. Native Chat combo delivery retains its existing envelope. +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. From 71b104a6757221385c265769d774bcdac5e5a5ee Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 02:30:59 +0800 Subject: [PATCH 40/64] test: cover native combo media and pin cleanup --- .../adapter-registry-authority.test.ts | 15 +++++ ...-combo-run-turn-classified-failure.test.ts | 57 +++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 4d47e3ca8aa..691702c5071 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1296,6 +1296,21 @@ describe("TRAE CN ACP regression contract", () => { }); }); + 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 replace an intervening newer roster", async () => { const rowA = { ...config(), models: [] }; const rowB = { ...config(), models: [] }; 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 9bf637ae146..3e8a3c3ba76 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -414,13 +414,63 @@ test("Chat combo fallback retains original developer image media", async () => { }) 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: "https://fixture.invalid/image.png", detail: "high" } }, + { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8=" } }, ] }, { role: "user", content: "Question" }, ] }), @@ -428,9 +478,8 @@ test("Chat combo fallback retains original developer image media", async () => { 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.indexOf("Inspect the attachment")).toBe(wire.lastIndexOf("Inspect the attachment")); + expect(wire).toContain("Inspect the attachment"); + expect(wire).toContain("aGVsbG8="); } finally { open.mockRestore(); } }); From 47b67bc5492fa3f403113907612c610b5717c534 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 02:54:03 +0800 Subject: [PATCH 41/64] fix: reject stale TRAE rosters before pinning --- src/codex/catalog/trae-cn-models.ts | 1 + structure/catalog.md | 2 +- structure/providers/trae-cn.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 8bacd8b642a..748c373deef 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -42,6 +42,7 @@ export async function refreshTraeCnCatalogSources( 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, Date.now(), captureModelCacheGeneration(name)); if (!release) return; retained.push({ name, release, contentRevision: observeModelCacheRevision(name) }); diff --git a/structure/catalog.md b/structure/catalog.md index fdd8e797019..3d37d364281 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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 runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. Each authoritative gather result is pinned before the next provider can evict its ordinary cache row, and 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 before publication; an intervening discovery or authority change retires the whole round rather than restoring an older roster. Each roster is discovered once per round and 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). +TRAE CN discovery runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. Each authoritative gather result must still own its captured content revision before it is pinned, then stays pinned before the next provider can evict its ordinary cache row. 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. Each roster is discovered once per round and 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 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 56267d5c3f5..9bbb13f2ec2 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -29,7 +29,7 @@ user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overri 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, then pins each authoritative gather result before discovering the next provider. Those pins survive +source phase, then 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 From 11d35e6484c547965173ac8bc44d00e651978640 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 02:55:26 +0800 Subject: [PATCH 42/64] test: cover pre-pin TRAE roster replacement --- .../adapter-registry-authority.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 691702c5071..ea301d61cbb 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -1311,6 +1311,30 @@ describe("TRAE CN ACP regression contract", () => { 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("background publication cannot replace an intervening newer roster", async () => { const rowA = { ...config(), models: [] }; const rowB = { ...config(), models: [] }; From 2279135f0b07da1830ab10326fb6fe2b7e2375fa Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 03:58:15 +0800 Subject: [PATCH 43/64] fix: serialize TRAE discovery and retain refusal usage --- src/adapters/trae-cn/session.ts | 24 +++++++++--- src/codex/catalog/trae-cn-models.ts | 60 ++++++++++++++++++++--------- structure/catalog.md | 2 +- structure/providers/trae-cn.md | 3 +- 4 files changed, 63 insertions(+), 26 deletions(-) diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 2a050ac912b..569687ffa1a 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -192,7 +192,8 @@ class TraeAcpClient { } /** Register before writing so immediate responses and stream errors settle the same request. */ - async request(method: string, params: Record, dispatch?: TraePromptDispatch, onSuccess?: () => void): Promise { + 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) => { @@ -201,7 +202,7 @@ class TraeAcpClient { reject(new TraeAcpError(`TRAE CN ACP timed out during ${method}`, "trae_acp_timeout")); }, this.timeoutMs); this.pending.set(id, { resolve: result => { - try { onSuccess?.(); resolve(result); } catch (error) { reject(error); } + try { onSuccess?.(result); resolve(result); } catch (error) { reject(error); } }, reject, timer }); }); try { @@ -357,20 +358,31 @@ export async function openTraeSession( await client.request("session/set_model", { sessionId, modelId }, undefined, () => client.acknowledgeModelSelection(sessionId, modelId)); if (signal?.aborted) return; let output = false; - completion = client.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, dispatch); - void completion.then(() => client.endUpdates(), () => client.endUpdates()); + 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?.(); + }; + void completion.then(endUpdatesAfterBatch, endUpdatesAfterBatch); while (true) { const frame = await client.nextUpdate(); if (!frame) break; if (frame.kind !== "update" || frame.sessionId !== sessionId) continue; for (const event of traePromptEvents(sessionId, frame.update)) { - if (event.type === "text_delta") output = true; - emit(event); if (event.type === "error") { + 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; diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 748c373deef..92a19253af2 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -22,6 +22,24 @@ 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, @@ -82,23 +100,29 @@ export async function fetchTraeCnProviderModels( // 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); - 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 withDiscoveryTurn(name, async () => { + 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 generation = captureModelCacheGeneration(name); + 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(); } - return currentFallback(); - } + }); } diff --git a/structure/catalog.md b/structure/catalog.md index 3d37d364281..0df84de94ee 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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 runs in the background source phase of `src/codex/catalog-auto-refresh.ts`, before its subsequent convergence. Each authoritative gather result must still own its captured content revision before it is pinned, then stays pinned before the next provider can evict its ordinary cache row. 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. Each roster is discovered once per round and 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). +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. 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. 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 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 9bbb13f2ec2..7b7aae7bea2 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -29,7 +29,8 @@ user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overri 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, then revalidates the gather's content revision before pinning each authoritative result and discovering the next provider. Those pins survive +source phase. Discovery sessions are serialized per provider, so a slow older session 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 From c1f1a8269e2d793fa31738a8251c71cc44317d58 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 03:59:31 +0800 Subject: [PATCH 44/64] test: cover discovery order and refusal usage --- .../adapter-registry-authority.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index ea301d61cbb..73af4dd543f 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -788,6 +788,22 @@ describe("TRAE CN ACP regression contract", () => { }); } + 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("shutdown remains bounded when the process and its pipe never close", async () => { const fixture = traeFixture(request => request.method === "session/prompt"); fixture.child.kill = () => true; @@ -1335,6 +1351,43 @@ describe("TRAE CN ACP regression contract", () => { 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("background publication cannot replace an intervening newer roster", async () => { const rowA = { ...config(), models: [] }; const rowB = { ...config(), models: [] }; From cdceb1a6ef86158a41dc30eed158c8321d8337a4 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 04:20:28 +0800 Subject: [PATCH 45/64] fix: bind discovery authority and refusal usage --- src/adapters/trae-cn/session.ts | 14 +++++++++++++- src/codex/catalog/trae-cn-models.ts | 3 ++- structure/catalog.md | 2 +- structure/providers/trae-cn.md | 6 ++++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 569687ffa1a..6d2050d4ed7 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -373,7 +373,19 @@ export async function openTraeSession( const frame = await client.nextUpdate(); if (!frame) break; if (frame.kind !== "update" || frame.sessionId !== sessionId) continue; - for (const event of traePromptEvents(sessionId, frame.update)) { + let promptEvents: AdapterEvent[]; + try { + promptEvents = traePromptEvents(sessionId, frame.update); + } catch { + 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") { const usage = completionReceived ? traeUsage(receivedCompletion) : undefined; emit({ ...event, ...(usage ? { usage } : {}) }); diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 92a19253af2..6cd869f20d9 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -100,12 +100,13 @@ export async function fetchTraeCnProviderModels( // 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 generation = captureModelCacheGeneration(name); const currentFallback = (): ProviderModelsResult => observed(getStaleCached(name) ?? configured, "degraded"); try { const session = await openTraeSession(provider.baseUrl, { timeoutMs: 15_000 }, undefined, provider); diff --git a/structure/catalog.md b/structure/catalog.md index 0df84de94ee..478609338c2 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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. 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. 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). +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. 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 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. diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 7b7aae7bea2..c66141a6051 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -12,7 +12,8 @@ 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`; only a +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. @@ -29,7 +30,8 @@ user home plus `.trae`, before HOME isolation. Provider `proxy`/`noProxy` overri 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, so a slow older session cannot overwrite a newer roster. +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; From c04e71b0ef29365509d1a3dcf0e8cf5389a1326d Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 04:31:55 +0800 Subject: [PATCH 46/64] test: cover queued authority and malformed usage --- .../adapter-registry-authority.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 73af4dd543f..c9357b2ac0b 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -804,6 +804,23 @@ describe("TRAE CN ACP regression contract", () => { .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("shutdown remains bounded when the process and its pipe never close", async () => { const fixture = traeFixture(request => request.method === "session/prompt"); fixture.child.kill = () => true; @@ -1388,6 +1405,30 @@ describe("TRAE CN ACP regression contract", () => { 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: [] }; From 150b5589fd985251993a605c8d68eac355e2f4f5 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 05:26:12 +0800 Subject: [PATCH 47/64] fix: surface trailing TRAE reader failures --- src/adapters/trae-cn/adapter.ts | 3 ++- src/adapters/trae-cn/session.ts | 15 +++++++++++++-- structure/providers/trae-cn.md | 3 ++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 0985f809ef2..d572e1f3d57 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -126,7 +126,8 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda } catch (error) { if (incoming.abortSignal?.aborted) { emitCancelledUsage(); return; } if (error instanceof SendBudgetExhaustedError || error instanceof SpendLedgerFileRefusedError) throw error; - emit({ ...errorEvent(error), ...(terminal?.usage ? { usage: terminal.usage } : {}) }); + 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/session.ts b/src/adapters/trae-cn/session.ts index 6d2050d4ed7..da275a140b7 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -4,7 +4,7 @@ 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 } from "../../types"; +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"; @@ -25,7 +25,7 @@ export interface TraeAcpDeps { } export class TraeAcpError extends Error { - constructor(message: string, readonly code = "trae_acp_error") { + constructor(message: string, readonly code = "trae_acp_error", readonly usage?: OcxUsage) { super(message); this.name = "TraeAcpError"; } @@ -233,6 +233,9 @@ class TraeAcpClient { 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; @@ -399,6 +402,14 @@ export async function openTraeSession( } 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", diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index c66141a6051..8598ecdbbfb 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -18,7 +18,8 @@ normal `end_turn` completion without text fails with `trae_empty_response`. An o 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. The CLI uses +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. From 2de2bddb52601e4f2e8b2e88919d35f87bac22ec Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 05:27:21 +0800 Subject: [PATCH 48/64] test: cover queued authority and trailing failures --- .../adapter-registry-authority.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index c9357b2ac0b..8a058ebf4c9 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -821,6 +821,39 @@ describe("TRAE CN ACP regression contract", () => { .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("shutdown remains bounded when the process and its pipe never close", async () => { const fixture = traeFixture(request => request.method === "session/prompt"); fixture.child.kill = () => true; From 0e61e1f17eba6b0d9ea3b14b301e82e4db9d3e06 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 05:46:11 +0800 Subject: [PATCH 49/64] fix: preserve usage through ACP EOF wrapping --- src/adapters/trae-cn/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index da275a140b7..836eff56714 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -132,7 +132,7 @@ class TraeAcpClient { if (!this.initialized && this.launchStatus) { return startupFailure("spawn", { code: this.launchStatus === 127 ? "ENOENT" : "EACCES" }); } - return new TraeAcpError(this.diagnostic() || error.message, error.code); + return new TraeAcpError(this.diagnostic() || error.message, error.code, error.usage); } /** Wake every waiter immediately; process reaping is separate from request settlement. */ From 2ad779eab772877536f8e76efac56c46e10012b1 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 05:47:47 +0800 Subject: [PATCH 50/64] test: cover usage-bearing ACP EOF --- .../adapter-registry-authority.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 8a058ebf4c9..5b9366ad859 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -854,6 +854,25 @@ describe("TRAE CN ACP regression contract", () => { }); } + 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; From 4b9182002c0c30514968e590a2047ecfefbf9304 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 06:08:16 +0800 Subject: [PATCH 51/64] fix: retain prompt usage across cleanup errors --- src/adapters/trae-cn/adapter.ts | 42 ++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index d572e1f3d57..2ae8fca6976 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -104,22 +104,36 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda if (decision && !decision.allowed) throw new SendBudgetExhaustedError(); permit = decision?.allowed ? decision.permit : undefined; const session = await openTraeSession(provider.baseUrl, deps, incoming.abortSignal, provider); + let promptFailure: 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 }); - }, - }); + 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; + throw error; + } } finally { - await session.close(); + try { + await session.close(); + } catch (error) { + const usage = promptFailure instanceof TraeAcpError ? promptFailure.usage : undefined; + if (error instanceof TraeAcpError && usage && !error.usage) { + throw new TraeAcpError(error.message, error.code, usage); + } + throw error; + } } if (incoming.abortSignal?.aborted) emitCancelledUsage(); else if (terminal) emit(terminal); From e286e748b786c742de119d0c705a379da2817640 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 06:10:29 +0800 Subject: [PATCH 52/64] test: cover reader failure cleanup timeout --- .../adapter-registry-authority.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 5b9366ad859..9d4b485a12c 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -902,6 +902,26 @@ describe("TRAE CN ACP regression contract", () => { 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 }); + }); + 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 => { From 5e3e9a50c5d6536063860475d82347a927652898 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 06:32:43 +0800 Subject: [PATCH 53/64] fix: preserve primary TRAE failures through cleanup --- src/adapters/trae-cn/adapter.ts | 59 ++++++++++++++++----------------- src/adapters/trae-cn/session.ts | 9 ++++- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 2ae8fca6976..2bf0812fc12 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -91,12 +91,14 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda 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 = () => { - if (!terminal?.usage || cancelledUsageEmitted) return; + const usage = terminal?.usage ?? (promptFailure instanceof TraeAcpError ? promptFailure.usage : undefined); + if (!usage || cancelledUsageEmitted) return; cancelledUsageEmitted = true; - emit({ type: "incomplete", reason: "client_cancelled", usage: terminal.usage, retryable: false }); + emit({ type: "incomplete", reason: "client_cancelled", usage, retryable: false }); }; try { const replay = replayText(parsed); @@ -104,37 +106,34 @@ export function createTraeCnAdapter(provider: OcxProviderConfig, deps: TraeCnAda if (decision && !decision.allowed) throw new SendBudgetExhaustedError(); permit = decision?.allowed ? decision.permit : undefined; const session = await openTraeSession(provider.baseUrl, deps, incoming.abortSignal, provider); - let promptFailure: unknown; + let cleanupFailure: unknown; try { - 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; - throw error; - } - } finally { - try { - await session.close(); - } catch (error) { - const usage = promptFailure instanceof TraeAcpError ? promptFailure.usage : undefined; - if (error instanceof TraeAcpError && usage && !error.usage) { - throw new TraeAcpError(error.message, error.code, usage); - } - throw error; + 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) { diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 836eff56714..14d1bbcca24 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -442,7 +442,14 @@ export async function openTraeSession( emit({ type: "done", stopReason: stop, ...(usage ? { usage } : {}) }); } catch (error) { if (error instanceof TraeAcpError && error.code === "trae_acp_closed") { - await client.close(); + 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; From 7581f2de1a53e7ce1d0034af1705dabce53b5b55 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 06:34:09 +0800 Subject: [PATCH 54/64] test: cover cancellation and cleanup precedence --- .../adapter-registry-authority.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 9d4b485a12c..62c2da4ddd1 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -361,6 +361,23 @@ describe("TRAE CN ACP regression contract", () => { }); } + 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=" }, @@ -922,6 +939,33 @@ describe("TRAE CN ACP regression contract", () => { .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 => { From f9856287203f2fcd4ba911248c6389181a55b546 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 06:55:08 +0800 Subject: [PATCH 55/64] fix: bind buffered combo error usage --- src/server/responses/run-turn-execution.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 830b00f74a5..732aa4526f5 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -1103,10 +1103,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, From 58bf3fd82be3a0bee4a40c568c082c06a058ebb2 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 06:56:44 +0800 Subject: [PATCH 56/64] test: cover buffered combo error usage --- ...-combo-run-turn-classified-failure.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 3e8a3c3ba76..aa8b9056592 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -232,6 +232,29 @@ test("TRAE text-only turns do not resolve an OpenAI helper", async () => { } 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"); From f8bc59875fc72d73aded7b1f849e9954341344f0 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 07:36:17 +0800 Subject: [PATCH 57/64] fix: preserve policy snapshots and media carriers --- src/adapters/openai-chat/messages.ts | 7 ++++++- src/server/responses/policy-fallback.ts | 3 ++- structure/providers/chat-compat.md | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index aefe2ad629e..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" @@ -298,6 +299,10 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv 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. diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index b44313e69ea..d6326639cb2 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -224,7 +224,8 @@ export async function handleResponsesWithPolicyFallback( const restored = restoreChatInstructionMedia( chatToResponsesBody(options.chatReplayOriginalInboundBody), options.chatReplayOriginalInboundBody, ); - rawBody = { ...body, input: restored.input, instructions: restored.instructions }; + rawBody = { ...structuredClone(body as Record), + input: restored.input, instructions: restored.instructions }; } else rawBody = structuredClone(body as Record); } }, diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 8857389da15..6344c36c992 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -34,8 +34,8 @@ role rule as text-only instructions, so pinned templates receive a chronological `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 use the same user -carrier rule. Contiguous text and attachment groups retain their source order, so interleaved instructions and recordings +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. From 6755882af106752ce3f78db9c6cb27e430bd7a00 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 07:37:47 +0800 Subject: [PATCH 58/64] test: cover policy snapshots and rich carriers --- tests/adapters/openai/openai-chat-audio.test.ts | 8 +++++--- .../responses-combo-run-turn-classified-failure.test.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/adapters/openai/openai-chat-audio.test.ts b/tests/adapters/openai/openai-chat-audio.test.ts index 7fcbee7e401..786fccf4ca7 100644 --- a/tests/adapters/openai/openai-chat-audio.test.ts +++ b/tests/adapters/openai/openai-chat-audio.test.ts @@ -139,8 +139,8 @@ test("a default-role developer image uses a user carrier after its instruction t expect(messages).toEqual([ { role: "system", content: "Base" }, { role: "user", content: "First" }, - { role: "system", content: [{ type: "text", text: "Inspect this image" }] }, { 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" }, @@ -154,8 +154,10 @@ test("a default-role developer document uses a user carrier after its instructio ] }] }); const messages = JSON.parse(createOpenAIChatAdapter(provider).buildRequest(parsed).body).messages; expect(messages).toEqual([ - { role: "system", content: [{ type: "text", text: "Read this document" }] }, - { role: "user", content: [{ type: "file", file: { filename: file.filename, file_data: file.file_data } }] }, + { role: "user", content: [ + { type: "text", text: "Read this document" }, + { type: "file", file: { filename: file.filename, file_data: file.file_data } }, + ] }, ]); }); 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 aa8b9056592..f4692f285e6 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -511,7 +511,7 @@ for (const media of ["image", "video"]) { 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 }; + 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: [ From 9d6649fa03d9fa93292aa9e1aa6950cadae03a76 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 08:00:30 +0800 Subject: [PATCH 59/64] fix: drain buffered TRAE replies before refusal --- src/adapters/trae-cn/session.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 14d1bbcca24..1c04998cd3b 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -390,6 +390,9 @@ export async function openTraeSession( } for (const event of promptEvents) { if (event.type === "error") { + if (!completionReceived) { + await new Promise(resolve => { setTimeout(resolve, 0); }); + } const usage = completionReceived ? traeUsage(receivedCompletion) : undefined; emit({ ...event, ...(usage ? { usage } : {}) }); client.write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } }); From 4df0db0b1d45ac43e6b7133db50d6f9f5453a214 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 08:01:54 +0800 Subject: [PATCH 60/64] test: cover tool-before-reply usage --- .../adapter-registry-authority.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 62c2da4ddd1..fb983937596 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -821,6 +821,25 @@ describe("TRAE CN ACP regression contract", () => { .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 => { From d3c294507627643d3181010080254d3c73f06a9c Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 08:38:23 +0800 Subject: [PATCH 61/64] fix: preserve TRAE cache age and batch usage --- src/adapters/trae-cn/session.ts | 8 +++++--- src/codex/catalog/trae-cn-models.ts | 4 +++- src/codex/model-cache.ts | 5 +++++ structure/catalog.md | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/adapters/trae-cn/session.ts b/src/adapters/trae-cn/session.ts index 1c04998cd3b..230f4e38a6c 100644 --- a/src/adapters/trae-cn/session.ts +++ b/src/adapters/trae-cn/session.ts @@ -371,6 +371,9 @@ export async function openTraeSession( 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(); @@ -380,6 +383,7 @@ export async function openTraeSession( 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, @@ -390,9 +394,7 @@ export async function openTraeSession( } for (const event of promptEvents) { if (event.type === "error") { - if (!completionReceived) { - await new Promise(resolve => { setTimeout(resolve, 0); }); - } + await drainBufferedCompletion(); const usage = completionReceived ? traeUsage(receivedCompletion) : undefined; emit({ ...event, ...(usage ? { usage } : {}) }); client.write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } }); diff --git a/src/codex/catalog/trae-cn-models.ts b/src/codex/catalog/trae-cn-models.ts index 6cd869f20d9..d5a127fa197 100644 --- a/src/codex/catalog/trae-cn-models.ts +++ b/src/codex/catalog/trae-cn-models.ts @@ -4,6 +4,7 @@ import { captureModelCacheGeneration, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, + getCachedFetchedAt, getPinnedCached, getStaleCached, isModelCacheGenerationCurrent, @@ -61,7 +62,8 @@ export async function refreshTraeCnCatalogSources( if (!current()) return; if (after && gathered.outcome.state === "authoritative") { if (observeModelCacheRevision(name) !== gathered.contentRevision) return; - const release = pinCachedModels(name, gathered.models, Date.now(), captureModelCacheGeneration(name)); + const release = pinCachedModels(name, gathered.models, getCachedFetchedAt(name) ?? Date.now(), + captureModelCacheGeneration(name)); if (!release) return; retained.push({ name, release, contentRevision: observeModelCacheRevision(name) }); } diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index 6b581be9ca5..5019feaea24 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -206,6 +206,11 @@ 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); diff --git a/structure/catalog.md b/structure/catalog.md index 478609338c2..276a9e65998 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,7 +22,7 @@ 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. 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). +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 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. From fc7cbd883ebcceba14617d263340f0921400a5df Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 08:40:01 +0800 Subject: [PATCH 62/64] test: cover cache age and malformed batch order --- .../adapter-registry-authority.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index fb983937596..6a6d0520eb8 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -857,6 +857,26 @@ describe("TRAE CN ACP regression contract", () => { .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: {}, @@ -1428,6 +1448,21 @@ describe("TRAE CN ACP regression contract", () => { 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(); From 9711eae1ff16efa4e0739df662a4b9b8f4ee00a9 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 09:27:44 +0800 Subject: [PATCH 63/64] fix: let combos bypass unavailable TRAE models --- src/adapters/trae-cn/adapter.ts | 3 ++- structure/providers/trae-cn.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/adapters/trae-cn/adapter.ts b/src/adapters/trae-cn/adapter.ts index 2bf0812fc12..c2bcb27caf1 100644 --- a/src/adapters/trae-cn/adapter.ts +++ b/src/adapters/trae-cn/adapter.ts @@ -58,6 +58,7 @@ function validateInput(parsed: OcxParsedRequest): { message: string; code: strin 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"; @@ -67,7 +68,7 @@ function errorEvent(error: unknown): Extract { 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" : code, + code: auth ? "trae_cn_not_logged_in" : clientCode, retryable: false, }; } diff --git a/structure/providers/trae-cn.md b/structure/providers/trae-cn.md index 8598ecdbbfb..6b89f537f1a 100644 --- a/structure/providers/trae-cn.md +++ b/structure/providers/trae-cn.md @@ -63,7 +63,9 @@ Received usage is preserved even when cleanup fails. A failed workspace allocati 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 and keep their existing routing policy. +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. From 6c5157335691b0d86c3791bf844eadef4a554cbe Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 12 Nov 2026 09:28:43 +0800 Subject: [PATCH 64/64] test: cover TRAE fallback and media owners --- .../adapter-registry-authority.test.ts | 2 +- tests/helpers/responses-core-source.ts | 1 + .../chat-inline-document-bytes.test.ts | 4 +-- ...-combo-run-turn-classified-failure.test.ts | 27 +++++++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index 6a6d0520eb8..50daa2b2342 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -598,7 +598,7 @@ describe("TRAE CN ACP regression contract", () => { 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: "trae_model_unavailable", status: 400 }); + 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 }); }); diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 541517462ce..e7dd1601a6c 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -33,6 +33,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 f4692f285e6..6762d0bba15 100644 --- a/tests/responses/responses-combo-run-turn-classified-failure.test.ts +++ b/tests/responses/responses-combo-run-turn-classified-failure.test.ts @@ -790,6 +790,33 @@ for (const stream of [false, true]) { } 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 () => {