From 888fbf4a4986d3a8bf41b2100963efca3114b23f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:35:18 +0900 Subject: [PATCH 1/9] fix(codex): separate proxy v2 guidance from native mode (cherry picked from commit 481edbbd8a853c4155db9c40f76e432f9001b2d2) (cherry picked from commit 24977adf223210dbf68cfb5d626916f97b47ed9a) --- .../docs/ja/reference/configuration/agents.md | 6 +- .../docs/ko/reference/configuration/agents.md | 6 +- .../docs/reference/configuration/agents.md | 22 ++- .../docs/ru/reference/configuration/agents.md | 28 ++-- .../zh-cn/reference/configuration/agents.md | 6 +- src/server/responses/collaboration.ts | 35 +++-- src/types/config.ts | 2 +- .../multi-agent-compat.test.ts | 125 +++++++++++++++--- 8 files changed, 181 insertions(+), 49 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 2b185b81c4..964d729d73 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -28,13 +28,15 @@ description: マルチエージェント サーフェス、委任ガイダンス ## ロスターとガイダンス -有効な v2 ロスターは、v2 と互換性があり、挿入されたカタログに存在する、構成され、ピッカーに表示され、優先順位で並べ替えられた最初の 5 つのモデルです。 V2 の適格性は、明示的な `"v2"`、`null`、または欠落しているアップストリーム ピンを適格なものとして扱います。実際の `"v1"` ピンは除外されます。除外されたエントリは設定に残るため、後で適格になる可能性があります。 +有効な v2 ロスターは、設定済みでピッカーに表示され、優先順位で並べ替えられた最初の 5 つのモデルのうち、挿入されたカタログに存在し、明示的に `"disabled"` とされていないモデルです。明示的な `"v2"` ピンは再帰的なワーカーをサポートし、`"v1"`、`null`、ピンの省略はリーフワーカーとして引き続き適格です。除外されたエントリは設定に残るため、後で適格になる可能性があります。 表面検出はツール形状を使用します。 `send_input`、`resume_agent`、または `close_agent` を持つ名前空間付き `spawn_agent` は v1 です。 `send_message`、`followup_task`、`interrupt_agent`、または `list_agents` を備えたフラット `spawn_agent` は v2 です。 V1 ガイダンスは、`max` または `ultra` でのみプロアクティブ テキストです。 V2 は、優先モデル、適格なロスター、またはフォールバック チェーンが存在する場合にのみ、プロキシ作成の開発者メッセージを受信します。組み込みの v2 ガイダンスには 700 文字のバジェットがあり、必要に応じて最初にロスターが削除されます。ガイダンスはリプレイ プレフィックス全体で重複排除され、後続の `compaction_trigger` の前に挿入されます。 -`injectionModel` および `injectionEffort` は、ネイティブデフォルト同期が有効になっていない限り、推奨事項です。組み込みの v2 テキストは、サポートされているモデル/エフォートのオーバーライドを `fork_turns: "none"` を使用して `spawn_agent` に渡すように Codex に要求します。カスタム `injectionPrompt` は、欠落している値を空の文字列に置き換えます。 +組み込みの v2 サブエージェントガイダンスとカスタム `injectionPrompt` 本文は、どちらも `` を使用し、Codex ネイティブの `` メッセージとは区別されます。組み込みテキストは、解決済みの優先モデル、ロスター、フォールバックチェーンを示しますが、委任、モデルのオーバーライド、`fork_turns` は指示しません。カスタム本文のプレースホルダー置換と内容は維持されます。`injectionModel` および `injectionEffort` は、ネイティブデフォルト同期が有効になっていない限り推奨事項であり、カスタムプレースホルダーの欠落値は引き続き空の文字列に置き換えられます。 + +リプレイの重複排除では、タグの種類ごとに最新のテキストとの完全一致を確認します。両方の値が新しいプロキシのタグを使用する場合、カスタムガイダンスから組み込み形式へ戻すと、その時点の内容が追加されます。途中でネイティブモードが変わっても、変更のないプロキシガイダンスは重複追加されません。既存のネイティブメッセージと旧タグ付きの履歴は保持されます。ラッパーの変更によって過去のメッセージの作成者が判明したり、以前の指示が取り消されたりするわけではありません。複数バージョンが混在する履歴は、旧タグだけでは分類できず、そのような履歴での設定変更の検出は保証されません。 ## ネイティブ Codex のデフォルト同期 diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 1c999536f2..104393d28a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -28,13 +28,15 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 ## 로스터와 안내 -실제 v2 로스터는 설정되어 있고, 선택기에 보이며, 우선순위로 정렬된 상위 다섯 모델 중 v2와 호환되고 주입된 카탈로그에 존재하는 모델입니다. v2 적격성은 명시적인 `"v2"`, `null`, 또는 생략된 상위 고정값을 적격으로 보고, 실제 `"v1"` 고정값은 제외합니다. 제외된 항목은 나중에 적격이 될 수 있도록 설정에 그대로 남습니다. +실제 v2 로스터는 설정되어 있고, 선택기에 보이며, 우선순위로 정렬된 상위 다섯 모델 중 주입된 카탈로그에 존재하고 명시적으로 `"disabled"`로 표시되지 않은 모델입니다. 명시적인 `"v2"` 고정값은 재귀 작업자를 지원하며, `"v1"`, `null`, 생략된 고정값도 하위 작업을 다시 위임하지 않는 작업자로 참여할 수 있습니다. 제외된 항목은 나중에 적격이 될 수 있도록 설정에 그대로 남습니다. 표면 판별은 도구 형태를 기준으로 합니다. 네임스페이스가 붙은 `spawn_agent`에 `send_input`, `resume_agent`, `close_agent`가 있으면 v1입니다. 평평한 `spawn_agent`에 `send_message`, `followup_task`, `interrupt_agent`, `list_agents`가 있으면 v2입니다. V1 안내는 `max` 또는 `ultra`에서만 선제 텍스트로 제공됩니다. V2는 선호 모델, 적격 로스터, 대체 체인 중 하나가 있을 때만 프록시가 작성한 개발자 메시지를 받습니다. 내장 v2 안내에는 700자 예산이 있고, 필요하면 로스터를 먼저 줄입니다. 안내는 replay prefix 전반에서 중복 제거되며, 뒤에 오는 `compaction_trigger` 앞에 삽입됩니다. -`injectionModel`과 `injectionEffort`는 네이티브 기본값 동기화가 활성화되지 않으면 권고 수준입니다. 내장 v2 텍스트는 Codex에게 지원되는 모델/노력 오버라이드를 `fork_turns: "none"`과 함께 `spawn_agent`로 전달하라고 요청합니다. 사용자 지정 `injectionPrompt`는 누락된 값을 빈 문자열로 대체합니다. +내장 v2 서브에이전트 안내와 사용자 지정 `injectionPrompt` 본문은 모두 ``를 사용하며, Codex 네이티브 `` 메시지와 구분됩니다. 내장 텍스트는 결정된 선호 모델, 모델 목록, 대체 체인을 알리지만 위임, 모델 오버라이드, `fork_turns`를 지시하지는 않습니다. 사용자 지정 본문의 자리표시자 치환과 내용은 유지됩니다. `injectionModel`과 `injectionEffort`는 네이티브 기본값 동기화가 활성화되지 않으면 계속 권고 수준이며, 사용자 지정 자리표시자의 누락된 값은 빈 문자열로 대체됩니다. + +replay 중복 제거는 각 태그 계열의 가장 최근 텍스트와 정확히 일치하는지 비교합니다. 두 값 모두 새 프록시 태그 계열을 사용하는 경우, 사용자 지정 안내에서 내장 형식으로 돌아오면 현재 안내가 추가됩니다. 그 사이에 네이티브 모드가 바뀌어도 변경되지 않은 프록시 안내가 중복 추가되지는 않습니다. 기존 네이티브 메시지와 예전 태그가 붙은 이력은 보존됩니다. 래퍼 변경으로 과거 메시지의 작성자가 판별되거나 이전 지침이 철회되는 것은 아닙니다. 여러 버전이 섞인 이력은 예전 태그만으로 분류할 수 없으며, 이러한 이력에서 설정 전환이 감지된다고 보장하지 않습니다. ## Codex 기본값 동기화 diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index f3caea02f2..57e9ab7d8e 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -75,9 +75,9 @@ loudly when the installed Codex build does not know the flag yet. ## Roster and guidance The effective v2 roster is the configured, picker-visible, priority-sorted first five models that -are compatible with v2 and present in the injected catalog. V2 eligibility treats an explicit `"v2"`, -`null`, or absent upstream pin as eligible; a real `"v1"` pin is excluded. Excluded entries remain in -configuration so they can become eligible later. +are present in the injected catalog and are not explicitly marked `"disabled"`. An explicit `"v2"` +pin supports recursive workers; `"v1"`, `null`, and absent pins remain eligible as leaf workers. +Excluded entries remain in configuration so they can become eligible later. Surface detection uses tool shape. A namespaced `spawn_agent` with `send_input`, `resume_agent`, or `close_agent` is v1. A flat `spawn_agent` with `send_message`, `followup_task`, `interrupt_agent`, or @@ -88,9 +88,19 @@ message only when a preferred model, eligible roster, or fallback chain exists. has a 700-character budget and drops the roster first if necessary. Guidance is deduplicated across replay prefixes and inserted before a trailing `compaction_trigger`. -`injectionModel` and `injectionEffort` are advisory unless native-default sync is enabled. The built-in -v2 text asks Codex to pass supported model/effort overrides to `spawn_agent` with -`fork_turns: "none"`. A custom `injectionPrompt` substitutes missing values with an empty string. +Both built-in v2 subagent guidance and custom `injectionPrompt` bodies use +``, separate from Codex's native `` messages. +Built-in text reports the resolved preferred model, roster, and fallback chain without prescribing +delegation, model overrides, or `fork_turns`. Custom bodies retain their placeholder substitution +and content. `injectionModel` and `injectionEffort` remain advisory unless native-default sync is +enabled; missing custom placeholder values are still replaced with an empty string. + +Replay deduplication compares the latest exact text in each tag family. When both values use the +new proxy family, switching custom guidance back to the built-in form appends the current value; +intervening native mode changes do not duplicate unchanged proxy guidance. Existing native and +legacy-tagged history is preserved. This wrapper change does not identify the author of old +messages or revoke prior instructions. Mixed-version histories cannot be classified from the +legacy tag alone, and transition detection across such histories is not guaranteed. ## Native Codex default sync diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index a3a0bc434b..b33ac85841 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -36,10 +36,10 @@ custom prompt на этом API передаётся полем `prompt`. ## Roster и guidance Эффективный ростер v2 — это настроенные, видимые в picker'е, отсортированные по priority первые -пять моделей, совместимых с v2 и присутствующих во внедряемом каталоге. Для v2 запись считается -допустимой, если upstream pin равен `"v2"`, `null` либо вовсе отсутствует; реальный pin `"v1"` -исключает модель. Исключённые записи всё равно остаются в конфигурации, чтобы позже снова стать -допустимыми. +пять моделей, присутствующих во внедряемом каталоге и не отмеченных явно как `"disabled"`. +Явный pin `"v2"` поддерживает рекурсивных подагентов; `"v1"`, `null` и отсутствующий pin +остаются допустимыми для подагентов без дальнейшего делегирования. Исключённые записи остаются +в конфигурации, чтобы позже снова стать допустимыми. Определение surface основано на форме tool'ов. Namespaced `spawn_agent` вместе с `send_input`, `resume_agent` или `close_agent` — это v1. Плоский `spawn_agent` вместе с `send_message`, @@ -51,10 +51,22 @@ roster или fallback chain. Встроенное guidance v2 ограниче сначала удаляет roster. Guidance дедуплицируется по replay-prefix и вставляется перед завершающим `compaction_trigger`. -`injectionModel` и `injectionEffort` носят рекомендательный характер, если только не включён -native-default sync. Встроенный текст v2 просит Codex передавать поддерживаемые override'ы model -и effort в `spawn_agent` с `fork_turns: "none"`. В custom `injectionPrompt` отсутствующие значения -подставляются как пустая строка. +И встроенные указания v2 для подагентов, и пользовательские тела `injectionPrompt` используют +``, отдельно от нативных сообщений Codex ``. +Встроенный текст сообщает итоговую предпочтительную модель, список моделей и цепочку резервных +моделей, но не предписывает делегирование, переопределение модели или `fork_turns`. Подстановка +значений в плейсхолдеры и содержимое пользовательских тел сохраняются. `injectionModel` и +`injectionEffort` остаются рекомендациями, если не включена синхронизация нативных значений по +умолчанию; отсутствующие значения пользовательских плейсхолдеров заменяются пустой строкой. + +Дедупликация replay проверяет точное совпадение с последним текстом в каждой группе тегов. +Если оба значения используют новую группу тегов прокси, при возврате от пользовательских указаний +к встроенной форме добавляется её текущее содержимое; промежуточные изменения нативного режима +не дублируют неизменившиеся указания прокси. +Существующая история нативных сообщений и сообщений со старым тегом сохраняется. Изменение +обёртки не устанавливает автора старых сообщений и не отменяет прежние инструкции; историю +со смешанными версиями нельзя классифицировать только по старому тегу, и обнаружение переходов +в такой истории не гарантируется. ## Синхронизация native default'ов Codex diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 1c8d71eeca..ec67917fd0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -28,13 +28,15 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 ## 名单与引导 -有效的 v2 名单,是已配置、在选择器中可见、按优先级排序的前五个模型中,和 v2 兼容且存在于注入目录中的那些模型。v2 资格判定会把显式的 `"v2"`、`null`,或缺失的上游固定值视为可用;真正的 `"v1"` 固定值会被排除。被排除的条目仍会保留在配置中,以便将来重新变为可用。 +有效的 v2 名单,是已配置、在选择器中可见、按优先级排序的前五个模型中,存在于注入目录且未明确标记为 `"disabled"` 的模型。显式的 `"v2"` 标记支持递归子代理;`"v1"`、`null` 和缺失的标记仍可作为叶子子代理。被排除的条目仍会保留在配置中,以便将来重新变为可用。 界面检测使用工具形状来判断。带命名空间的 `spawn_agent`,如果具有 `send_input`、`resume_agent` 或 `close_agent`,就是 v1。平铺的 `spawn_agent`,如果具有 `send_message`、`followup_task`、`interrupt_agent` 或 `list_agents`,就是 v2。 V1 引导只会在 `max` 或 `ultra` 时以主动文本形式出现。V2 只有在存在首选模型、可用名单或回退链时,才会收到代理生成的开发者消息。内置 v2 引导有 700 个字符的预算,必要时会先删减名单。引导会在 replay prefix 之间去重,并插入到末尾的 `compaction_trigger` 之前。 -除非启用了原生默认值同步,`injectionModel` 和 `injectionEffort` 都只是建议。内置 v2 文本会要求 Codex 使用 `fork_turns: "none"` 将受支持的模型/effort 覆盖传给 `spawn_agent`。自定义 `injectionPrompt` 会把缺失值替换为空字符串。 +内置 v2 子代理引导和自定义 `injectionPrompt` 正文都使用 ``,与 Codex 原生的 `` 消息区分开来。内置文本会说明解析后的首选模型、名单和回退链,但不会指示委派、模型覆盖或 `fork_turns`。自定义正文的占位符替换和内容保持不变。除非启用了原生默认值同步,`injectionModel` 和 `injectionEffort` 仍只是建议;自定义占位符的缺失值仍替换为空字符串。 + +replay 去重会分别与每类标签的最新文本进行精确比较。当两个值都使用新的代理标签时,从自定义引导切回内置形式会追加当前的引导内容;期间原生模式的变化不会导致未改变的代理引导被重复添加。现有的原生消息历史和带旧标签的历史都会保留。更换包装标签并不能确定旧消息的作者,也不会撤销先前的指令;对于混合版本的历史,不能仅凭旧标签进行分类,也不保证检测到这类历史中的设置切换。 ## Codex 原生默认值同步 diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 8d8ce42e7f..e01a79cea8 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -241,6 +241,9 @@ export const PROACTIVE_MULTI_AGENT_MODE_TEXT = [ "This mode remains active until a later multi-agent mode developer message changes it.", ].join(" "); +const OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG = ""; +const OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG = ""; + export function isV1CollabSurface(parsed: OcxParsedRequest): boolean { return collabSurface(parsed) === "v1"; } @@ -468,18 +471,15 @@ export async function multiAgentGuidanceText( // fallback only for explicit routed/account-qualified ids. const promptModel = preferred?.model ?? (injectionModel?.includes("/") ? injectionModel : undefined); - return `${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}`; + return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`; } if (!preferred && roster === "" && fallbackGuidance === "") return null; - let text = "When the active spawn_agent tool supports optional \"model\" or \"reasoning_effort\" overrides, " - + "use only models listed for this collaboration surface. " - + "When setting either override, set fork_turns to \"none\" " - + "(or a positive turn count such as \"3\"; full-history forks reject overrides) " - + "and make the task message self-contained."; + let text = "OpenCodex sub-agent routing metadata for this collaboration surface. " + + "This metadata does not override Codex delegation or model-selection rules."; if (preferred) { text += ` Preferred sub-agent: model "${preferred.model}"` + (injectionEffort ? `, reasoning_effort "${injectionEffort}"` : "") - + " — use it unless the user names another."; + + "."; } text += fallbackGuidance; text += roster; @@ -487,7 +487,7 @@ export async function multiAgentGuidanceText( // Roster is the only unbounded part — drop it before breaking the budget. text = text.slice(0, text.length - roster.length); } - return `${text}`; + return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${text}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`; } const effort = parsed.options.reasoning; @@ -544,6 +544,17 @@ function isGeneratedDeveloperItem(item: unknown, text: string): boolean { return generatedDeveloperText(item) === text; } +function generatedGuidanceFamily(text: string): "multi_agent_mode" | "opencodex_subagent_guidance" | undefined { + if (text.startsWith("") && text.endsWith("")) { + return "multi_agent_mode"; + } + if (text.startsWith(OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG) + && text.endsWith(OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG)) { + return "opencodex_subagent_guidance"; + } + return undefined; +} + function isDeveloperPrefixItem(item: unknown): boolean { if (!isRecord(item)) return false; if (item.type === "additional_tools") return item.role === "developer"; @@ -583,13 +594,13 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] }; if (rawInput) { const replayPrefix = rawInput.slice(0, replayPrefixLen); - const taggedGuidance = text.startsWith("") && text.endsWith(""); - const lastTaggedGuidance = taggedGuidance + const guidanceFamily = generatedGuidanceFamily(text); + const lastTaggedGuidance = guidanceFamily ? replayPrefix.map(generatedDeveloperText) - .filter(item => item?.startsWith("") && item.endsWith("")) + .filter(item => item !== undefined && generatedGuidanceFamily(item) === guidanceFamily) .at(-1) : undefined; - if (taggedGuidance ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) { + if (guidanceFamily ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) { return; } } diff --git a/src/types/config.ts b/src/types/config.ts index 017d01a94a..0399ee7be2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -513,7 +513,7 @@ export interface OcxConfig { streamMode?: "auto" | "legacy-tee" | "eager-relay"; /** * Custom override for the injected v2 multi-agent guidance body (the text inside - * the tags). After guidance is enabled and the v2 surface and + * the tags). After guidance is enabled and the v2 surface and * catalog-state gates pass, a configured injectionModel is sufficient to render it; * otherwise an eligible roster or fallback is required. Placeholders: `{{model}}` -> the * effective preferred model for the request (a bare native model is account-qualified diff --git a/tests/codex-integration/multi-agent-compat.test.ts b/tests/codex-integration/multi-agent-compat.test.ts index 9430ab657a..6b3d1374b2 100644 --- a/tests/codex-integration/multi-agent-compat.test.ts +++ b/tests/codex-integration/multi-agent-compat.test.ts @@ -267,7 +267,7 @@ describe("multiAgentGuidanceText", () => { } }); - test("v2 built-in guidance is schema-agnostic and keeps fork rules", async () => { + test("v2 built-in guidance reports routing metadata without replacing native delegation rules", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [{ slug: "anthropic/claude-sonnet-5", @@ -279,10 +279,13 @@ describe("multiAgentGuidanceText", () => { { injectionModel: "anthropic/claude-sonnet-5" }, ); - expect(text).toContain("When the active spawn_agent tool supports optional"); - expect(text).toContain("use only models listed for this collaboration surface"); - expect(text).toContain("fork_turns"); - expect(text).toContain('"none"'); + expect(text).toStartWith(""); + expect(text).toEndWith(""); + expect(text).toContain("OpenCodex sub-agent routing metadata"); + expect(text).toContain("does not override Codex delegation or model-selection rules"); + expect(text).not.toContain("fork_turns"); + expect(text).not.toContain("use it unless"); + expect(text).not.toContain(""); expect(text).not.toMatch(/hidden/i); expect(text).not.toMatch(/not in the schema/i); expect(text).not.toMatch(/never claim/i); @@ -384,7 +387,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(custom).toBe('Use team/gpt-5.6-sol.'); + expect(custom).toBe('Use team/gpt-5.6-sol.'); const exactBare = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), @@ -403,7 +406,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(exactBareCustom).toBe("Use local-fast."); + expect(exactBareCustom).toBe("Use local-fast."); const bareParent = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), @@ -449,7 +452,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(ambiguousCustom).toBe("Use ."); + expect(ambiguousCustom).toBe("Use ."); expect(ambiguousCustom).not.toContain("gpt-5.6-sol"); }); @@ -508,7 +511,7 @@ describe("multiAgentGuidanceText", () => { injectionModel: "gpt-5.6-sol", injectionPrompt: "Use {{model}}.", }, - )).toBe("Use ."); + )).toBe("Use ."); }); test("effective roster applies alias, visibility, v2 compatibility, stable priority, cap, and diagnostics", async () => { @@ -612,7 +615,7 @@ describe("multiAgentGuidanceText", () => { { injectionModel: "anthropic/claude-sonnet-5" }, ); expect(text).toContain('"anthropic/claude-sonnet-5"'); - expect(text).toContain("fork_turns"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toContain("Proactive multi-agent delegation is active"); // and WITHOUT an injectionModel it stays silent (codex-rs owns the v2 Proactive text) expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "ultra", tools: nativeV2 }))).toBeNull(); @@ -654,7 +657,7 @@ describe("multiAgentGuidanceText", () => { injectionEffort: "xhigh", subagentModels: ["gpt-5.6-terra"], }); - expect(text).toContain("When the active spawn_agent tool supports optional"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toMatch(/hidden|not in the schema|never claim/i); expect(text).toContain('(reasoning_effort high/max/ultra): "gpt-5.6-terra"'); }); @@ -738,8 +741,8 @@ describe("multiAgentGuidanceText", () => { // gpt-5.6-luna carries upstream's "v1" pin, which is now an eligible LEAF worker // (codex-rs 6d4d9442c), so it joins the substituted roster. expect(text).toBe( - 'CUSTOM model=raw/preferred-model effort=max' - + ' Available models (reasoning_effort high/max): "gpt-5.6-terra", "gpt-5.6-luna".', + 'CUSTOM model=raw/preferred-model effort=max' + + ' Available models (reasoning_effort high/max): "gpt-5.6-terra", "gpt-5.6-luna".', ); }); @@ -778,14 +781,14 @@ describe("multiAgentGuidanceText", () => { expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "medium", tools: v2Tools }))).toBeNull(); }); - test("v2 surface + roster alone (no injectionModel) fires with the argument-acceptance preamble", async () => { + test("v2 surface + roster alone (no injectionModel) reports routing metadata", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [{ slug: "gpt-5.6-terra", efforts: ["high", "max", "ultra"] }]); const text = await multiAgentGuidanceText( parsedFixture({ reasoning: "medium", tools: [{ name: "spawn_agent" }] }), { subagentModels: ["gpt-5.6-terra"] }, ); - expect(text).toContain("When the active spawn_agent tool supports optional"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toMatch(/hidden|not in the schema|never claim/i); expect(text).toContain('(reasoning_effort high/max/ultra): "gpt-5.6-terra"'); expect(text).not.toContain("Preferred sub-agent"); @@ -861,7 +864,7 @@ describe("multiAgentGuidanceText", () => { subagentModels: ["gpt-5.5", "opencode-go/glm-5.2", "anthropic/claude-opus-4-6", "gpt-5.6-sol", "gpt-5.6-terra"], }, ); - const body = text!.replace(/^/, "").replace(/<\/multi_agent_mode>$/, ""); + const body = text!.replace(/^/, "").replace(/<\/opencodex_subagent_guidance>$/, ""); expect(body.length).toBeLessThanOrEqual(700); expect(body).toContain("Available models"); // roster fits inside the budget }); @@ -1182,6 +1185,96 @@ describe("injectDeveloperMessage", () => { expect((replay._rawBody as { input: unknown[] }).input.at(-1)).toEqual(generatedItem(guidanceA)); }); + test("proxy guidance dedup records a metadata A-B-A transition", () => { + const metadataA = "A"; + const metadataB = "B"; + const current = { type: "message", role: "user", content: "current turn" }; + const rawInput = [generatedItem(metadataA), generatedItem(metadataB), current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_1" }); + parsed._replayPrefixLen = 2; + parsed._continuationConversationMessageIndex = 2; + + injectDeveloperMessage(parsed, metadataA); + + expect(rawInput).toEqual([generatedItem(metadataA), generatedItem(metadataB), generatedItem(metadataA), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([metadataA, metadataB, metadataA, "current turn"]); + }); + + test("proxy guidance dedup preserves intervening native mode changes", () => { + const nativeA = "Native policy A"; + const nativeB = "Native policy B"; + const metadata = "Routing metadata"; + const rawInput = [generatedItem(nativeA), generatedItem(metadata), generatedItem(nativeB), { role: "user", content: "work" }]; + const before = structuredClone(rawInput); + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = 3; + + injectDeveloperMessage(parsed, metadata); + + expect(rawInput).toEqual(before); + expect(parsed.context.messages.map(message => message.content)).toEqual([nativeA, metadata, nativeB, "work"]); + }); + + test("native mode dedup ignores later proxy guidance", () => { + const native = "Native policy"; + const metadata = "Routing metadata"; + const rawInput = [generatedItem(native), generatedItem(metadata), { role: "user", content: "work" }]; + const before = structuredClone(rawInput); + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = 2; + + injectDeveloperMessage(parsed, native); + + expect(rawInput).toEqual(before); + expect(countExact(rawInput, native)).toBe(1); + }); + + test("restores default v2 guidance after a custom prompt without changing the custom body", async () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [{ slug: "gpt-5.6-terra", efforts: ["high", "max"], multiAgentVersion: "v2" }]); + const fixture = parsedFixture({ tools: [{ name: "spawn_agent" }] }); + const options = { injectionModel: "gpt-5.6-terra", injectionEffort: "high" }; + const metadata = await multiAgentGuidanceText(fixture, options); + const custom = await multiAgentGuidanceText(fixture, { + ...options, + injectionPrompt: "Custom {{model}} effort={{effort}}\nKeep {{unknown}}.", + }); + expect(metadata).not.toBeNull(); + const current = { type: "message", role: "user", content: "current turn" }; + const rawInput = [generatedItem(metadata!), generatedItem(custom!), current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_1" }); + parsed._replayPrefixLen = 2; + parsed._continuationConversationMessageIndex = 2; + + injectDeveloperMessage(parsed, (await multiAgentGuidanceText(fixture, options))!); + + expect(rawInput).toEqual([generatedItem(metadata!), generatedItem(custom!), generatedItem(metadata!), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([metadata, custom, metadata, "current turn"]); + expect(custom).toBe("Custom gpt-5.6-terra effort=high\nKeep {{unknown}}."); + }); + + const legacyBuiltIn = 'When the active spawn_agent tool supports optional "model" or "reasoning_effort" overrides, ' + + 'use only models listed for this collaboration surface. When setting either override, set fork_turns to "none" ' + + '(or a positive turn count such as "3"; full-history forks reject overrides) and make the task message self-contained.' + + ' Preferred sub-agent: model "gpt-5.6-terra", reasoning_effort "high" — use it unless the user names another.'; + test.each([ + ["built-in", legacyBuiltIn], + ["custom", "Operator-authored legacy prompt."], + ])("preserves legacy %s and native policy when first injecting new proxy guidance", (_kind, legacy) => { + const native = "Native delegation policy"; + const metadata = "Routing metadata"; + const current = { type: "message", role: "user", content: "work" }; + const prefix = [generatedItem(legacy), generatedItem(native)]; + const rawInput = [...prefix, current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = prefix.length; + + injectDeveloperMessage(parsed, metadata); + + expect(rawInput).toEqual([...prefix, generatedItem(metadata), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([legacy, native, metadata, "work"]); + }); + test("exact-guidance predicate rejects every near-match replay-prefix shape (#326)", () => { const nearMatches: Array<[string, unknown]> = [ ["non-record item", null], From 74a60168d2b2aff8c584c28d9cbc515811e6e656 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:05:41 +0900 Subject: [PATCH 2/9] docs(codex): describe injection effort as advisory metadata (cherry picked from commit 6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a) (cherry picked from commit 21757b71a6007d217ef1f383c739cfc6618fd8e9) --- src/types/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/config.ts b/src/types/config.ts index 0399ee7be2..0b0a2b2b98 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -469,8 +469,8 @@ export interface OcxConfig { */ syncCodexSubagentDefaults?: boolean; /** - * Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls - * (`reasoning_effort` argument). Only meaningful while `injectionModel` is set; validated against + * Optional reasoning effort reported as advisory metadata in v2 sub-agent guidance. + * It does not prescribe spawn overrides. Only meaningful while `injectionModel` is set; validated against * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary. */ injectionEffort?: string; From 694c51991ab7e01dd9e483ac54dfeb04ff2703af Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 11:16:40 +0900 Subject: [PATCH 3/9] docs(codex): record proxy guidance policy boundary Document the carried v2 wrapper and replay contract while preserving native and legacy policy history. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> (cherry picked from commit 8000e2482fb06567ac3d3e3474c54c5a4468d92f) --- structure/03_catalog-and-subagents.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index b64cb4bce1..8ff92157ad 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -465,6 +465,19 @@ cause delegation. The TOML edit owns only marker-tagged values, preserves existi user-owned `[agents]` defaults rather than overwriting them, and rejects ambiguous table shapes without changing the file. +V2 proxy guidance uses `` for both built-in metadata and +custom `injectionPrompt` bodies. The built-in text reports the resolved preferred model, +effort, roster and fallback chain without prescribing delegation, spawn overrides or +`fork_turns`. Custom bodies retain their placeholder behavior. The guidance switch and +catalog-state gates still apply; stale or unknown catalog state suppresses proxy guidance. +V1 retains its `` proactive text at `max` or `ultra`. + +Replay deduplication compares the latest exact generated developer text separately for +each tag family, preserving built-in → custom → built-in transitions without duplicating +unchanged proxy metadata after a native policy change. Native and legacy-tagged history +remain intact: tags do not establish historical authorship or revoke old instructions, +and mixed-version transition detection is not guaranteed. + Claude Code `ocx-*` agent definitions consume the same effective `claudeCode.blockedSkills` policy as inbound bundle elision. When the list is non-empty (default: `claude-api`), generated definitions whose marker-stripped model resolves to a routed id receive a preventive instruction not to invoke From 3ceef0121712b290c3d4443e9fc3f0a04cecead6 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 11:49:28 +0900 Subject: [PATCH 4/9] docs(devlog): revalidate V2 guidance stack layer --- devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md b/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md index 2e5219c1a4..37da50117a 100644 --- a/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md +++ b/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md @@ -47,3 +47,7 @@ Remote-only focused activation: `bun test tests/codex-integration/multi-agent-co Main decision: preserve the complete original diff. The optional extra server caller fixture is deferred unless source audit reveals an untested change; do not duplicate the existing replay matrix merely for volume. Sync structure/03_catalog-and-subagents.md to the new tag and policy boundary. + +## wp3 P refresh + +Previous wp2 D: PR3991 head00eb47886 passed run34180674115, source audit and remote docs425pages; proceed guidance carry. Prepared layer3 consists of24977adf2,21757b71a,8000e2482, based on d1f61e933. Intervening wp2 changes affect xAI adapter/tests, provider/adapters docs and structure04; none overlap the9layer3 files. Original #3944 remains open at6fb0fc6f. Independent prepared-source/security audit PASS in isolated v2GuidanceReviewer.md; actual adoption requires unchanged-delta/interdiff verification and own hostedCI. From 97b8dab5e4359bb4d472b290ee2b35196041a83a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:51:19 +0900 Subject: [PATCH 5/9] fix(subagents): use server-owned proactive delegation recommendations (cherry picked from commit db502d486c8d8de80c0143fcfd6f86238d1ce917) (cherry picked from commit 5b60a4fa70aa5ef34ba043065961d5ea0042b780) --- .../docs/ja/reference/configuration/agents.md | 8 + .../docs/ko/reference/configuration/agents.md | 8 + .../docs/reference/configuration/agents.md | 25 ++- .../docs/ru/reference/configuration/agents.md | 8 + .../zh-cn/reference/configuration/agents.md | 8 + .../SubagentDelegationSection.tsx | 21 ++- gui/src/i18n/de.ts | 10 +- gui/src/i18n/en.ts | 10 +- gui/src/i18n/fr.ts | 10 +- gui/src/i18n/ja.ts | 10 +- gui/src/i18n/ko.ts | 10 +- gui/src/i18n/ru.ts | 10 +- gui/src/i18n/tr.ts | 10 +- gui/src/i18n/zh-TW.ts | 10 +- gui/src/i18n/zh.ts | 10 +- gui/src/pages/Subagents.tsx | 27 ++- gui/src/pages/use-subagent-delegation.ts | 6 + gui/tests/multi-agent-guidance.test.tsx | 31 ++- gui/tests/subagents-ultra-mode.test.tsx | 177 ++++++++++++++++-- src/codex/features.ts | 4 +- src/codex/multi-agent-mode-policy.ts | 24 +++ .../management/agent-settings-routes.ts | 3 + tests/codex-integration/codex-v2-gate.test.ts | 53 ++++++ 23 files changed, 406 insertions(+), 87 deletions(-) create mode 100644 src/codex/multi-agent-mode-policy.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 964d729d73..6dc3f39162 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -26,6 +26,14 @@ description: マルチエージェント サーフェス、委任ガイダンス 管理 API は、`GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models`、および `/api/subagent-model-fallback` を公開します。インジェクションモデルの更新は部分的です。カスタム プロンプトは、その API の `prompt` フィールドです。 +## 常時プロアクティブ委任 + +Subagents → 詳細設定の **常時プロアクティブ委任**(旧 **Ultra mode**)は、推論のエフォートを変更せず、委任を開始する条件だけを変更します。推奨プリセットでも、ユーザーの指示、権限の境界、タスクの範囲、ツールの規則は維持されます。 + +`GET` と `PUT /api/v2` は、追加で `multiAgentModeHintRecommendation: { text, revision }` を返します。ダッシュボードはプリセットの有効化と復元にサーバー提供のテキストを使い、固定文へのフォールバックは行いません。旧サーバーが推奨値を返さない場合や値の形式が不正な場合、プリセットの適用と復元は利用できませんが、既存のカスタムヒントの編集と削除は引き続き利用できます。プリセットの復元はローカルの下書きだけを変更し、保存操作で確定します。 + +設定の読み取り、無関係な更新、アップグレードでは、保存済みのヒントは移行されません。ヒントを明示的に更新し、その本文が既知の旧 OpenCodex プリセット2種類のいずれかとバイト単位で完全一致する場合に限り、現在の推奨文に置き換わります。それ以外の有効なカスタムテキストは、空白だけが異なるものも含め、バイト単位で保持されます。既存の v2 有効化、機能サポート確認、ヒント削除の規則は変わらず、変更は新しい Codex セッションに適用されます。 + ## ロスターとガイダンス 有効な v2 ロスターは、設定済みでピッカーに表示され、優先順位で並べ替えられた最初の 5 つのモデルのうち、挿入されたカタログに存在し、明示的に `"disabled"` とされていないモデルです。明示的な `"v2"` ピンは再帰的なワーカーをサポートし、`"v1"`、`null`、ピンの省略はリーフワーカーとして引き続き適格です。除外されたエントリは設定に残るため、後で適格になる可能性があります。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 104393d28a..611989e71c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -26,6 +26,14 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 관리 API는 `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`를 제공합니다. injection-model 업데이트는 부분 업데이트입니다. 사용자 지정 프롬프트는 이 API의 `prompt` 필드입니다. +## 항상 능동 위임 + +서브에이전트 → 고급의 **항상 능동 위임**(이전 이름: **울트라 모드**)은 추론 노력을 바꾸지 않고 위임을 시작하는 조건만 변경합니다. 권장 프리셋에서도 사용자 지침, 권한 경계, 작업 범위, 도구 규칙은 유지됩니다. + +`GET`과 `PUT /api/v2`는 `multiAgentModeHintRecommendation: { text, revision }`도 반환합니다. 대시보드는 프리셋을 켜거나 복원할 때 서버가 제공한 텍스트를 사용하며, 고정된 대체 문구는 사용하지 않습니다. 이전 서버가 권장값을 제공하지 않거나 잘못된 형식으로 반환하면 프리셋 적용·복원은 사용할 수 없으며, 기존 사용자 지정 힌트의 편집·삭제는 계속 사용할 수 있습니다. **프리셋 복원**은 로컬 초안만 바꾸고, **저장**을 눌러야 저장됩니다. + +설정 조회, 관련 없는 업데이트, 업그레이드는 저장된 힌트를 자동 변경하지 않습니다. 힌트를 명시적으로 업데이트할 때 본문이 알려진 두 가지 이전 OpenCodex 프리셋 중 하나와 바이트 단위로 정확히 일치하는 경우에만 현재 권장 문구로 바뀝니다. 그 밖의 유효한 사용자 지정 텍스트는 공백 차이가 있는 변형까지 바이트 단위로 보존됩니다. 기존 v2 활성화·기능 지원 확인·힌트 해제 규칙은 그대로이며, 변경은 새 Codex 세션에 적용됩니다. + ## 로스터와 안내 실제 v2 로스터는 설정되어 있고, 선택기에 보이며, 우선순위로 정렬된 상위 다섯 모델 중 주입된 카탈로그에 존재하고 명시적으로 `"disabled"`로 표시되지 않은 모델입니다. 명시적인 `"v2"` 고정값은 재귀 작업자를 지원하며, `"v1"`, `null`, 생략된 고정값도 하위 작업을 다시 위임하지 않는 작업자로 참여할 수 있습니다. 제외된 항목은 나중에 적격이 될 수 있도록 설정에 그대로 남습니다. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 57e9ab7d8e..5a4ccf650b 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -45,20 +45,33 @@ Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT / `config.json` key; `ocx v2 threads ` writes `max_concurrent_threads_per_session` under `[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled. -**Ultra mode** (the Subagents dashboard toggle, `PUT /api/v2` field -`multiAgentModeHintText`, and `ocx v2 mode-hint`) writes +**Always proactive delegation** in Subagents → Advanced (formerly **Ultra mode**) changes the +delegation trigger without changing reasoning effort. Its preset preserves user instructions, +authority boundaries, task scope, and tool rules. The dashboard toggle, `PUT /api/v2` field +`multiAgentModeHintText`, and `ocx v2 mode-hint` write `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex's `$CODEX_HOME/config.toml`. The CLI `ocx v2 mode-hint` command persists this key even -when `multi_agent_v2` is disabled; it does not toggle the feature. The hint overrides -codex-rs's effort-derived multi-agent policy, so any model and any reasoning effort -receives the Proactive delegation prompt; it does **not** change reasoning effort. +when `multi_agent_v2` is disabled; it does not toggle the feature. The hint replaces +codex-rs's effort-derived multi-agent policy when that native surface is active. A `null` value removes the key so the effort-derived policy (ultra = proactive, otherwise explicit) resumes; empty or whitespace-only values are rejected because a present empty override would suppress even the ultra-derived Proactive message. The -Subagents dashboard's Ultra mode **on** toggle requires both the native feature and +Subagents dashboard's **Always proactive delegation** toggle requires both the native feature and an explicit v2 surface (`multiAgentMode: "v2"`, equivalent to `ocx v2 mode v2`); `ocx v2 on` alone does not satisfy that dashboard gate. +`GET` and `PUT /api/v2` also return `multiAgentModeHintRecommendation: { text, revision }`. +The dashboard uses this server-provided text when enabling or restoring the preset, with no +hardcoded fallback. If an older server omits the recommendation or returns a malformed value, +preset installation and restoration are unavailable; editing or clearing an existing custom hint +remains available. **Restore preset** changes only the local draft; **Save** persists it. + +Reading settings, unrelated updates, and upgrades do not migrate a stored hint. Only an explicit +hint update that matches either of the two recognized legacy OpenCodex presets byte-for-byte is +replaced with the current recommendation. Other valid custom text, including whitespace variants, +is preserved byte-for-byte. Mode-hint support is still checked before writing, and changes apply +to new Codex sessions. + The management API exposes `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models`, and `/api/subagent-model-fallback`. Injection-model updates are partial; the custom prompt is the `prompt` field on that API. diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index b33ac85841..6dcf3be84b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -33,6 +33,14 @@ Management API предоставляет `GET`/`PUT /api/v2`, `/api/injection-m `/api/subagent-models` и `/api/subagent-model-fallback`. Обновления injection-model частичные; custom prompt на этом API передаётся полем `prompt`. +## Всегда проактивное делегирование + +**Всегда проактивное делегирование** в Subagents → Дополнительно (прежнее название — **Ultra mode**) меняет только условие запуска делегирования, не меняя reasoning effort. Рекомендуемый preset сохраняет инструкции пользователя, границы полномочий, область задачи и правила работы с инструментами. + +`GET` и `PUT /api/v2` дополнительно возвращают `multiAgentModeHintRecommendation: { text, revision }`. При включении или восстановлении preset дашборд использует текст сервера без встроенного запасного варианта. Если старый сервер не возвращает рекомендацию или возвращает некорректное значение, применение и восстановление preset недоступны; существующий custom hint по-прежнему можно редактировать или удалять. Восстановление preset меняет только локальный черновик; действие сохранения записывает его. + +Чтение настроек, несвязанные изменения и обновление версии не мигрируют сохранённый hint. Только явное обновление hint, текст которого побайтово совпадает с одним из двух известных старых preset OpenCodex, заменяет его текущей рекомендацией. Остальной допустимый custom text, включая варианты с отличиями в пробельных символах, сохраняется побайтово. Существующие правила включения v2, проверки поддержки и удаления hint не меняются; изменения применяются к новым сессиям Codex. + ## Roster и guidance Эффективный ростер v2 — это настроенные, видимые в picker'е, отсортированные по priority первые diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index ec67917fd0..8bb438cd20 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -26,6 +26,14 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 管理 API 公开 `GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models` 和 `/api/subagent-model-fallback`。injection-model 更新是部分更新;自定义 prompt 是该 API 上的 `prompt` 字段。 +## 始终主动委派 + +Subagents → 高级中的 **始终主动委派**(原名 **Ultra mode**)只改变触发委派的条件,不改变推理 effort。推荐预设仍遵循用户指令、权限边界、任务范围和工具规则。 + +`GET` 和 `PUT /api/v2` 还会返回 `multiAgentModeHintRecommendation: { text, revision }`。仪表板在启用或恢复预设时使用服务器提供的文本,不会回退到硬编码文案。如果旧服务器没有提供推荐值,或返回的值格式无效,则无法应用或恢复预设;仍可编辑或清除现有的自定义提示。恢复预设只修改本地草稿,保存操作才会将其写入配置。 + +读取设置、无关更新和版本升级不会迁移已保存的提示。只有显式更新提示,且正文与两种已知旧版 OpenCodex 预设之一逐字节完全一致时,才会替换为当前推荐文本。其他有效的自定义文本,包括仅空白字符不同的变体,都会逐字节保留。现有的 v2 启用、功能支持检查和清除提示规则保持不变;更改会应用于新的 Codex 会话。 + ## 名单与引导 有效的 v2 名单,是已配置、在选择器中可见、按优先级排序的前五个模型中,存在于注入目录且未明确标记为 `"disabled"` 的模型。显式的 `"v2"` 标记支持递归子代理;`"v1"`、`null` 和缺失的标记仍可作为叶子子代理。被排除的条目仍会保留在配置中,以便将来重新变为可用。 diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index a975786a94..19108e04cd 100644 --- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx @@ -299,10 +299,15 @@ export default function SubagentDelegationSection({ @@ -385,7 +390,3 @@ function UltraModeEditor({ ); } - -/** Canonical Proactive delegation text mirrored from codex-rs (multi_agent_mode_instructions.rs). */ -export const ULTRA_MODE_PRESET = - "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it."; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b7e62e0ea8..40ee6ea36f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -680,14 +680,14 @@ export const de: Record = { "sub.workspace.selectModel": "Modell auswählen", "sub.workspace.selectModelDesc": "Wählen Sie ein Modell aus der Liste, um Details anzuzeigen und es für spawn_agent hervorzuheben.", "sub.workspace.selector": "Öffentlicher Selektor", - "sub.ultraMode": "Ultra-Modus", + "sub.ultraMode": "Immer proaktiv delegieren", "sub.ultraModeHint": "Aktiviert die proaktive Multi-Agent-Delegierungsrichtlinie für alle Modelle und Reasoning-Efforts (ändert den Reasoning-Effort selbst nicht). Schreibt features.multi_agent_v2.multi_agent_mode_hint_text in config.toml.", "sub.ultraModeV2Required": "Erfordert die v2-Multi-Agent-Oberfläche — aktivieren Sie zuerst multi_agent_v2 und wählen Sie v2 in der Subagentenmodus-Steuerung.", - "sub.ultraModeText": "Delegierungstext des Ultra-Modus", + "sub.ultraModeText": "Text für proaktive Delegierung", "sub.ultraModePreset": "Voreinstellung wiederherstellen", - "sub.ultraModeLoadFail": "Ultra-Modus-Einstellungen konnten nicht geladen werden — läuft der Proxy?", - "sub.ultraModeSaveFail": "Ultra-Modus-Einstellungen konnten nicht gespeichert werden", - "sub.ultraModeSaved": "Ultra-Modus gespeichert. Gilt für neue Codex-Sitzungen.", + "sub.ultraModeLoadFail": "Einstellungen für proaktive Delegierung konnten nicht geladen werden — läuft der Proxy?", + "sub.ultraModeSaveFail": "Einstellungen für proaktive Delegierung konnten nicht gespeichert werden", + "sub.ultraModeSaved": "Proaktive Delegierung gespeichert. Gilt für neue Codex-Sitzungen.", "logs.title": "Anfrage-Protokolle", "logs.tabLogs": "Protokolle", "logs.tabDebug": "Diagnose", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index fdb91fad32..53d940d7e6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -709,14 +709,14 @@ export const en = { "sub.workspace.mainAria": "Subagent model details", "sub.workspace.notFeatured": "Not featured", "sub.workspace.priority": "Priority", - "sub.ultraMode": "Ultra mode", + "sub.ultraMode": "Always proactive delegation", "sub.ultraModeHint": "Enable the Proactive multi-agent delegation policy for every model and reasoning effort (does not change reasoning effort itself). Writes features.multi_agent_v2.multi_agent_mode_hint_text in config.toml.", "sub.ultraModeV2Required": "Requires the v2 multi-agent surface — enable multi_agent_v2 and select v2 in the Sub-agent mode control first.", - "sub.ultraModeText": "Ultra mode delegation text", + "sub.ultraModeText": "Proactive delegation text", "sub.ultraModePreset": "Restore preset", - "sub.ultraModeLoadFail": "Failed to load Ultra mode settings — is the proxy running?", - "sub.ultraModeSaveFail": "Failed to save Ultra mode settings", - "sub.ultraModeSaved": "Ultra mode saved. Applies to new Codex sessions.", + "sub.ultraModeLoadFail": "Failed to load proactive delegation settings — is the proxy running?", + "sub.ultraModeSaveFail": "Failed to save proactive delegation settings", + "sub.ultraModeSaved": "Proactive delegation saved. Applies to new Codex sessions.", "sub.workspace.removeFromFeatured": "Remove {m} from featured", "sub.workspace.selectModel": "Select a model", "sub.workspace.selectModelDesc": "Pick a model from the list to see details and feature it for spawn_agent.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4354e6fd56..7afb44ab2c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -692,14 +692,14 @@ export const fr: Record = { "sub.workspace.mainAria": "Détails du modèle de sous-agent", "sub.workspace.notFeatured": "Non mis à la une", "sub.workspace.priority": "Priorité", - "sub.ultraMode": "Mode Ultra", + "sub.ultraMode": "Délégation toujours proactive", "sub.ultraModeHint": "Activer la politique de délégation multi-agent proactive pour tous les modèles et niveaux de raisonnement (sans modifier le niveau de raisonnement lui-même). Écrit features.multi_agent_v2.multi_agent_mode_hint_text dans config.toml.", "sub.ultraModeV2Required": "Nécessite l’interface multi-agent v2 — activez multi_agent_v2 et sélectionnez d’abord v2 dans le contrôle du mode Sous-agent.", - "sub.ultraModeText": "Texte de délégation du mode Ultra", + "sub.ultraModeText": "Texte de délégation proactive", "sub.ultraModePreset": "Rétablir le préréglage", - "sub.ultraModeLoadFail": "Échec du chargement des paramètres du mode Ultra — le proxy est-il en cours d’exécution ?", - "sub.ultraModeSaveFail": "Échec de l’enregistrement des paramètres du mode Ultra", - "sub.ultraModeSaved": "Mode Ultra enregistré. S’applique aux nouvelles sessions Codex.", + "sub.ultraModeLoadFail": "Échec du chargement des paramètres de délégation proactive — le proxy est-il en cours d’exécution ?", + "sub.ultraModeSaveFail": "Échec de l’enregistrement des paramètres de délégation proactive", + "sub.ultraModeSaved": "Délégation proactive enregistrée. S’applique aux nouvelles sessions Codex.", "sub.workspace.removeFromFeatured": "Retirer {m} de la sélection À la une", "sub.workspace.selectModel": "Sélectionner un modèle", "sub.workspace.selectModelDesc": "Choisissez un modèle dans la liste pour afficher ses détails et le mettre à la une pour spawn_agent.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index dfbab83390..75095da332 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -640,14 +640,14 @@ export const ja: Record = { "sub.workspace.selectModel": "モデルを選択", "sub.workspace.selectModelDesc": "一覧からモデルを選んで詳細を確認し、spawn_agent のおすすめに設定します。", "sub.workspace.selector": "公開セレクター", - "sub.ultraMode": "ウルトラモード", + "sub.ultraMode": "常時プロアクティブ委任", "sub.ultraModeHint": "すべてのモデルと reasoning effort で Proactive マルチエージェント委任ポリシーを有効にします(reasoning effort 自体は変更しません)。config.toml に features.multi_agent_v2.multi_agent_mode_hint_text を書き込みます。", "sub.ultraModeV2Required": "v2 マルチエージェントサーフェスが必要です — 先に multi_agent_v2 を有効にし、サブエージェントモードで v2 を選択してください。", - "sub.ultraModeText": "ウルトラモード委任テキスト", + "sub.ultraModeText": "プロアクティブ委任テキスト", "sub.ultraModePreset": "プリセットを復元", - "sub.ultraModeLoadFail": "ウルトラモード設定を読み込めませんでした — プロキシは実行中ですか?", - "sub.ultraModeSaveFail": "ウルトラモード設定の保存に失敗しました", - "sub.ultraModeSaved": "ウルトラモードを保存しました。新しい Codex セッションから適用されます。", + "sub.ultraModeLoadFail": "プロアクティブ委任設定を読み込めませんでした — プロキシは実行中ですか?", + "sub.ultraModeSaveFail": "プロアクティブ委任設定の保存に失敗しました", + "sub.ultraModeSaved": "プロアクティブ委任設定を保存しました。新しい Codex セッションから適用されます。", // logs "logs.title": "リクエストログ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 19855bdd33..5ae812e9e8 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -697,14 +697,14 @@ export const ko: Record = { "sub.workspace.selectModel": "모델 선택", "sub.workspace.selectModelDesc": "목록에서 모델을 선택하여 세부 정보를 확인하고 spawn_agent에 추천하세요.", "sub.workspace.selector": "공개 셀렉터", - "sub.ultraMode": "울트라 모드", + "sub.ultraMode": "항상 능동 위임", "sub.ultraModeHint": "모든 모델과 reasoning effort에서 Proactive 멀티에이전트 위임 정책을 켭니다 (reasoning effort 자체는 변경하지 않음). config.toml에 features.multi_agent_v2.multi_agent_mode_hint_text를 기록합니다.", "sub.ultraModeV2Required": "v2 멀티에이전트 서피스가 필요합니다 — 먼저 multi_agent_v2를 켜고 서브에이전트 모드에서 v2를 선택하세요.", - "sub.ultraModeText": "울트라 모드 위임 텍스트", + "sub.ultraModeText": "능동 위임 텍스트", "sub.ultraModePreset": "프리셋 복원", - "sub.ultraModeLoadFail": "울트라 모드 설정을 불러오지 못했습니다 — 프록시가 실행 중인가요?", - "sub.ultraModeSaveFail": "울트라 모드 설정 저장에 실패했습니다", - "sub.ultraModeSaved": "울트라 모드가 저장되었습니다. 새 Codex 세션부터 적용됩니다.", + "sub.ultraModeLoadFail": "능동 위임 설정을 불러오지 못했습니다 — 프록시가 실행 중인가요?", + "sub.ultraModeSaveFail": "능동 위임 설정 저장에 실패했습니다", + "sub.ultraModeSaved": "능동 위임 설정이 저장되었습니다. 새 Codex 세션부터 적용됩니다.", "sub.fallbackLabel": "서브에이전트 폴백 체인", "sub.fallbackHint": "서브에이전트 모델을 사용할 수 없거나 실패할 때 순서대로 시도할 모델입니다.", "sub.fallbackAdd": "폴백 모델 추가…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 194d7aa72a..2900abb6f3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -695,14 +695,14 @@ export const ru: Record = { "sub.workspace.selectModel": "Выберите модель", "sub.workspace.selectModelDesc": "Выберите модель из списка, чтобы увидеть детали и добавить её в избранные для spawn_agent.", "sub.workspace.selector": "Публичный селектор", - "sub.ultraMode": "Ультра-режим", + "sub.ultraMode": "Всегда проактивное делегирование", "sub.ultraModeHint": "Включает политику упреждающего делегирования мультиагентов для всех моделей и уровней reasoning effort (сам reasoning effort не меняется). Записывает features.multi_agent_v2.multi_agent_mode_hint_text в config.toml.", "sub.ultraModeV2Required": "Требуется мультиагентная поверхность v2 — сначала включите multi_agent_v2 и выберите v2 в переключателе режима субагентов.", - "sub.ultraModeText": "Текст делегирования ультра-режима", + "sub.ultraModeText": "Текст проактивного делегирования", "sub.ultraModePreset": "Восстановить пресет", - "sub.ultraModeLoadFail": "Не удалось загрузить настройки ультра-режима — работает ли прокси?", - "sub.ultraModeSaveFail": "Не удалось сохранить настройки ультра-режима", - "sub.ultraModeSaved": "Ультра-режим сохранён. Применяется к новым сеансам Codex.", + "sub.ultraModeLoadFail": "Не удалось загрузить настройки проактивного делегирования — работает ли прокси?", + "sub.ultraModeSaveFail": "Не удалось сохранить настройки проактивного делегирования", + "sub.ultraModeSaved": "Проактивное делегирование сохранено. Применяется к новым сеансам Codex.", // logs "logs.title": "Журнал запросов", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index aa97b22ff2..f551a97d43 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -702,14 +702,14 @@ export const tr: Record = { "sub.workspace.selectModel": "Bir model seçin", "sub.workspace.selectModelDesc": "Detayları görmek için listeden bir model seçin.", "sub.workspace.selector": "Genel seçici", - "sub.ultraMode": "Ultra modu", + "sub.ultraMode": "Her zaman proaktif delegasyon", "sub.ultraModeHint": "Tüm modeller ve reasoning effort için Proactive çoklu ajan delegasyon politikasını etkinleştirir (reasoning effort değerini değiştirmez). config.toml dosyasına features.multi_agent_v2.multi_agent_mode_hint_text yazar.", "sub.ultraModeV2Required": "v2 çoklu ajan yüzeyi gerekir — önce multi_agent_v2'yi etkinleştirin ve alt ajan modu denetiminde v2'yi seçin.", - "sub.ultraModeText": "Ultra modu delegasyon metni", + "sub.ultraModeText": "Proaktif delegasyon metni", "sub.ultraModePreset": "Ön ayarı geri yükle", - "sub.ultraModeLoadFail": "Ultra modu ayarları yüklenemedi — proxy çalışıyor mu?", - "sub.ultraModeSaveFail": "Ultra modu ayarları kaydedilemedi", - "sub.ultraModeSaved": "Ultra modu kaydedildi. Yeni Codex oturumlarına uygulanır.", + "sub.ultraModeLoadFail": "Proaktif delegasyon ayarları yüklenemedi — proxy çalışıyor mu?", + "sub.ultraModeSaveFail": "Proaktif delegasyon ayarları kaydedilemedi", + "sub.ultraModeSaved": "Proaktif delegasyon kaydedildi. Yeni Codex oturumlarına uygulanır.", "sub.fallbackLabel": "Alt ajan yedek zinciri", "sub.fallbackHint": "Alt ajan modeli kullanılamadığında veya başarısız olduğunda sırayla denenecek modeller.", "sub.fallbackAdd": "Yedek model ekle…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index db9829821d..ae0ee0517b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -547,14 +547,14 @@ export const zhTW: Record = { "sub.moveUp": "上移 {m}", "sub.moveDown": "下移 {m}", "sub.removeAria": "移除 {m}", - "sub.ultraMode": "超級模式", + "sub.ultraMode": "始終主動委派", "sub.ultraModeHint": "為所有模型和推理力度啟用主動多代理委派策略(不改變推理力度本身)。將 features.multi_agent_v2.multi_agent_mode_hint_text 寫入 config.toml。", "sub.ultraModeV2Required": "需要 v2 多代理表面 — 請先啟用 multi_agent_v2,並在子代理模式控制項中選擇 v2。", - "sub.ultraModeText": "超級模式委派文字", + "sub.ultraModeText": "主動委派文字", "sub.ultraModePreset": "還原預設", - "sub.ultraModeLoadFail": "無法載入超級模式設定 — 代理是否在執行?", - "sub.ultraModeSaveFail": "儲存超級模式設定失敗", - "sub.ultraModeSaved": "超級模式已儲存。適用於新的 Codex 會話。", + "sub.ultraModeLoadFail": "無法載入主動委派設定 — 代理是否在執行?", + "sub.ultraModeSaveFail": "儲存主動委派設定失敗", + "sub.ultraModeSaved": "主動委派設定已儲存。適用於新的 Codex 會話。", "logs.title": "請求日誌", "logs.tabLogs": "日誌", "logs.tabDebug": "除錯", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a13ff07973..ca92aaeafc 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -690,14 +690,14 @@ export const zh: Record = { "sub.workspace.selectModel": "选择模型", "sub.workspace.selectModelDesc": "从列表中选择一个模型以查看详情,并将其设为 spawn_agent 的精选模型。", "sub.workspace.selector": "公开选择器", - "sub.ultraMode": "超级模式", + "sub.ultraMode": "始终主动委派", "sub.ultraModeHint": "为所有模型和推理力度启用主动多代理委派策略(不改变推理力度本身)。将 features.multi_agent_v2.multi_agent_mode_hint_text 写入 config.toml。", "sub.ultraModeV2Required": "需要 v2 多代理表面 — 请先启用 multi_agent_v2,并在子代理模式控件中选择 v2。", - "sub.ultraModeText": "超级模式委派文本", + "sub.ultraModeText": "主动委派文本", "sub.ultraModePreset": "恢复预设", - "sub.ultraModeLoadFail": "无法加载超级模式设置 — 代理是否在运行?", - "sub.ultraModeSaveFail": "保存超级模式设置失败", - "sub.ultraModeSaved": "超级模式已保存。适用于新的 Codex 会话。", + "sub.ultraModeLoadFail": "无法加载主动委派设置 — 代理是否在运行?", + "sub.ultraModeSaveFail": "保存主动委派设置失败", + "sub.ultraModeSaved": "主动委派设置已保存。适用于新的 Codex 会话。", // logs "logs.title": "请求日志", diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index 0b9806066d..94a8df7086 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -10,6 +10,11 @@ import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from type CachedSubagents = { available: string[]; chosen: string[]; fallback?: string[]; pollMs?: number; fallbackAvailable?: string[] }; +const UNLOADED_ULTRA_MODE: UltraModeState = { + enabled: false, hintText: null, recommendation: null, + multiAgentV2Enabled: false, multiAgentMode: "default", +}; + function seedSubagents(cacheKey: string): CachedSubagents | null { return readSessionListCache(cacheKey); } @@ -40,7 +45,9 @@ export default function Subagents({ apiBase }: { apiBase: string }) { /** Sync guard: state-only `busy` can miss clicks before the disabled re-render commits. */ const saveInFlight = useRef(false); const delegation = useSubagentDelegation(apiBase); - const [ultraMode, setUltraMode] = useState({ enabled: false, hintText: null, multiAgentV2Enabled: false, multiAgentMode: "default" }); + const [ultraState, setUltraState] = useState<{ apiBase: string; mode: UltraModeState } | null>(null); + const ultraModeCurrent = ultraState?.apiBase === apiBase; + const ultraMode = ultraModeCurrent ? ultraState.mode : UNLOADED_ULTRA_MODE; const [ultraSaving, setUltraSaving] = useState(false); const [ultraLoadFailed, setUltraLoadFailed] = useState(false); const ultraLoadGeneration = useRef(0); @@ -62,21 +69,31 @@ export default function Subagents({ apiBase }: { apiBase: string }) { multiAgentMode?: "v1" | "default" | "v2"; multiAgentModeHintText?: string | null; keepNativeChatGptOnV1?: boolean; + multiAgentModeHintRecommendation?: { text?: unknown; revision?: unknown }; }>(res, t("sub.ultraModeLoadFail")); if (!data) return false; if (signal?.aborted || generation !== ultraLoadGeneration.current || currentUltraApiBase.current !== apiBase) return false; setUltraLoadFailed(false); - setUltraMode({ + const rawRecommendation = data.multiAgentModeHintRecommendation; + const recommendation = rawRecommendation + && typeof rawRecommendation.text === "string" + && rawRecommendation.text.trim().length > 0 + && typeof rawRecommendation.revision === "string" + && rawRecommendation.revision.trim().length > 0 + ? { text: rawRecommendation.text, revision: rawRecommendation.revision } + : null; + setUltraState({ apiBase, mode: { enabled: data.enabled ?? false, loaded: true, keepNativeChatGptOnV1: data.keepNativeChatGptOnV1 === true, hintText: data.multiAgentModeHintText ?? null, + recommendation, // Ultra mode replaces Codex's effort-derived policy for every model. The // `default` surface still preserves upstream V1 pins (for example luna), // so only an explicitly forced V2 catalog is an effective surface here. multiAgentV2Enabled: data.enabled === true && data.multiAgentMode === "v2", multiAgentMode: data.multiAgentMode ?? "default", - }); + } }); return true; }, [apiBase, t]); @@ -95,7 +112,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { }, [loadUltraMode, t]); const saveUltraMode = async (patch: UltraModePatch) => { - if (ultraSaving) return; + if (ultraSaving || !ultraModeCurrent || currentUltraApiBase.current !== apiBase) return; const requestApiBase = apiBase; setUltraSaving(true); setStatus(""); @@ -343,7 +360,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { saving: delegation.saving, onSave: patch => { void delegation.save(patch); }, ultraMode, - ultraSaving, + ultraSaving: ultraSaving || !ultraModeCurrent, onUltraModeSave: patch => { void saveUltraMode(patch); }, ultraLoadFailed, onUltraModeRetry: () => { void retryUltraMode(); }, diff --git a/gui/src/pages/use-subagent-delegation.ts b/gui/src/pages/use-subagent-delegation.ts index 9baa5baa9a..1a7829dc51 100644 --- a/gui/src/pages/use-subagent-delegation.ts +++ b/gui/src/pages/use-subagent-delegation.ts @@ -20,11 +20,17 @@ export type DelegationPatch = { }; /** Ultra mode (Proactive delegation for every model/effort) via /api/v2. */ +export type UltraModeHintRecommendation = { + text: string; + revision: string; +}; + export type UltraModeState = { loaded?: boolean; keepNativeChatGptOnV1?: boolean; enabled: boolean; hintText: string | null; + recommendation: UltraModeHintRecommendation | null; multiAgentV2Enabled: boolean; /** The raw multi-agent mode; Subagents renders the v1/base/v2 switch from it. */ multiAgentMode: "v1" | "default" | "v2"; diff --git a/gui/tests/multi-agent-guidance.test.tsx b/gui/tests/multi-agent-guidance.test.tsx index 8907406f0e..a2f2af5259 100644 --- a/gui/tests/multi-agent-guidance.test.tsx +++ b/gui/tests/multi-agent-guidance.test.tsx @@ -65,7 +65,7 @@ function props(overrides: Partial = {}): Props { guidanceEnabled: false, syncCodexDefaults: true, onSave: (patch) => { requests.push(patch); }, - ultraMode: { enabled: false, hintText: null, multiAgentV2Enabled: false, multiAgentMode: "default" }, + ultraMode: { enabled: false, hintText: null, recommendation: null, multiAgentV2Enabled: false, multiAgentMode: "default" }, fallback: [], fallbackPollMs: 60000, fallbackBusy: false, @@ -150,3 +150,32 @@ test("sends model clearing through the shared save path", async () => { expect(requests).toEqual([{ model: null, effort: "high" }]); }); + +test("a recommendation-only refresh preserves the draft until Restore is chosen", async () => { + const current = props({ + ultraMode: { + enabled: true, hintText: "stored custom", multiAgentV2Enabled: true, multiAgentMode: "v2", + recommendation: { text: "old recommendation", revision: "old" }, + }, + onUltraModeSave: patch => { requests.push(patch); }, + }); + await mount(current); + const textarea = host.querySelector(".swi-ultra-mode-editor textarea")!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "unsaved custom draft"); + textarea.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + textarea.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + await act(async () => { + root!.render(); + }); + expect(textarea.value).toBe("unsaved custom draft"); + expect(requests).toHaveLength(0); + const restore = [...host.querySelectorAll(".swi-ultra-mode-editor button")] + .find(button => button.textContent?.trim() === "Restore preset")!; + await act(async () => { restore.click(); }); + expect(textarea.value).toBe("new recommendation"); + expect(requests).toHaveLength(0); +}); diff --git a/gui/tests/subagents-ultra-mode.test.tsx b/gui/tests/subagents-ultra-mode.test.tsx index 6c2c1d8719..22cfc459be 100644 --- a/gui/tests/subagents-ultra-mode.test.tsx +++ b/gui/tests/subagents-ultra-mode.test.tsx @@ -1,9 +1,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import type { Root } from "react-dom/client"; import Subagents from "../src/pages/Subagents"; -import { ULTRA_MODE_PRESET } from "../src/components/subagents-workspace/SubagentDelegationSection"; import { LanguageProvider } from "../src/i18n/provider"; const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; @@ -14,6 +13,7 @@ let root: Root | null = null; let v2Responses: Array<{ ok: boolean; body: unknown; status?: number }> = []; let v2Call = 0; let requests: Array<{ url: string; init?: RequestInit }> = []; +const recommendation = { text: "server-supplied proactive policy", revision: "test-policy-v1" }; function response(body: unknown, ok = true, status = 200): Response { return { @@ -75,6 +75,7 @@ afterEach(async () => { }); async function mount(apiBase = "") { + const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(container); root.render( @@ -88,13 +89,13 @@ async function mount(apiBase = "") { function ultraSwitch(): HTMLButtonElement { const button = Array.from(container.querySelectorAll("button")) - .find(candidate => candidate.getAttribute("aria-label") === "Ultra mode"); - if (!button) throw new Error("Ultra mode switch not found"); + .find(candidate => candidate.getAttribute("aria-label") === "Always proactive delegation"); + if (!button) throw new Error("Always proactive delegation switch not found"); return button as HTMLButtonElement; } test("does not enable Ultra mode for the default surface even when V2 is enabled", async () => { - v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "default", multiAgentModeHintText: null } }]; + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "default", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation } }]; await mount(); expect(ultraSwitch().disabled).toBe(true); @@ -104,13 +105,13 @@ test("does not enable Ultra mode for the default surface even when V2 is enabled test("clears the page load error after a successful Ultra mode retry", async () => { v2Responses = [ { ok: false, body: { error: "temporary failure" }, status: 503 }, - { ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null } }, + { ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation } }, ]; await mount(); - expect(container.textContent).toContain("Failed to load Ultra mode settings"); + expect(container.textContent).toContain("Failed to load proactive delegation settings"); const ultraErrorRow = Array.from(container.querySelectorAll(".swi-delegation-row")) - .find(row => row.textContent?.includes("Failed to load Ultra mode settings")); + .find(row => row.textContent?.includes("Failed to load proactive delegation settings")); const retry = ultraErrorRow?.querySelector("button"); expect(retry).toBeTruthy(); @@ -118,18 +119,156 @@ test("clears the page load error after a successful Ultra mode retry", async () await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); expect(v2Call).toBe(2); - expect(container.textContent).not.toContain("Failed to load Ultra mode settings"); + expect(container.textContent).not.toContain("Failed to load proactive delegation settings"); expect(ultraSwitch().disabled).toBe(false); }); -test("uses the complete canonical proactive delegation preset", () => { - expect(ULTRA_MODE_PRESET).toBe([ - "Proactive multi-agent delegation is active.", - "Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies.", - "Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently.", - "Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself.", - "This mode remains active until a later multi-agent mode developer message changes it.", - ].join(" ")); +test("enabling Ultra mode uses the server-supplied recommendation", async () => { + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation } }]; + await mount(); + + await act(async () => { ultraSwitch().click(); }); + + const request = requests.find(item => item.init?.method === "PUT" && new URL(item.url, "http://localhost/").pathname === "/api/v2"); + expect(JSON.parse(String(request?.init?.body))).toEqual({ multiAgentModeHintText: recommendation.text }); +}); + +test("an older server without a recommendation disables only preset installation", async () => { + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null } }]; + await mount(); + + expect(ultraSwitch().disabled).toBe(true); + expect(ultraSwitch().getAttribute("aria-pressed")).toBe("false"); +}); + +test.each([ + { text: "", revision: "r1" }, + { text: "valid", revision: " " }, + { text: 42, revision: "r1" }, +])("malformed server recommendations cannot install a preset: %j", async malformed => { + v2Responses = [{ ok: true, body: { + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, + multiAgentModeHintRecommendation: malformed, + } }]; + await mount(); + + expect(ultraSwitch().disabled).toBe(true); + await act(async () => { ultraSwitch().click(); }); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); +}); + +test("an older server preserves an existing custom hint and still allows clearing it", async () => { + v2Responses = [{ ok: true, body: { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: "custom policy" } }]; + await mount(); + + expect(ultraSwitch().disabled).toBe(false); + expect(ultraSwitch().getAttribute("aria-pressed")).toBe("true"); + await act(async () => { ultraSwitch().click(); }); + + const request = requests.find(item => item.init?.method === "PUT" && new URL(item.url, "http://localhost/").pathname === "/api/v2"); + expect(JSON.parse(String(request?.init?.body))).toEqual({ multiAgentModeHintText: null }); +}); + +test.each([undefined, { text: "", revision: "r1" }])("custom hints remain editable without a valid recommendation: %j", async unavailable => { + v2Responses = [{ ok: true, body: { + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: "custom policy", + multiAgentModeHintRecommendation: unavailable, + } }]; + await mount(); + const editor = container.querySelector(".swi-ultra-mode-editor")!; + const textarea = editor.querySelector("textarea")!; + const restore = [...editor.querySelectorAll("button")].find(button => button.textContent?.trim() === "Restore preset")!; + const save = [...editor.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + const custom = " my custom policy\nwith a preserved trailing space "; + expect(restore.disabled).toBe(true); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, custom); + textarea.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + textarea.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + await act(async () => { save.click(); }); + const puts = requests.filter(item => item.init?.method === "PUT"); + expect(puts).toHaveLength(1); + expect(JSON.parse(String(puts[0].init?.body))).toEqual({ multiAgentModeHintText: custom }); +}); + +test("a custom hint loads without writing and restore stays local until Save", async () => { + v2Responses = [{ ok: true, body: { + enabled: true, + multiAgentMode: "v2", + multiAgentModeHintText: "custom policy", + multiAgentModeHintRecommendation: recommendation, + } }]; + await mount(); + + const editor = container.querySelector(".swi-ultra-mode-editor"); + const textarea = editor?.querySelector("textarea") as HTMLTextAreaElement | null; + const restore = Array.from(editor?.querySelectorAll("button") ?? []) + .find(button => button.textContent?.trim() === "Restore preset"); + const save = Array.from(editor?.querySelectorAll("button") ?? []) + .find(button => button.textContent?.trim() === "Save"); + + expect(textarea?.value).toBe("custom policy"); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + + await act(async () => { (restore as HTMLButtonElement).click(); }); + expect(textarea?.value).toBe(recommendation.text); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + + await act(async () => { (save as HTMLButtonElement).click(); }); + const put = requests.find(item => item.init?.method === "PUT" && new URL(item.url, "http://localhost/").pathname === "/api/v2"); + expect(JSON.parse(String(put?.init?.body))).toEqual({ multiAgentModeHintText: recommendation.text }); +}); + +test.each([ + ["missing", undefined], + ["malformed", { text: "", revision: "b1" }], + ["valid", { text: "server-B policy", revision: "b1" }], +] as const)("server switches cannot install or restore another server's preset (%s)", async (_kind, nextRecommendation) => { + let releaseNext!: (value: Response) => void; + const nextRead = new Promise(resolve => { releaseNext = resolve; }); + const nextState = { + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, + multiAgentModeHintRecommendation: nextRecommendation, + }; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + requests.push({ url: String(url), init }); + const path = new URL(String(url), "http://localhost/").pathname; + if (path === "/old/api/v2") return response({ + enabled: true, multiAgentMode: "v2", multiAgentModeHintText: "custom-A policy", + multiAgentModeHintRecommendation: recommendation, + }); + if (path === "/new/api/v2") return init?.method === "PUT" ? response(nextState) : nextRead; + if (path.endsWith("/api/subagent-models")) return response({ available: [], chosen: [] }); + if (path.endsWith("/api/subagent-model-fallback")) return response({ available: [], models: [], pollMs: 60_000 }); + if (path.endsWith("/api/injection-model")) return response({ available: [], efforts: [] }); + return response({}); + }, + }); + await mount("/old"); + expect(container.querySelector(".swi-ultra-mode-editor textarea")?.value).toBe("custom-A policy"); + await act(async () => { root!.render(); }); + + expect(ultraSwitch().disabled).toBe(true); + expect(container.querySelector(".swi-ultra-mode-editor")).toBeNull(); + await act(async () => { ultraSwitch().click(); }); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(0); + + await act(async () => { releaseNext(response(nextState)); await nextRead; }); + const valid = Boolean(nextRecommendation?.text); + expect(ultraSwitch().disabled).toBe(!valid); + await act(async () => { ultraSwitch().click(); }); + const puts = requests.filter(item => item.init?.method === "PUT"); + if (valid) { + expect(puts).toHaveLength(1); + expect(puts[0]?.url).toBe("/new/api/v2"); + expect(JSON.parse(String(puts[0]?.init?.body))).toEqual({ multiAgentModeHintText: nextRecommendation!.text }); + } else { + expect(puts).toHaveLength(0); + } }); test("a save refresh from an old API server cannot overwrite a newer server", async () => { @@ -143,7 +282,7 @@ test("a save refresh from an old API server cannot overwrite a newer server", as if (path === "/old/api/v2") { if (init?.method === "PUT") return response({ ok: true }); oldGets++; - if (oldGets === 1) return response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null }); + if (oldGets === 1) return response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, multiAgentModeHintRecommendation: recommendation }); return oldRefresh; } if (path === "/new/api/v2") return response({ enabled: false, multiAgentMode: "default", multiAgentModeHintText: null }); @@ -170,7 +309,7 @@ test("a save refresh from an old API server cannot overwrite a newer server", as expect(ultraSwitch().disabled).toBe(true); await act(async () => { - releaseOldRefresh(response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: ULTRA_MODE_PRESET })); + releaseOldRefresh(response({ enabled: true, multiAgentMode: "v2", multiAgentModeHintText: recommendation.text, multiAgentModeHintRecommendation: recommendation })); await oldRefresh; await new Promise(resolve => setTimeout(resolve, 10)); }); diff --git a/src/codex/features.ts b/src/codex/features.ts index 9875a6ea2c..a52e9fc46e 100644 --- a/src/codex/features.ts +++ b/src/codex/features.ts @@ -38,6 +38,7 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWri import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl"; import { CODEX_CONFIG_PATH } from "./paths"; import { resolveAndPersistCodexRuntime } from "./runtime"; +import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy"; /** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */ export const DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY = "default_mode_request_user_input"; @@ -1091,7 +1092,8 @@ export function setMultiAgentModeHintText(value: string | null, configPath?: str }; } } - return setV2StringField("multi_agent_mode_hint_text", value, configPath); + const canonicalValue = value === null ? null : canonicalizeOpenCodexModeHint(value); + return setV2StringField("multi_agent_mode_hint_text", canonicalValue, configPath); } export const MODE_HINT_CAPABILITY_CACHE_MAX_ENTRIES = 8; diff --git a/src/codex/multi-agent-mode-policy.ts b/src/codex/multi-agent-mode-policy.ts new file mode 100644 index 0000000000..395dc72507 --- /dev/null +++ b/src/codex/multi-agent-mode-policy.ts @@ -0,0 +1,24 @@ +export const MULTI_AGENT_MODE_HINT_RECOMMENDATION = { + revision: "proactive-trigger-v1", + text: [ + "Proactive multi-agent delegation is active.", + "Only the delegation trigger changes: a separate explicit request is no longer required.", + "All existing user, authority, task-scope, and collaboration-tool rules continue to apply.", + "Delegate eligible independent work when parallel execution could materially improve speed or quality.", + "User requests override this hint.", + "This mode remains active until a later multi-agent mode developer message changes it.", + ].join(" "), +} as const; + +/** Byte-exact presets previously written by OpenCodex dashboard releases. */ +export const LEGACY_OPENCODEX_MODE_HINTS = [ + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.", + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it.", +] as const; + +/** Upgrade only known OpenCodex-owned values; user-authored variants stay byte-identical. */ +export function canonicalizeOpenCodexModeHint(text: string): string { + return LEGACY_OPENCODEX_MODE_HINTS.some(legacy => legacy === text) + ? MULTI_AGENT_MODE_HINT_RECOMMENDATION.text + : text; +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 561d00a080..d7617c5884 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -40,6 +40,7 @@ import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../codex/multi-agent-mode-policy"; import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; @@ -248,6 +249,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise agentsMaxDepth: getAgentsMaxDepth(), subagentDeveloperInstructions: getSubagentDeveloperInstructions(), multiAgentModeHintText: getMultiAgentModeHintText(), + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, // max_depth is V1-only upstream; this is the global-flag statement, derived // server-side so no client can present it as an effective V2 limit. agentsMaxDepthAppliesWhenV2Disabled: !enabled, @@ -421,6 +423,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise agentsMaxDepth: getAgentsMaxDepth(), subagentDeveloperInstructions: getSubagentDeveloperInstructions(), multiAgentModeHintText: getMultiAgentModeHintText(), + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, agentsMaxDepthAppliesWhenV2Disabled: !enabled, warnings, catalogRefresh, diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 6e8b6a18c1..39ec9a342c 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -44,10 +44,18 @@ import { v2TotalLimitToV1ChildLimit, } from "../../src/codex/features"; import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../src/codex/multi-agent-mode-policy"; import { cmdV2, codexFeaturesInvocation, v2StatusLine, multiAgentModeLine } from "../../src/cli/v2"; import { handleManagementAPI } from "../../src/server/management-api"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +// Independently pinned release presets: removing a production compatibility +// entry must not silently remove its regression case too. +const RELEASED_MODE_HINTS = [ + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.", + "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it.", +] as const; + function template(): Record { return { slug: "gpt-5.5", @@ -423,6 +431,19 @@ describe("multi_agent_mode_hint_text reader/writer", () => { expect(readFileSync(path, "utf8")).toBe(before); }); + test("writer upgrades exact released presets while preserving user-edited text", () => { + for (const legacy of RELEASED_MODE_HINTS) { + const path = fixtureConfig(TABLE); + expect(setMultiAgentModeHintText(legacy, path)).toEqual({ ok: true, changed: true }); + expect(getMultiAgentModeHintText(path)).toBe(MULTI_AGENT_MODE_HINT_RECOMMENDATION.text); + } + for (const custom of [`${RELEASED_MODE_HINTS[0]} `, `${RELEASED_MODE_HINTS[1]} Ask before delegating.`]) { + const path = fixtureConfig(TABLE); + expect(setMultiAgentModeHintText(custom, path)).toEqual({ ok: true, changed: true }); + expect(getMultiAgentModeHintText(path)).toBe(custom); + } + }); + test("writer clears with null: removes the key, keeps siblings", () => { const path = fixtureConfig("[features.multi_agent_v2]\nenabled = true\nmulti_agent_mode_hint_text = \"Proactive delegation\"\nmax_concurrent_threads_per_session = 17\n"); expect(setMultiAgentModeHintText(null, path)).toEqual({ ok: true, changed: true }); @@ -1348,6 +1369,7 @@ describe("management API parity surface for the WP2 keys", () => { agentsMaxDepth: 2, subagentDeveloperInstructions: null, multiAgentModeHintText: null, + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, agentsMaxDepthAppliesWhenV2Disabled: true, }); const v2Path = fixtureConfig("[features.multi_agent_v2]\nenabled = true\n"); @@ -1357,6 +1379,37 @@ describe("management API parity surface for the WP2 keys", () => { }); }); + test.each(RELEASED_MODE_HINTS)("GET preserves a released preset until an explicit hint save: %s", async legacy => { + const initial = `[features.multi_agent_v2]\nenabled = false\n# keep adjacent setting\nmax_concurrent_threads_per_session = 17\nmulti_agent_mode_hint_text = ${JSON.stringify(legacy)}\n`; + await withConfig(initial, async (path, deps) => { + const before = readFileSync(path, "utf8"); + const get = await handleManagementAPI(new Request("http://localhost/api/v2"), new URL("http://localhost/api/v2"), config, deps); + expect(await get?.json()).toMatchObject({ + multiAgentModeHintText: legacy, + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, + }); + expect(readFileSync(path, "utf8")).toBe(before); + + const unrelated = await handleManagementAPI(put({ agentsEnabled: false }), new URL("http://localhost/api/v2"), config, deps); + expect(unrelated?.status).toBe(200); + expect(getMultiAgentModeHintText(path)).toBe(legacy); + + const saved = await handleManagementAPI(put({ multiAgentModeHintText: legacy }), new URL("http://localhost/api/v2"), config, deps); + expect(saved?.status).toBe(200); + expect(await saved?.json()).toMatchObject({ + multiAgentModeHintText: MULTI_AGENT_MODE_HINT_RECOMMENDATION.text, + multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, + }); + expect(getMultiAgentModeHintText(path)).toBe(MULTI_AGENT_MODE_HINT_RECOMMENDATION.text); + expect(readFileSync(path, "utf8")).toContain("# keep adjacent setting"); + expect(getMaxConcurrentThreads(path)).toBe(17); + + const after = readFileSync(path, "utf8"); + expect(setMultiAgentModeHintText(legacy, path)).toEqual({ ok: true, changed: false }); + expect(readFileSync(path, "utf8")).toBe(after); + }); + }); + test("PUT writes each new field independently and re-reads them", async () => { await withConfig("[features.multi_agent_v2]\nenabled = false\n", async (path, deps) => { const onlyNew = await handleManagementAPI(put({ agentsEnabled: false }), new URL("http://localhost/api/v2"), config, deps); From bfa1baf15fba5c5df19f79eb9fb04160311de207 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 11:17:22 +0900 Subject: [PATCH 6/9] docs(subagents): record server-owned preset contract Document response-only recommendations, explicit-write legacy normalization and current-server dashboard draft ownership. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> (cherry picked from commit e5a6f35b8d82387703958188f4fcbaacb8eec892) --- structure/03_catalog-and-subagents.md | 11 +++++++++++ structure/05_gui-and-management-api.md | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 8ff92157ad..d3f20de401 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -478,6 +478,17 @@ unchanged proxy metadata after a native policy change. Native and legacy-tagged remain intact: tags do not establish historical authorship or revoke old instructions, and mixed-version transition detection is not guaranteed. +The native mode hint is separate from proxy guidance and native `[agents]` defaults. +`src/codex/multi-agent-mode-policy.ts` owns the proactive recommendation; the dashboard +obtains it from `/api/v2` rather than maintaining its own preset. An explicit dashboard, +API or CLI hint write passes through `setMultiAgentModeHintText`, which replaces only +the two byte-exact released OpenCodex presets with the current recommendation. Other +valid custom text, including whitespace variants, is preserved. Reads, unrelated writes +and upgrades do not migrate stored hints. The writer retains its native capability check +and stores only `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex TOML; +`null` removes that key. The hint affects new native Codex sessions when their v2 surface +is active, without changing reasoning effort or the proxy guidance switch. + Claude Code `ocx-*` agent definitions consume the same effective `claudeCode.blockedSkills` policy as inbound bundle elision. When the list is non-empty (default: `claude-api`), generated definitions whose marker-stripped model resolves to a routed id receive a preventive instruction not to invoke diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index c1562df4ec..d6e66375b7 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -126,7 +126,7 @@ this document owns is which module holds which area and what invariant that area | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | -| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | +| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | @@ -377,6 +377,20 @@ Subagent fallback settings load independently of the main roster. Their failure fallback controls and provides a retry; available fallback options come from that endpoint's availability list while already-configured stale values remain editable. +Subagents → Advanced uses the current API server's recommendation for **Always proactive +delegation** (formerly Ultra mode). Enabling requires the native v2 flag, explicit v2 mode +and a recommendation with nonblank string text and revision. Missing or malformed +recommendations disable preset installation and restoration while existing custom hints +remain editable and clearable. Restore changes only the editor draft; Save writes it. +Recommendation-only refreshes preserve unsaved drafts. Switching API servers hides the +previous hint and blocks mode writes until the new server's settings arrive. + +An explicit `multiAgentModeHintText` write canonicalizes only the two byte-exact legacy +OpenCodex presets; other valid custom text keeps its bytes. GET, unrelated PUTs and upgrades +leave stored hints unchanged. `null` clears the hint, blank strings are rejected, and the +existing native capability check still precedes writes. The text and revision recommendation +is supplied independently of stored TOML and is not evidence of native runtime support. + Account quota discovery is capability-based. Cheap OAuth and provider-key lists include `quotaMode` (`probe`, `passive`, or `unsupported`) without contacting upstream quota APIs. `GET /api/oauth/accounts?provider=..."a=1` and From 669f23eeec341618ae92a547c35ba9fa07d4bdde Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 12:13:45 +0900 Subject: [PATCH 7/9] docs(devlog): revalidate preset layer and artifact proof --- devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md index d12ead3ba9..6dcebfb036 100644 --- a/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md +++ b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md @@ -74,3 +74,9 @@ Suggested main-owned scratch outputs: `ui-v2/source-manifest.json`, `ui-v2/reque Main decision: preserve original 23-file diff and all regressions. Suggested extra CLI/API test cases remain a targeted audit decision rather than mandatory scope expansion. Sync structure/03_catalog-and-subagents.md and structure/05_gui-and-management-api.md with the response-only recommendation and explicit-write behavior. Render QA uses the existing built artifact. + +## wp4 P refresh and artifact publication + +Previous wp3 D: PR3992 at3ceef0121 passed CI34181398746, target-check rerun34181398713, independent source/security audit and425page remote docs build. Proceed server-owned preset. Candidate5b60a4fa7+e5a6f35b8 was independently audited25files; all25preimage files on actual predecessor3ceef0121 equal candidatebase8000e2482. Preserve original contribution and both layers of agent guides. + +UI proof uses the immutable matching hosted dashboard artifact and reviewed auxiliary stdlib fixture at /tmp/ocx-bug6-prep-v2-01a07e9d/.tmp/ui-v2/server.py. No product server/build or actual account operations. Capture and inspect representative desktop/mobileEN/KO, valid/custom/restore/save/clear/missing/malformed/error cases; hosted tests retain in-place delayed-server-switch coverage. Publish only sanitized screenshot assets on an owned evidence branch via noverify push and link immutable commit URLs in this PR, so adding review evidence does not rewrite the tested product head. This evidence-only branch is not another product PR or native stack. Actual product delivery remains the one six-layer chain. Preserve asset provenance/buildGUI-tree checks and teardown proof. From f7d3bf6fd863eaa92b460c2650fc789e7fbe4817 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 12:29:40 +0900 Subject: [PATCH 8/9] fix(gui): keep delegation preset text readable on narrow screens --- .../260908_bug6_manual_stack/040_v2_preset.md | 6 ++++++ gui/src/styles-subagents-workspace.css | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md index 6dcebfb036..8d9d368c9f 100644 --- a/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md +++ b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md @@ -80,3 +80,9 @@ Main decision: preserve original 23-file diff and all regressions. Suggested ext Previous wp3 D: PR3992 at3ceef0121 passed CI34181398746, target-check rerun34181398713, independent source/security audit and425page remote docs build. Proceed server-owned preset. Candidate5b60a4fa7+e5a6f35b8 was independently audited25files; all25preimage files on actual predecessor3ceef0121 equal candidatebase8000e2482. Preserve original contribution and both layers of agent guides. UI proof uses the immutable matching hosted dashboard artifact and reviewed auxiliary stdlib fixture at /tmp/ocx-bug6-prep-v2-01a07e9d/.tmp/ui-v2/server.py. No product server/build or actual account operations. Capture and inspect representative desktop/mobileEN/KO, valid/custom/restore/save/clear/missing/malformed/error cases; hosted tests retain in-place delayed-server-switch coverage. Publish only sanitized screenshot assets on an owned evidence branch via noverify push and link immutable commit URLs in this PR, so adding review evidence does not rewrite the tested product head. This evidence-only branch is not another product PR or native stack. Actual product delivery remains the one six-layer chain. Preserve asset provenance/buildGUI-tree checks and teardown proof. + +## C render foldback: narrow editor layout + +Real prebuilt GUI capture at320px shows an unbroken configuration-key hint clipped and the textarea compressed beside both actions;390px has no page overflow but editing space is unnecessarily narrow. Evidence ui-qa/ko-320-custom.png and ko-mobile-custom.png under ignored scratch. This is a bounded improvement of the changed preset surface, not a claim that the original PR introduced the old layout. + +MODIFY gui/src/styles-subagents-workspace.css only: add overflow-wrap:anywhere to existing delegation setting hints; give the preset editor a minmax(0,1fr)/auto/auto grid with min-width0 textarea; reuse existing640px media breakpoint to span textarea across a full first row and place two actions on the second row. NoJS, labels, fields or authority semantics change. Rebuild only remotely; compare head-stamped320/390/1440captures and DOMwidth/textareawidth, plus existing frontendgates. Retain prior functional UI request evidence becauseJS unchanged; verify a representative Save on finalrender. Main re-audits this CSS scope before B and captures finalartifact before publishing screenshotproof. diff --git a/gui/src/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css index a9f9466ce7..2e4bde01ed 100644 --- a/gui/src/styles-subagents-workspace.css +++ b/gui/src/styles-subagents-workspace.css @@ -88,8 +88,22 @@ margin-top: 3px; max-width: 72ch; line-height: 1.5; + overflow-wrap: anywhere; } +.swi-ultra-mode-editor { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: start; +} + +.swi-ultra-mode-textarea { + min-width: 0; + width: 100%; +} + +.swi-ultra-mode-editor > button { justify-self: end; } + .swi-delegation-controls { display: flex; align-items: center; @@ -587,4 +601,6 @@ @media (max-width: 640px) { .swi-fallback-editor { flex-direction: column; } .swi-fallback-controls { width: 100%; } + .swi-ultra-mode-editor { grid-template-columns: minmax(0, 1fr) auto; } + .swi-ultra-mode-textarea { grid-column: 1 / -1; } } From 727683f44e9f1daa9b6b1e2dbf93167e4ce30cc1 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 13:07:24 +0900 Subject: [PATCH 9/9] test: bound home-guard probe completion and cleanup --- .../260908_bug6_manual_stack/040_v2_preset.md | 6 + tests/ci-workflows/test-home-guard.test.ts | 273 +++++++++++++++--- 2 files changed, 241 insertions(+), 38 deletions(-) diff --git a/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md index 8d9d368c9f..74f15d39b3 100644 --- a/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md +++ b/devlog/_plan/260908_bug6_manual_stack/040_v2_preset.md @@ -86,3 +86,9 @@ UI proof uses the immutable matching hosted dashboard artifact and reviewed auxi Real prebuilt GUI capture at320px shows an unbroken configuration-key hint clipped and the textarea compressed beside both actions;390px has no page overflow but editing space is unnecessarily narrow. Evidence ui-qa/ko-320-custom.png and ko-mobile-custom.png under ignored scratch. This is a bounded improvement of the changed preset surface, not a claim that the original PR introduced the old layout. MODIFY gui/src/styles-subagents-workspace.css only: add overflow-wrap:anywhere to existing delegation setting hints; give the preset editor a minmax(0,1fr)/auto/auto grid with min-width0 textarea; reuse existing640px media breakpoint to span textarea across a full first row and place two actions on the second row. NoJS, labels, fields or authority semantics change. Rebuild only remotely; compare head-stamped320/390/1440captures and DOMwidth/textareawidth, plus existing frontendgates. Retain prior functional UI request evidence becauseJS unchanged; verify a representative Save on finalrender. Main re-audits this CSS scope before B and captures finalartifact before publishing screenshotproof. + +## C verification foldback: bounded guard probes + +Run34183701289 timed out on macos1/2 after test-home-guard passed its unregistered-home case, then Bun reported a dangling process. Runtime root cause remains unproven (H1retainedchildhandles/H2syncwait/H3isolate state); source establishes an unbounded synchronous probe owner. This is a verification-harness correction, not a claim to fix Bun internals. No production guard behavior changes. + +MODIFY tests/ci-workflows/test-home-guard.test.ts only: convert runProbe and all its callers to awaited async Bun.spawn; reuse exported captureTestOutput from scripts/test.ts (its executable entry is import.meta.main gated, already imported by test-runner tests); preserve exact argv/cwd/environment filtering/fake homes and all existing assertions. Use existing watchdogMs(5000) below CI60s test ceiling, bounded execution/TERM/KILL/reap/output-drain stages with cleared timers and cleanup of probe scratch. Require natural exit0, no signal, complete output; timeout/nonzero/incomplete/unreaped is a thrown diagnostic, never a success marker. Record safe probe-boundary ID/PID/exit diagnostics without inherited environment. Add synthetic nonzero-exit and nonterminating-probe controls proving failures are reported and owned child gone; do not skip guards or extend CI deadlines. All product verification stays remote. If new diagnostics still stall/fail, investigate that boundary; passing the converted harness is not an upstream-root-cause claim. diff --git a/tests/ci-workflows/test-home-guard.test.ts b/tests/ci-workflows/test-home-guard.test.ts index 398e6a2099..47369ed663 100644 --- a/tests/ci-workflows/test-home-guard.test.ts +++ b/tests/ci-workflows/test-home-guard.test.ts @@ -9,8 +9,8 @@ * * Incident: devlog/_fin/260730_codex_rs_upstream_v2_live_handoff/070. */ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -18,6 +18,8 @@ import { assertNotRealHomeUnderTest, isTestHomeGuardArmed, protectedHomeForTests import { getConfigDir } from "../../src/config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; +import { captureTestOutput } from "../../scripts/test"; /** * Two different things are needed from the repo root, and conflating them is @@ -38,26 +40,211 @@ const REPO_ROOT = repoRoot(); */ const REPO_ROOT_URL = pathToFileURL(repoRoot() + "/").href; -/** - * Run a probe in a child process so we control OCX_REAL_HOME at STARTUP — the guard - * captures its protected path at module load, which is exactly the property under test. - */ -function runProbe(source: string, env: Record): { code: number; stdout: string; stderr: string } { - const dir = mkdtempSync(join(tmpdir(), "ocx-guard-probe-")); - const file = join(dir, "probe.ts"); - writeFileSync(file, source, "utf8"); - const childEnv: Record = {}; - for (const [key, value] of Object.entries({ ...process.env, ...env })) { - if (value !== undefined) childEnv[key] = value; +// Scale only execution: cleanup retains room below CI's existing 60-second ceiling. +const PROBE_EXECUTION_MS = watchdogMs(5_000); +const PROBE_TERM_MS = 5_000; +const PROBE_REAP_MS = 2_000; +const PROBE_DRAIN_MS = 1_000; + +function beginProbe(id: string): string { + console.warn(`[home-guard:${id}] 01 fixture setup`); + return id; +} + +async function waitForProbe(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); } - const result = Bun.spawnSync([process.execPath, "run", file], { cwd: REPO_ROOT, env: childEnv, stdout: "pipe", stderr: "pipe" }); - return { - code: result.exitCode ?? 1, - stdout: new TextDecoder().decode(result.stdout), - stderr: new TextDecoder().decode(result.stderr), +} + +type ProbeOutcome = { + pid: number | null; + code: number | null; + signal: NodeJS.Signals | null; + reaped: boolean; + complete: boolean; + stdout: string; + stderr: string; + root: string | undefined; +}; + +class ProbeFailure extends Error { + constructor(readonly id: string, readonly failures: string[], readonly outcome: ProbeOutcome) { + super(`[home-guard:${id}] ${failures.join(", ")}; pid=${outcome.pid} exit=${outcome.code} signal=${outcome.signal} reaped=${outcome.reaped} complete=${outcome.complete}`); + this.name = "ProbeFailure"; + } +} + +/** Keep the startup home contract; own execution, reaping and pipe draining separately. */ +async function runProbe(id: string, source: string, env: Record): Promise { + const outcome: ProbeOutcome = { + pid: null, code: null, signal: null, reaped: false, complete: false, + stdout: "", stderr: "", root: undefined, }; + const failures: string[] = []; + let child: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined; + let exited: Promise | undefined; + let capture: ReturnType | undefined; + const stage = (message: string) => console.warn(`[home-guard:${id}] ${message}`); + try { + stage("02 probe file setup"); + outcome.root = mkdtempSync(join(tmpdir(), "ocx-guard-probe-")); + const file = join(outcome.root, "probe.ts"); + writeFileSync(file, source, "utf8"); + const childEnv: Record = {}; + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) childEnv[key] = value; + } + stage("03 spawn requested"); + child = Bun.spawn([process.execPath, "run", file], { cwd: REPO_ROOT, env: childEnv, stdout: "pipe", stderr: "pipe" }); + const owned = child; + outcome.pid = owned.pid; + stage(`04 pid=${owned.pid}`); + // Rejection is an observation failure, never evidence that the process was reaped. + exited = owned.exited.then(code => { + outcome.code = code; + outcome.signal = owned.signalCode ?? null; + outcome.reaped = true; + stage(`08 exit pid=${owned.pid} code=${code} signal=${outcome.signal}`); + }, () => { + failures.push("exit-observation-failed"); + stage(`08 exit observation failed pid=${owned.pid}`); + }); + capture = captureTestOutput(owned.stdout, owned.stderr); + if (!await waitForProbe(exited, PROBE_EXECUTION_MS)) { + failures.push("execution-timeout"); + stage(`05 execution timeout pid=${owned.pid}`); + } + } catch { + failures.push("setup-or-observation-failed"); + } finally { + if (child && !outcome.reaped) { + stage(`06 TERM pid=${child.pid}`); + try { child.kill("SIGTERM"); } catch { stage("06 TERM request failed"); } + if (exited) await waitForProbe(exited, PROBE_TERM_MS); + if (!outcome.reaped) { + stage(`07 KILL pid=${child.pid}`); + try { child.kill("SIGKILL"); } catch { stage("07 KILL request failed"); } + if (exited) await waitForProbe(exited, PROBE_REAP_MS); + } + if (!outcome.reaped) failures.push("reap-timeout"); + } + if (capture) { + try { Object.assign(outcome, await capture.finish(PROBE_DRAIN_MS)); } + catch { failures.push("capture-failed"); } + stage(`09 capture complete=${outcome.complete}`); + if (!outcome.complete) failures.push("incomplete-output"); + } + if (outcome.root && (!child || outcome.reaped)) { + try { + removeTreeWithRetry(outcome.root); + stage("10 probe files removed"); + } catch { + failures.push("cleanup-failed"); + stage("10 probe cleanup failed"); + } + } else if (outcome.root) { + stage(`10 probe files retained: child unreaped pid=${outcome.pid}`); + } + } + if (outcome.code !== 0) failures.push("nonzero-exit"); + if (outcome.signal !== null) failures.push("signal-exit"); + // A timeout remains a failure even if TERM subsequently permits a natural exit 0. + if (failures.length) throw new ProbeFailure(id, failures, { ...outcome }); + return outcome; +} + +async function probeFailure(pending: Promise): Promise { + const failure: unknown = await pending.then(() => undefined, error => error); + expect(failure).toBeInstanceOf(ProbeFailure); + if (!(failure instanceof ProbeFailure)) throw new Error("Expected a failed guard probe"); + return failure; +} + +function expectOwnedProbeGone(outcome: ProbeOutcome): void { + expect(outcome.reaped).toBe(true); + if (outcome.pid === null || outcome.root === undefined) throw new Error("Probe never spawned"); + expect(outcome.pid).toBeGreaterThan(0); + let code: string | undefined; + try { process.kill(outcome.pid, 0); } + catch (error) { code = (error as NodeJS.ErrnoException).code; } + expect(code).toBe("ESRCH"); + expect(existsSync(outcome.root)).toBe(false); } +describe("guard probe lifecycle", () => { + test("nonzero exit retains output, reports failure and reaps the owned child", async () => { + const failure = await probeFailure(runProbe(beginProbe("control-nonzero"), ` + console.log("OCX_GUARD_NONZERO"); + process.exitCode = 23; + `, {})); + expect(failure.failures).toEqual(["nonzero-exit"]); + expect(failure.outcome.code).toBe(23); + expect(failure.outcome.signal).toBeNull(); + expect(failure.outcome.complete).toBe(true); + expect(failure.outcome.stdout.trim()).toBe("OCX_GUARD_NONZERO"); + expectOwnedProbeGone(failure.outcome); + }); + + test("a referenced handle times out and is reaped even if TERM permits exit zero", async () => { + const failure = await probeFailure(runProbe(beginProbe("control-hanging"), ` + const keepAlive = setInterval(() => {}, 1000); + const stop = () => { + clearInterval(keepAlive); + process.off("SIGTERM", stop); + }; + process.on("SIGTERM", stop); + console.log("OCX_GUARD_HANG_READY"); + `, {})); + expect(failure.failures).toContain("execution-timeout"); + expect(failure.failures).not.toContain("reap-timeout"); + expect(failure.outcome.stdout.trim()).toBe("OCX_GUARD_HANG_READY"); + expect(failure.outcome.complete).toBe(true); + // POSIX can handle TERM and exit naturally; Windows may terminate directly. + if (process.platform !== "win32") { + expect(failure.outcome.code).toBe(0); + expect(failure.outcome.signal).toBeNull(); + } + expectOwnedProbeGone(failure.outcome); + }, 60_000); // Match the existing CI ceiling; include bounded TERM/reap/drain locally too. + + test("exit zero with an open output pipe is incomplete, never a successful probe", async () => { + let cancelled = false; + const stdout = new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode("OCX_GUARD_PARTIAL\n")); }, + cancel() { cancelled = true; }, + }); + const stderr = new ReadableStream({ start(controller) { controller.close(); } }); + // Exercise runProbe's integration with real capture; no unmanaged descendant is needed. + const spawn = spyOn(Bun, "spawn").mockReturnValue({ + pid: 0, stdout, stderr, exited: Promise.resolve(0), signalCode: null, + kill() { throw new Error("Exited synthetic child must not be killed"); }, + } as unknown as ReturnType); + try { + const pending = runProbe(beginProbe("control-open-pipe"), "", {}); + spawn.mockRestore(); // runProbe spawns synchronously before its first await. + const failure = await probeFailure(pending); + expect(failure.failures).toEqual(["incomplete-output"]); + expect(failure.outcome.code).toBe(0); + expect(failure.outcome.signal).toBeNull(); + expect(failure.outcome.reaped).toBe(true); + expect(failure.outcome.complete).toBe(false); + expect(failure.outcome.stdout).toBe("OCX_GUARD_PARTIAL\n"); + expect(cancelled).toBe(true); + expect(failure.outcome.root).toBeDefined(); + expect(existsSync(failure.outcome.root!)).toBe(false); + } finally { + spawn.mockRestore(); + } + }); +}); + /** A fake "real home" the guard will protect, so no deny case aims at the true one. */ function sentinelHome(): { realHome: string; opencodexHome: string; codexHome: string } { const realHome = mkdtempSync(join(tmpdir(), "ocx-sentinel-home-")); @@ -87,9 +274,10 @@ const canSymlink = (() => { removeTreeWithRetry(probeDir); } })(); - test("armed + the protected home: all three writers throw", () => { + test("armed + the protected home: all three writers throw", async () => { + const probeId = beginProbe("01-protected-writers"); const { realHome, opencodexHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; import { mutateStore } from "${REPO_ROOT_URL}src/oauth/store"; import { saveCodexAccountCredential } from "${REPO_ROOT_URL}src/codex/account-store"; @@ -115,9 +303,10 @@ const canSymlink = (() => { expect(() => readFileSync(join(opencodexHome, "codex-accounts.json"))).toThrow(); }); - test("armed native credential writes reject the protected Codex home", () => { + test("armed native credential writes reject the protected Codex home", async () => { + const probeId = beginProbe("02-native-credentials"); const { realHome, codexHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealCodexHomeUnderTest } from "${REPO_ROOT_URL}src/lib/test-home-guard"; try { // JSON.stringify, not raw interpolation: a Windows temp path is @@ -136,7 +325,8 @@ const canSymlink = (() => { expect(probe.stdout).not.toContain("WRITE_ALLOWED"); }); - test.skipIf(!canSymlink)("armed + a symlink escaping a temp home into the protected home: refused", () => { + test.skipIf(!canSymlink)("armed + a symlink escaping a temp home into the protected home: refused", async () => { + const probeId = beginProbe("03-symlink-file"); // Atomic writes resolve their destination through symlinks, so a temp home whose // config.json points into the protected home would otherwise pass the caller's // dir-level check and then write the real file anyway. @@ -146,7 +336,7 @@ const canSymlink = (() => { const dir = mkdtempSync(join(tmpdir(), "ocx-escape-home-")); symlinkSync(protectedFile, join(dir, "config.json")); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; const REFUSAL = "refusing to write the real OpenCodex home"; try { @@ -162,10 +352,11 @@ const canSymlink = (() => { expect(readFileSync(protectedFile, "utf8")).toBe('{"sentinel":true}'); }); - test("armed + an unregistered temp home: writers succeed", () => { + test("armed + an unregistered temp home: writers succeed", async () => { + const probeId = beginProbe("04-unregistered-home"); // The 54 suites that mkdtemp their own home must keep working with no opt-in. const dir = mkdtempSync(join(tmpdir(), "ocx-plain-home-")); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never); console.log("wrote"); @@ -175,7 +366,8 @@ const canSymlink = (() => { expect(JSON.parse(readFileSync(join(dir, "config.json"), "utf8")).port).toBe(10100); }); - test.skipIf(!canSymlink)("armed + a first write beneath a symlinked PARENT escaping into the protected home: refused", () => { + test.skipIf(!canSymlink)("armed + a first write beneath a symlinked PARENT escaping into the protected home: refused", async () => { + const probeId = beginProbe("05-symlink-parent"); // The file does not exist yet, so resolveWriteTarget returns the literal // path and target === path; the guard must resolve the parent directory // instead of skipping (review: symlinked config dir + absent destination). @@ -185,7 +377,7 @@ const canSymlink = (() => { symlinkSync(opencodexHome, linkDir); const modeBefore = statSync(opencodexHome).mode; - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { atomicWriteFile, writePid } from "${REPO_ROOT_URL}src/config"; const REFUSAL = "refusing to write the real OpenCodex home"; try { @@ -215,9 +407,10 @@ const canSymlink = (() => { expect(statSync(opencodexHome).mode).toBe(modeBefore); }); - test("disarmed: the protected home is allowed (production stays inert)", () => { + test("disarmed: the protected home is allowed (production stays inert)", async () => { + const probeId = beginProbe("06-disarmed"); const { realHome, opencodexHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { saveConfig } from "${REPO_ROOT_URL}src/config"; saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never); console.log("wrote"); @@ -226,12 +419,13 @@ const canSymlink = (() => { expect(probe.stdout).toContain("wrote"); }); - test("the protected path comes from OCX_REAL_HOME, not the sandboxed HOME", () => { + test("the protected path comes from OCX_REAL_HOME, not the sandboxed HOME", async () => { + const probeId = beginProbe("07-captured-home"); // The inversion this guards against: if the guard read homedir() after the harness // replaced HOME, it would protect the sandbox and leave the real home writable. const { realHome } = sentinelHome(); const decoyHome = mkdtempSync(join(tmpdir(), "ocx-decoy-home-")); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { protectedHomeForTests } from "${REPO_ROOT_URL}src/lib/test-home-guard"; console.log(protectedHomeForTests()); `, { OCX_TEST_HOME_GUARD: "1", OCX_REAL_HOME: realHome, HOME: decoyHome }); @@ -240,12 +434,13 @@ const canSymlink = (() => { expect(probe.stdout).not.toContain("ocx-decoy-home-"); }); - test.skipIf(!canSymlink)("a symlink pointing at the protected home is rejected", () => { + test.skipIf(!canSymlink)("a symlink pointing at the protected home is rejected", async () => { + const probeId = beginProbe("08-symlink-home"); const { realHome, opencodexHome } = sentinelHome(); const linkDir = mkdtempSync(join(tmpdir(), "ocx-symlink-")); const link = join(linkDir, "looks-like-temp"); symlinkSync(opencodexHome, link); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealHomeUnderTest } from "${REPO_ROOT_URL}src/lib/test-home-guard"; try { assertNotRealHomeUnderTest(${JSON.stringify(link)}); console.log("allowed"); } catch { console.log("rejected"); } @@ -254,12 +449,13 @@ const canSymlink = (() => { expect(probe.stdout.trim()).toBe("rejected"); }); - test("/var and /private/var spellings of one path agree", () => { + test("/var and /private/var spellings of one path agree", async () => { + const probeId = beginProbe("09-path-alias"); // macOS hands out /var/folders/... whose realpath is /private/var/folders/...; // a lexical comparison would disagree with itself across those two spellings. const { realHome } = sentinelHome(); const aliased = realHome.startsWith("/var/") ? join("/private", realHome) : realHome.replace(/^\/private/, ""); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealHomeUnderTest } from "${REPO_ROOT_URL}src/lib/test-home-guard"; const results: string[] = []; for (const path of [${JSON.stringify(join(realHome, ".opencodex"))}, ${JSON.stringify(join(aliased, ".opencodex"))}]) { @@ -327,9 +523,10 @@ const canSymlink = (() => { * And the guard has to hold for a process that never reached the lock at all, which is the * state the timed-out worker was actually in. */ - test("a process that arms the guard is protected even with no lock and a real HOME", () => { + test("a process that arms the guard is protected even with no lock and a real HOME", async () => { + const probeId = beginProbe("10-no-lock"); const { realHome } = sentinelHome(); - const probe = runProbe(` + const probe = await runProbe(probeId, ` import { assertNotRealHomeUnderTest, isTestHomeGuardArmed } from "${REPO_ROOT_URL}src/lib/test-home-guard"; let rejected = false; try { assertNotRealHomeUnderTest(${JSON.stringify(join(realHome, ".opencodex"))}); } catch { rejected = true; }