diff --git a/docs-site/src/content/docs/es/guides/codex-integration.md b/docs-site/src/content/docs/es/guides/codex-integration.md index 000d03b6dca..1eb2b9a3c2b 100644 --- a/docs-site/src/content/docs/es/guides/codex-integration.md +++ b/docs-site/src/content/docs/es/guides/codex-integration.md @@ -228,7 +228,7 @@ El proxy admite hasta 128 conexiones Responses WebSocket orientadas al cliente. Si una continuación canónica hacia adelante ChatGPT hace referencia a un estado de repetición local caducado o faltante, opencodex devuelve `previous_response_not_found` antes de enviar cualquier cosa aguas arriba. El cliente Codex de WebSocket reconoce este error y puede volver a conectarse con su contexto completo retenido, incluyendo llamadas de herramientas completadas y sus resultados, dentro de su presupuesto de reintento de flujo normal. Por lo tanto, una tarea inactiva no necesita una tarea nueva únicamente porque la caché de reproducción del proxy expiró. El estado de continuación reproducido se conserva durante 24 horas y permanece limitado por su memoria, disco y límites de entrada existentes; esto no recupera el historial que el cliente ya no tiene. HTTP clientes deben manejar el error explícitamente y volver a enviar su contexto completo sin `previous_response_id`. Volver a intentar solo el mismo ID no puede recuperar el estado faltante. -El mismo rechazo se aplica cuando el estado referenciado pertenece a un ámbito de tarea cliente diferente, incluso cuando la nueva solicitud parece llevar entrada completa. El proxy no puede probar que la entrada está completa, por lo que no elimina silenciosamente `previous_response_id` ni revela si existe un estado coincidente. Vuelva a intentar con la conversación completa y omita `previous_response_id`; los ámbitos coincidentes y las continuaciones heredadas donde ambos ámbitos están ausentes o en blanco continúan reproduciéndose normalmente. +El mismo rechazo se aplica cuando el estado referenciado pertenece a un ámbito de tarea cliente diferente, incluso cuando la nueva solicitud parece llevar entrada completa. El proxy no puede probar que la entrada está completa, por lo que no elimina silenciosamente `previous_response_id` ni revela si existe un estado coincidente. Cuando está activado el modo `v2-plaintext` o la opción `plaintextV2AgentMessages`, la reproducción local exige el mismo principal de OpenCodex admitido y el `thread-id` propio y no vacío de la solicitud. Un hilo principal, una dirección de loopback, una credencial del proveedor o una clave de relleno no constituyen esa identidad. Sin principal admitido o sin hilo propio, envía la conversación completa sin `previous_response_id`. Las configuraciones predeterminadas de Codex y DSH en loopback no establecen esa identidad. El estado conservado mientras cualquiera de las dos opciones está activada permanece vinculado al desactivarlas. El modo predeterminado `v1` conserva la continuación por hilo principal para el estado no vinculado, también en loopback. Esto es independiente de los ID de Responses gestionados por el proveedor nativo. La misma señal de recuperación se aplica a cada destino enrutado, porque solo el paso a través nativo Responses puede responder a un giro cuya historia perdió este proxy: reenvía `previous_response_id` a un backend que almacenó la cadena. Cada uno de los otros cables reconstruye la conversación a partir de la propia entrada de cada solicitud, por lo que una expansión perdida allí enviaría el giro actual solo y perdería silenciosamente la conversación. Eso incluye los tres que parecen con estado: Devin vuelve a enviar toda la conversación a cada paso, la referencia de punto de control de Cursor vive en la misma tienda vencida y vuelve a la reproducción completa sin ella, y Kiro reconstruye su historial de conversaciones a partir de los turnos en que se entregó. También se aplica a los proveedores enrutados Responses configurados con `statelessResponses: true`, y a las solicitudes enrutadas donde una herramienta personalizada se redujo a una función pero un resultado delta no tiene llamada local para establecer su tipo original. La reproducción completa conserva la llamada, el resultado y el razonamiento juntos; opencodex no adivina el tipo de resultado ni lo descarta. Los proveedores estatales todavía resuelven la función nativa y las continuaciones personalizadas nativas por sí mismos. Estas comprobaciones siguen el protocolo de cable seleccionado y las declaraciones de la herramienta, no el nombre del modelo. Para una puerta de enlace que no puede resolver los ID de respuesta almacenados, habilite `statelessResponses` en ese proveedor; otros proveedores mantienen sus valores predeterminados. diff --git a/docs-site/src/content/docs/es/guides/sub-agent-surface.md b/docs-site/src/content/docs/es/guides/sub-agent-surface.md index aadfb96a6fa..114f4edc2e4 100644 --- a/docs-site/src/content/docs/es/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/es/guides/sub-agent-surface.md @@ -21,7 +21,9 @@ Elija el modo para **Nuevas sesiones**. Las sesiones existentes mantienen la sup | **base** | Asignaciones originales por modelo: GPT-5.6 Sol/Terra usan v2; Luna, v1. Los demás siguen la opción `multi_agent_v2` de Codex. | Para quienes quieren conservar esas asignaciones y usan modelos principales y subagentes del mismo lado del límite de proveedores. Sol y Terra quedan en v2. | | **v2** | Herramientas planas `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent` y listado de agentes, con sesiones concurrentes. | Para quienes necesitan el flujo concurrente y comprenden la herencia de modelos y la limitación de tareas cifradas descrita abajo. | -En **v2**, la opción **Mantener ChatGPT en v1** (`keepNativeChatGptOnV1`) conserva Sol/Terra en v1 para que puedan crear subagentes Grok o Claude. Los agentes principales nativos de ChatGPT cifran el cuerpo de las tareas `NEW_TASK` en v2 y los modelos enrutados no pueden leerlo. Los agentes principales enrutados permanecen en v2, donde las tareas de sus subagentes son texto plano. Para este modo híbrido, OpenCodex desactiva la anulación global `multi_agent_v2`, porque Codex la aplica antes que los ajustes por modelo del catálogo. Es una opción de v2, no un cuarto modo de catálogo. +**v2-plaintext** usa las asignaciones V2 del catálogo con la anulación global desactivada y compila mensajes de tareas aptos en texto sin cifrar. Es una opción experimental con consentimiento explícito; consulta [sus condiciones](/es/guides/subagent-v1-default/). + +En **v2**, la opción **Mantener ChatGPT en v1** (`keepNativeChatGptOnV1`) conserva Sol/Terra en v1 para que puedan crear subagentes Grok o Claude. Los agentes principales nativos de ChatGPT cifran el cuerpo de las tareas `NEW_TASK` en v2 y los modelos enrutados no pueden leerlo. Los agentes principales enrutados permanecen en v2, donde las tareas de sus subagentes son texto plano. Para este modo híbrido, OpenCodex desactiva la anulación global `multi_agent_v2`, porque Codex la aplica antes que los ajustes por modelo del catálogo. Es una opción de v2 independiente del modo `v2-plaintext`. :::tip[¿Tienes dudas?] Usa **v1**, el modo predeterminado. Elige **base** o **v2** solo si el modelo principal y el subagente están del mismo lado del límite de proveedores: en ambos modos, una tarea enviada desde un modelo nativo de ChatGPT a uno enrutado llega cifrada y falla. La opción híbrida de v2 descrita arriba evita ese cruce conservando los modelos nativos en v1. El dashboard pide confirmación antes de elegir base o v2 y enlaza a [Por qué v1 es el valor predeterminado](/es/guides/subagent-v1-default/). @@ -53,6 +55,7 @@ El modo seleccionado controla el campo `multi_agent_version` en cada entrada del - **v1** marca `multi_agent_version = "v1"` en todos los modelos. - **base** restaura los pines de aguas arriba. Las entradas sin pines siguen la bandera de la característica nativa `multi_agent_v2`. +- **v2-plaintext** asigna V2 en el catálogo y desactiva la anulación global; también activa el compilador experimental para solicitudes aptas. - **v2** marca `multi_agent_version = "v2"` en todos los modelos, excepto cuando **Mantener ChatGPT en v1** está habilitado: las filas nativas de ChatGPT permanecen en `"v1"` y las filas enrutadas o de combo permanecen en `"v2"`. opencodex aplica esto como el pase final tanto al catálogo en vivo `/v1/models` como al catálogo sincronizado al disco. Es por eso que un cambio de modo afecta a las nuevas sesiones de App, CLI y TUI creadas de manera consistente. @@ -182,7 +185,7 @@ Si la solicitud reconstruida recibe otro SSE `error` desnudo seguido por EOF, am ### GUI -- **Tablero** → primera celda de estadística: elija **v1**, **base** o **v2**. +- **Tablero** → primera celda de estadística: elija **v1**, **base**, **v2** o **v2-plaintext**. - **Modelos** → control segmentado de la fila superior: elija el mismo modo global. - **Tablero** → **Delegación de subagentes**: establece el modelo/esfuerzo de guía y la opción de opt-in por defecto nativa. - **Subagentes**: elija y ordene la lista y configure la cadena de reserva global. @@ -305,4 +308,4 @@ Solo un cambio real cuenta. Una sincronización cuyo resultado es idéntico en b El límite de contexto del modelo es independiente del modo de subagente. Configure en la página Modelos; los modelos nativos OpenAI conservan sus ventanas de contexto real. -El campo experimental `plaintextV2AgentMessages` no se establece en una nueva configuración y se ejecuta solo cuando se establece en `true`. La persona que llama debe usar el cable Responses y el destino final debe usar `adapter: "openai-responses"`, `authMode: "forward"` y la URL base exacta `https://chatgpt.com/backend-api/codex`. Quedan excluidos los proveedores de clave API de OpenAI, las pasarelas compatibles personalizadas, las rutas a otros proveedores y los llamantes que no usan Responses. Para una nueva llamada a la herramienta ChatGPT v2 nativa elegible, la opción asigna alias con alcance de solicitud al espacio de nombres y tres nombres reservados de la herramienta de mensajes, elimina el marcador de mensajes y restaura las identidades originales en la respuesta. Maneja `spawn_agent`, `send_message` y `followup_task` y no agrega ninguna petición de recuperación. HTTPS permanece cifrado, pero el texto de la tarea se puede conservar en el historial de Codex, las solicitudes del proveedor enrutado y el estado de respuesta/depuración local. El texto cifrado existente no cambia, y la opción depende del comportamiento no documentado de ChatGPT y Codex. Véase [Configuración del agente: Mensajes de agente de texto sin formato v2](/es/reference/configuration/agents/#plaintext-v2-agent-messages). Si una respuesta de flujo continuo ChatGPT no lleva ningún tipo de contenido, o una que no es ni `application/json` ni un flujo de eventos reconocible, opencodex comprueba un prefijo de evento Responses limitado antes de restaurar los nombres de herramienta de mensaje. Una respuesta `application/json` toma el camino JSON limitado en su lugar. La sonda espera como máximo `stallTimeoutSec` por su prefijo, y un cuerpo que permanece silencioso, no reconocido o ilegible se rechaza y nunca llega a Codex como una respuesta exitosa. +El campo experimental `plaintextV2AgentMessages` no se establece en una configuración nueva. Un valor explícito `true` o el modo seleccionado `v2-plaintext` activa el compilador. Este modo también selecciona V2 para chats nuevos sin activar la anulación global; al salir, conserva el valor independiente del campo. La compilación sigue la configuración actual en solicitudes aptas posteriores, incluso en sesiones V2 existentes. No descifra texto cifrado existente ni reescribe el historial, y desactivarla no elimina las copias conservadas. La persona que llama debe usar el cable Responses y el destino final debe usar `adapter: "openai-responses"`, `authMode: "forward"` y la URL base exacta `https://chatgpt.com/backend-api/codex`. Quedan excluidos los proveedores de clave API de OpenAI, las pasarelas compatibles personalizadas, las rutas a otros proveedores y los llamantes que no usan Responses. Para una nueva llamada a la herramienta ChatGPT v2 nativa elegible, la opción asigna alias con alcance de solicitud al espacio de nombres y tres nombres reservados de la herramienta de mensajes, elimina el marcador de mensajes y restaura las identidades originales en la respuesta. Maneja `spawn_agent`, `send_message` y `followup_task` y no agrega ninguna petición de recuperación. HTTPS permanece cifrado, pero el texto de la tarea se puede conservar en el historial de Codex, las solicitudes del proveedor enrutado y el estado de respuesta/depuración local. El texto cifrado existente no cambia, y la opción depende del comportamiento no documentado de ChatGPT y Codex. Véase [Configuración del agente: Mensajes de agente de texto sin formato v2](/es/reference/configuration/agents/#plaintext-v2-agent-messages). Si una respuesta de flujo continuo ChatGPT no lleva ningún tipo de contenido, o una que no es ni `application/json` ni un flujo de eventos reconocible, opencodex comprueba un prefijo de evento Responses limitado antes de restaurar los nombres de herramienta de mensaje. Una respuesta `application/json` toma el camino JSON limitado en su lugar. La sonda espera como máximo `stallTimeoutSec` por su prefijo, y un cuerpo que permanece silencioso, no reconocido o ilegible se rechaza y nunca llega a Codex como una respuesta exitosa. diff --git a/docs-site/src/content/docs/es/guides/subagent-v1-default.md b/docs-site/src/content/docs/es/guides/subagent-v1-default.md index 477d355c6e6..dc891cc73e3 100644 --- a/docs-site/src/content/docs/es/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/es/guides/subagent-v1-default.md @@ -5,7 +5,21 @@ description: "Qué impide la limitación de tareas cifradas de v2, por qué Open OpenCodex se instala con la interfaz de subagentes en **v1**. Las páginas Panel, Modelos y Subagentes solicitan confirmación antes de cambiar a **base** o **v2** y enlazan esta explicación. La CLI no solicita esa confirmación. -El motivo es concreto: de forma predeterminada, en v2 un modelo enrutado no puede leer la tarea cifrada que recibe de un modelo nativo de ChatGPT. Esto afecta al caso habitual de un agente GPT que crea un subagente Grok, Claude o GLM. Las excepciones explícitas son `keepNativeChatGptOnV1`, que conserva los modelos nativos de ChatGPT en v1 mientras los enrutados usan v2; un destino autorizado que pueda procesar la tarea cifrada mediante `allowEncryptedV2AgentTasks: true`; o la recuperación experimental `agentTaskRecovery`, descritas más abajo. +El motivo es concreto: de forma predeterminada, en v2 un modelo enrutado no puede leer la tarea cifrada que recibe de un modelo nativo de ChatGPT. Esto afecta al caso habitual de un agente GPT que crea un subagente Grok, Claude o GLM. Las excepciones explícitas son `keepNativeChatGptOnV1`, que conserva los modelos nativos de ChatGPT en v1 mientras los enrutados usan v2; un destino autorizado que pueda procesar la tarea cifrada mediante `allowEncryptedV2AgentTasks: true`; la recuperación experimental `agentTaskRecovery` o el modo experimental `v2-plaintext`, descritos más abajo. + +## V2 opcional con tareas en texto sin cifrar + +**V2 · tareas en texto sin cifrar** (`v2-plaintext`) selecciona V2 para chats nuevos mediante asignaciones del catálogo con la anulación global desactivada. Conserva nombres de tareas, jerarquía, seguimiento, cancelación y concurrencia de V2; reutiliza el compilador experimental para solicitudes Responses aptas dirigidas a ChatGPT por la ruta canónica. Los chats existentes conservan su selección, pero la compilación sigue la configuración actual también en sus solicitudes V2 aptas posteriores. Una actualización no selecciona este modo automáticamente. También puede activar el compilador el ajuste independiente `plaintextV2AgentMessages: true`. + +Mientras cualquiera de las dos opciones esté activada, las continuaciones locales por ID requieren el mismo principal de clave API de OpenCodex admitido y el `thread-id` propio de la solicitud; el hilo principal no da acceso a las tareas de un hijo. Sin cualquiera de las dos identidades, tras reiniciar el proxy o para entradas antiguas sin vínculo, envía la conversación completa sin `previous_response_id`. El estado vinculado mientras está activado permanece vinculado al desactivarlo. El modo predeterminado `v1` conserva la continuación por hilo principal para el estado no vinculado, incluso en loopback. + +En `v2-plaintext`, el panel exige aceptar que el texto puede conservarse en el historial de Codex, las solicitudes al proveedor y el estado local de respuesta o depuración, incluidas instantáneas y archivos en disco. HTTPS y la autenticación siguen activos. No se añade una llamada de recuperación, no se descifra el texto cifrado existente ni se reescribe el historial, y desactivar la compilación no elimina las copias retenidas. Un cliente o backend incompatible aún puede fallar. + +```bash +ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages +``` + +Para el destino local omite `--live`. Después consulta el mismo destino: `ocx v2 status --live --json` para el proxy activo o `ocx v2 status --json` para el destino local. La configuración y la convergencia del catálogo no prueban que una delegación real haya funcionado. Para salir, selecciona otro modo; se conserva el valor independiente de `plaintextV2AgentMessages`. La API de gestión exige `plaintextAgentMessagesAcknowledged` al entrar. @@ -65,6 +79,7 @@ OpenCodex registró esta consecuencia en [#92](https://github.com/lidge-jun/open | **v1** (predeterminado) | Todos los modelos anuncian las herramientas clásicas de creación con espacio de nombres. Cada creación puede indicar otro modelo directamente. | Si delega entre proveedores. Es el valor incluido en la instalación. | | **base** | Respeta la asignación de Codex por modelo: Sol y Terra usan v2, Luna usa v1 y los modelos sin asignación siguen la opción de Codex. | Si desea la interfaz prevista por Codex y delega dentro de un mismo proveedor. | | **v2** | Todos los modelos anuncian las herramientas concurrentes sin espacio de nombres. | Si desea las sesiones concurrentes nuevas y ambos agentes están del mismo lado del límite entre proveedores. | +| **v2-plaintext** | Asigna V2 a sesiones nuevas con la anulación global desactivada y compila las solicitudes aptas en texto sin cifrar. | Si acepta la conservación del texto y desea usar esta opción experimental. | base aparece en segundo lugar porque asigna v2 a Sol y Terra, modelos utilizados habitualmente como agentes principales. Para delegar de ChatGPT a un modelo enrutado, base tiene la misma limitación que v2. @@ -79,13 +94,13 @@ La actualización no sobrescribe una elección existente. El panel muestra el av Ambas respuestas quedan registradas. Cerrar el aviso sin responder lo deja pendiente para la siguiente apertura del panel. -El modo se aplica a las sesiones **nuevas** de Codex. Inicie otra sesión después del cambio. Si un proceso de la aplicación que lleva tiempo abierto todavía muestra la interfaz anterior, ejecute `ocx sync` y reinicie esa aplicación. +Las asignaciones del catálogo se aplican a las sesiones **nuevas** de Codex; el compilador de texto sin cifrar sigue la configuración actual para las solicitudes aptas, incluidas las posteriores de sesiones V2 existentes. Inicie otra sesión después del cambio. Si un proceso de la aplicación que lleva tiempo abierto todavía muestra la interfaz anterior, ejecute `ocx sync` y reinicie esa aplicación. ## Si prefiere utilizar v2 -Hay cuatro opciones, en el orden recomendado: +Además de `v2-plaintext`, hay cuatro opciones: 1. **Mantener ChatGPT en v1.** Dentro de v2, `keepNativeChatGptOnV1` conserva Sol y Terra en v1 para que puedan crear subagentes Grok o Claude; los agentes principales enrutados utilizan v2. 2. **Delegar dentro del mismo proveedor.** Un agente enrutado que crea otro subagente enrutado utiliza texto sin cifrar en v2 y funciona normalmente. @@ -104,7 +119,7 @@ Cuando Codex permita que un agente nativo de ChatGPT emita sin cifrar la tarea d ## Cambiar el modo -Panel, Modelos y Subagentes comparten el selector v1/base/v2 y solicitan confirmación antes de pasar a base o v2. Desde la CLI: +Panel, Modelos y Subagentes comparten el selector v1/base/v2/v2-plaintext y solicitan confirmación antes de pasar a base o v2; entrar en `v2-plaintext` requiere aceptar sus condiciones. Desde la CLI: ```bash ocx v2 status diff --git a/docs-site/src/content/docs/es/reference/cli.md b/docs-site/src/content/docs/es/reference/cli.md index 47473349363..4eefd6148e8 100644 --- a/docs-site/src/content/docs/es/reference/cli.md +++ b/docs-site/src/content/docs/es/reference/cli.md @@ -207,6 +207,8 @@ Las opciones enteras como `--limit` requieren números enteros decimales dentro +Para entrar en el modo experimental usa `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`. Los comandos genéricos `config set` y `config import` rechazan una entrada nueva a este modo, pues el comando dedicado también coordina la transición nativa. Antes de restaurar una configuración exportada con `multiAgentMode: "v2-plaintext"`, realiza primero esa transición con reconocimiento y después importa el archivo. Se permite restaurar un modo ya activo, cambiar otros ajustes y salir del modo. La preferencia independiente `plaintextV2AgentMessages` no cambia. + ## Listado de alias predeterminado `ocx alias --json` es equivalente a `ocx alias list --json`. La bandera de salida puede preceder o seguir una acción de alias explícita. diff --git a/docs-site/src/content/docs/es/reference/cli/agents.md b/docs-site/src/content/docs/es/reference/cli/agents.md index 028395a849c..6ff4035d7fb 100644 --- a/docs-site/src/content/docs/es/reference/cli/agents.md +++ b/docs-site/src/content/docs/es/reference/cli/agents.md @@ -101,9 +101,9 @@ ocx effort set --subagent - Status conserva los valores de límite de entorno de ejecución/almacenados existentes e informa de los valores no admitidos en `warnings` (una matriz vacía cuando no se admite ninguna). Las mismas advertencias aparecen en la salida humana y nombran el campo que se ignora con un comando de corrección. El estado nunca repara o reescribe esos valores. Un campo de subagente ignorado no elimina un límite principal válido. `ocx effort clear` limpia ambos límites mientras retiene la configuración de esfuerzo de inyección separada. Véase [Superficies de los subagentes](/es/guides/sub-agent-surface/) para las superficies de solicitud donde se aplican los límites. -### `ocx v2 |keep-native-v1 |threads |mode-hint >` +### `ocx v2 |keep-native-v1 |threads |mode-hint >` -Administre el indicador de características Codex `multi_agent_v2` y el modo de superficie multiagente de tres estados. Estos funcionan localmente por defecto. `--live` utiliza el proxy de ejecución seleccionado sin respaldo local; ambos objetivos admiten `--json`. Vuelva a leer el estado en el mismo objetivo después de un cambio y distinga la configuración almacenada del comportamiento de la nueva sesión. +Administre el indicador de características Codex `multi_agent_v2` y el modo de superficie multiagente. Estos funcionan localmente por defecto. `--live` utiliza el proxy de ejecución seleccionado sin respaldo local; ambos objetivos admiten `--json`. Vuelva a leer el estado en el mismo objetivo después de un cambio y distinga la configuración almacenada del comportamiento de la nueva sesión. | Subcomando | Medidas adoptadas | | --- | --- | @@ -113,6 +113,7 @@ Administre el indicador de características Codex `multi_agent_v2` y el modo de | `mode v1` | Forzar a todos los modelos a v1, deshabilitar v2 nativo y preservar el límite de subproceso activo. | | `mode default` | Respete los pines de superficie del modelo aguas arriba. | | `mode v2` | Forzar los modelos a v2 y preservar el límite de rosca activa. Con `keep-native-v1` desactivado, habilite la v2 nativa global; con ella, deshabilite la anulación global y use pines de catálogo. | +| `mode v2-plaintext` | Asigna V2 mediante el catálogo con la anulación global desactivada y activa la entrega experimental de tareas en texto sin cifrar. Requiere `--acknowledge-plaintext-agent-messages`. | | `keep-native-v1 on\|off` | Bajo `mode v2`, mantenga los modelos nativos ChatGPT en v1 y los modelos enrutados en v2. Habilitarlo desactiva la anulación global de V2 antes de la sincronización del catálogo. | | `threads ` | Establezca el límite de hilo v1/v2 activo en un número entero de al menos 1. | | `mode-hint ` | Establezca la sugerencia de delegación proactiva (modo Ultra) para cada modelo y esfuerzo. | @@ -132,7 +133,7 @@ El subcomando `mode` escribe `multiAgentMode` a la configuración opencodex y vu Codex resuelve una anulación global habilitada de `multi_agent_v2` antes del pin de catálogo del modelo seleccionado. Por lo tanto, el contrato híbrido `keep-native-v1` mantiene esa anulación global apagada; de lo contrario, una fila nativa estampada `v1` aún comenzaría en V2 y produciría tareas secundarias cifradas en el backend. -`mode-hint` escribe `features.multi_agent_v2.multi_agent_mode_hint_text` en el `$CODEX_HOME/config.toml` de Codex incluso cuando `multi_agent_v2` está actualmente deshabilitado. El comando solo persiste la anulación; no habilita ni deshabilita la función, por lo que la sugerencia tiene efecto cuando una superficie coincidente Codex está activa. La sugerencia anula la política multiagente derivada de esfuerzo de codex-rs, por lo que cualquier modelo y cualquier esfuerzo de razonamiento recibe el mensaje de delegación proactiva. **no** cambia el esfuerzo de razonamiento en sí. Se rechaza un argumento faltante o un valor de solo espacio en blanco; solo `--clear` elimina la sugerencia. El modo Ultra **on** del panel de Subagents tiene una puerta más estricta: requiere que la función nativa esté habilitada con una superficie v2 explícita ( `ocx v2 mode v2` ); `ocx v2 on` por sí solo no satisface esa puerta del tablero. +`mode-hint` escribe `features.multi_agent_v2.multi_agent_mode_hint_text` en el `$CODEX_HOME/config.toml` de Codex incluso cuando `multi_agent_v2` está actualmente deshabilitado. El comando solo persiste la anulación; no habilita ni deshabilita la función, por lo que la sugerencia tiene efecto cuando una superficie coincidente Codex está activa. La sugerencia anula la política multiagente derivada de esfuerzo de codex-rs, por lo que cualquier modelo y cualquier esfuerzo de razonamiento recibe el mensaje de delegación proactiva. **no** cambia el esfuerzo de razonamiento en sí. Se rechaza un argumento faltante o un valor de solo espacio en blanco; solo `--clear` elimina la sugerencia. El modo Ultra **on** del panel de Subagents tiene una puerta más estricta: requiere la función nativa habilitada con una superficie v2 explícita (`ocx v2 mode v2`) o `v2-plaintext`, que usa asignaciones del catálogo con la anulación global desactivada; `ocx v2 on` por sí solo no satisface esa puerta del tablero. diff --git a/docs-site/src/content/docs/es/reference/configuration/agents.md b/docs-site/src/content/docs/es/reference/configuration/agents.md index 197256c61ed..1f2fac872b7 100644 --- a/docs-site/src/content/docs/es/reference/configuration/agents.md +++ b/docs-site/src/content/docs/es/reference/configuration/agents.md @@ -29,7 +29,7 @@ El marcador interno `subagentModelsVersion` (actualmente `3`) hace que cada migr | Campo | Tipo | Predeterminado | Significado | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` marca todos los modelos del catálogo como v1; `v2` los marca como v2. `default` restaura las asignaciones del proveedor (Sol/Terra v2, Luna v1) y, en los demás casos, sigue la opción nativa `multi_agent_v2`. Se aplica a las sesiones nuevas. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` marca todos los modelos del catálogo como v1; `v2` los marca como v2. `default` restaura las asignaciones del proveedor (Sol/Terra v2, Luna v1) y, en los demás casos, sigue la opción nativa `multi_agent_v2`. `v2-plaintext` también activa la entrega experimental en texto sin cifrar y desactiva la anulación global. Las asignaciones se aplican a sesiones nuevas; la compilación activa también afecta a solicitudes aptas posteriores de sesiones V2 existentes. | | `keepNativeChatGptOnV1?` | `boolean` | `false` | Cuando `multiAgentMode` es `"v2"`, desactiva la anulación global de V2, marca las entradas nativas de ChatGPT como v1 y conserva las enrutadas en v2. Codex resuelve la anulación global antes que las asignaciones del catálogo: ambas partes son necesarias para que un agente principal de ChatGPT cree subagentes enrutados sin tareas cifradas por el backend ([#92](https://github.com/lidge-jun/opencodex/issues/92)). Se ignora en `v1` y `default`. | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | Hasta cinco identificadores nativos sin prefijo, con cuenta `/` o enrutados `provider/model` que aparecen primero en el selector de subagentes. El panel ofrece identificadores nativos sin prefijo y enrutados para nuevas selecciones; al guardar conserva los identificadores exactos de cuenta ya configurados, aunque no estén disponibles en el catálogo actual. Para añadir un identificador exacto de cuenta nuevo, usa `ocx agent subagents set` o edita la configuración. Tras la [migración única de Astra](#astra-roster-upgrade), se conserva una lista explícitamente vacía. | | `injectionModel?` | `string` | — | Modelo de subagente nativo o enrutado preferido que se menciona en la guía de delegación v2 redactada por el proxy. | @@ -44,14 +44,14 @@ El marcador interno `subagentModelsVersion` (actualmente `3`) hace que cada migr | `codexRoleCapability?` | `{ artificialAnalysis?: { apiKey?: string } }` | Sin fuente medida | Fuente de puntuaciones para la [asignación automática de roles](/es/guides/integrations/#auto-assign). Artificial Analysis usa tu clave API (literal, `${ENV}` o referencia del llavero) una vez al día para leer su lista gratuita de modelos; todos los dominios usan su Intelligence Index. Las puntuaciones no se incluyen en la respuesta. Si faltan datos o no están disponibles, se usan los niveles y precios configurados. La clave retirada `huggingFace` se ignora. | | `effortCap?` | `string` | — | Límite máximo para los turnos principales v2 que cumplan los requisitos y para los turnos de subagentes que lleven los marcadores correspondientes. Admite de `low` a `ultra`. | | `subagentEffortCap?` | `string` | — | Límite adicional solo para turnos de subagentes creados. Cuando se aplican ambos límites, prevalece el menor. | -| `plaintextV2AgentMessages?` | `boolean` | — (sin configurar) | Opción experimental que solo se ejecuta al establecerla explícitamente en `true`. Pide a los agentes principales v2 nativos de ChatGPT que cumplan los requisitos emitir en texto sin cifrar los argumentos de mensaje de `spawn_agent`, `send_message` y `followup_task`. Consulta [Mensajes de agentes v2 en texto sin cifrar](#plaintext-v2-agent-messages). | +| `plaintextV2AgentMessages?` | `boolean` | — (sin configurar) | Opción experimental que se activa con `true` explícito o con `multiAgentMode: "v2-plaintext"`. Pide a los agentes principales v2 nativos de ChatGPT que cumplan los requisitos emitir en texto sin cifrar los argumentos de mensaje de `spawn_agent`, `send_message` y `followup_task`. Consulta [Mensajes de agentes v2 en texto sin cifrar](#plaintext-v2-agent-messages). | | `agentTaskRecovery?` | `object` | — | Recuperación experimental de tareas v2 cifradas por el backend que se envían a proveedores enrutados. Está desactivada salvo que se indique `enabled: true`; consulta [Recuperación de tareas v2 cifradas](#encrypted-v2-task-recovery). | -Configura el modo desde el panel o con `ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. Los cambios de modo se aplican a sesiones nuevas. `maxConcurrentThreadsPerSession` es un campo de `PUT /api/v2`, no una clave de `config.json`; después de activar v2, `ocx v2 threads ` escribe `max_concurrent_threads_per_session` dentro de `[features.multi_agent_v2]` en el archivo `$CODEX_HOME/config.toml` de Codex. +Configura el modo desde el panel o con `ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. Para entrar en `v2-plaintext` se requiere `--acknowledge-plaintext-agent-messages`; la API exige `plaintextAgentMessagesAcknowledged` y el panel solicita consentimiento. Los cambios de asignación del catálogo se aplican a sesiones nuevas; el compilador de texto sin cifrar sigue la configuración actual para las solicitudes aptas, incluidas las posteriores de sesiones V2 existentes. `maxConcurrentThreadsPerSession` es un campo de `PUT /api/v2`, no una clave de `config.json`; después de activar v2, `ocx v2 threads ` escribe `max_concurrent_threads_per_session` dentro de `[features.multi_agent_v2]` en el archivo `$CODEX_HOME/config.toml` de Codex. El límite de subagentes de la página Modelos cuenta agentes secundarios. Un total V2 guardado de 1 solo permite el agente raíz y muestra 0 secundarios; configurar un secundario guarda un total V2 de 2. Los límites nuevos deben permitir al menos un secundario. Tanto esa cantidad como su conversión para la API deben ser enteros seguros, como máximo 9.007.199.254.740.991. Si V2 global está desactivado, incluido el modo híbrido, no se añade una posición para el agente raíz. La API rechaza límites inseguros con HTTP 400 antes de cambiar la función o la configuración. -**Delegación siempre proactiva**, en Subagentes → Avanzado (antes **Modo Ultra**), cambia el criterio para delegar sin cambiar el esfuerzo de razonamiento. Su configuración predefinida conserva las instrucciones del usuario, los límites de autoridad, el alcance de la tarea y las reglas de las herramientas. El interruptor del panel, el campo `multiAgentModeHintText` de `PUT /api/v2` y `ocx v2 mode-hint` escriben `features.multi_agent_v2.multi_agent_mode_hint_text` en el archivo `$CODEX_HOME/config.toml` de Codex. El comando `ocx v2 mode-hint` guarda esta clave incluso cuando `multi_agent_v2` está desactivado; no activa la función. Cuando la interfaz nativa está activa, esta indicación sustituye la política multiagente que codex-rs deriva del esfuerzo. Un valor `null` elimina la clave y restaura la política derivada del esfuerzo (ultra = proactiva; los demás niveles = explícita). Se rechazan los valores vacíos o compuestos solo por espacios, porque una anulación vacía suprimiría incluso el mensaje proactivo derivado de ultra. El interruptor **Delegación siempre proactiva** del panel de Subagentes requiere tanto la función nativa como un modo v2 explícito (`multiAgentMode: "v2"`, equivalente a `ocx v2 mode v2`); `ocx v2 on` por sí solo no cumple ese requisito. +**Delegación siempre proactiva**, en Subagentes → Avanzado (antes **Modo Ultra**), cambia el criterio para delegar sin cambiar el esfuerzo de razonamiento. Su configuración predefinida conserva las instrucciones del usuario, los límites de autoridad, el alcance de la tarea y las reglas de las herramientas. El interruptor del panel, el campo `multiAgentModeHintText` de `PUT /api/v2` y `ocx v2 mode-hint` escriben `features.multi_agent_v2.multi_agent_mode_hint_text` en el archivo `$CODEX_HOME/config.toml` de Codex. El comando `ocx v2 mode-hint` guarda esta clave incluso cuando `multi_agent_v2` está desactivado; no activa la función. Cuando la interfaz nativa está activa, esta indicación sustituye la política multiagente que codex-rs deriva del esfuerzo. Un valor `null` elimina la clave y restaura la política derivada del esfuerzo (ultra = proactiva; los demás niveles = explícita). Se rechazan los valores vacíos o compuestos solo por espacios, porque una anulación vacía suprimiría incluso el mensaje proactivo derivado de ultra. El interruptor **Delegación siempre proactiva** del panel de Subagentes requiere la función nativa con un modo v2 explícito (`multiAgentMode: "v2"`, equivalente a `ocx v2 mode v2`) o `multiAgentMode: "v2-plaintext"`, que usa asignaciones del catálogo con la anulación global desactivada; `ocx v2 on` por sí solo no cumple ese requisito. `GET` y `PUT /api/v2` también devuelven `multiAgentModeHintRecommendation: { text, revision }`. El panel usa ese texto del servidor al activar o restaurar la configuración predefinida, sin un texto alternativo fijo. Si un servidor anterior omite la recomendación o devuelve un valor mal formado, no se puede instalar ni restaurar la configuración predefinida; sigue siendo posible editar o borrar una indicación personalizada existente. **Restaurar configuración predefinida** solo cambia el borrador local; **Guardar** lo conserva. @@ -117,7 +117,7 @@ opencodex omite candidatos desactivados, sin ruta, con problemas de funcionamien ## Mensajes de agentes v2 en texto sin cifrar -`plaintextV2AgentMessages` no está configurado en una instalación nueva y solo se ejecuta al establecerlo explícitamente en `true`. El cliente debe usar el protocolo Responses y el destino final debe usar el transporte canónico de reenvío de ChatGPT Codex: `adapter: "openai-responses"`, `authMode: "forward"` y la URL base exacta `https://chatgpt.com/backend-api/codex`. Nunca se reescriben los proveedores de OpenAI con clave API, las pasarelas personalizadas compatibles con OpenAI, las rutas cuyo destino final sea otro proveedor ni las solicitudes que no usen Responses. +`plaintextV2AgentMessages` no está configurado en una instalación nueva. Un valor explícito `true` o `multiAgentMode: "v2-plaintext"` activa esta ruta experimental. El modo asigna V2 en el catálogo y desactiva la anulación global; entra mediante la confirmación del panel o `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages`. El modo conserva el booleano independiente, cuyo comportamiento anterior vuelve al salir. La compilación sigue la configuración actual en solicitudes canónicas aptas posteriores, incluso en sesiones V2 existentes. No descifra texto cifrado ni reescribe el historial; desactivarla no elimina copias retenidas en el historial, las solicitudes al proveedor o el estado local de respuesta y depuración. El cliente debe usar el protocolo Responses y el destino final debe usar el transporte canónico de reenvío de ChatGPT Codex: `adapter: "openai-responses"`, `authMode: "forward"` y la URL base exacta `https://chatgpt.com/backend-api/codex`. Nunca se reescriben los proveedores de OpenAI con clave API, las pasarelas personalizadas compatibles con OpenAI, las rutas cuyo destino final sea otro proveedor ni las solicitudes que no usen Responses. Para una solicitud v2 apta, opencodex reconoce el catálogo por un espacio de nombres `collaboration` de nivel superior con un hijo directo `spawn_agent`. El catálogo puede estar en `tools` de nivel superior o, cuando no existe `tools`, en `additional_tools` del primer elemento de entrada de desarrollador (Responses Lite). El catálogo explícito de nivel superior tiene prioridad; los catálogos de rol de usuario y los posteriores del historial no activan la opción. Se elimina `parameters.properties.message.encrypted: true`, si existe, únicamente de `spawn_agent`, `send_message` y `followup_task`. ChatGPT reserva tanto `collaboration` como esos tres nombres de herramientas, por lo que la solicitud usa alias privados fijos para las cuatro identidades. Antes del cambio, opencodex comprueba los catálogos de nivel superior y de `additional_tools`, los espacios de nombres anidados, las declaraciones `tool_search_output`, `tool_choice` y las llamadas anteriores para detectar conflictos con los alias o el espacio de nombres privado. Ante cualquier conflicto, toda la solicitud se conserva sin cambios. OpenCodex restaura solo los alias correspondientes a esa solicitud en las respuestas JSON, SSE y WebSocket antes de que Codex reciba la llamada. Se conserva `encrypted_function_args: []` para que los clientes compatibles reconozcan el mensaje como texto sin cifrar. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 1ef8e55d72c..b058cac30bf 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -194,6 +194,8 @@ fournisseur dédié n'annonce `supports_websockets = true` que lorsque `"websock bouclage, le fournisseur intégré de Codex peut tenter WebSocket en premier ; si cette fonction est désactivée, le proxy renvoie `426` et Codex se rabat sur HTTP/SSE. +Lorsque le mode `v2-plaintext` ou l’option `plaintextV2AgentMessages` est activé, la relecture locale exige le même appelant OpenCodex admis et le `thread-id` non vide propre à la requête. Un fil parent, une adresse de bouclage, des identifiants amont ou une clé de substitution ne constituent pas cette identité. Les clients sans principal d’appelant admis et les requêtes sans fil propre doivent renvoyer la conversation complète sans `previous_response_id`. Les configurations Codex et DSH de bouclage par défaut n’établissent pas cette identité. L’état conservé pendant l’activation de l’un ou l’autre réglage reste lié après sa désactivation. Dans le mode `v1` par défaut, l’état non lié conserve le comportement de continuation existant, limité au fil parent, y compris pour les appelants de bouclage par défaut. Cela reste distinct des ID Responses natifs appartenant au service amont. + ## Identité et historique du fil de discussion La configuration de bouclage par défaut conserve l'étiquette du fournisseur natif `openai` de Codex sur les diff --git a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md index 5941150a65e..14f079f64af 100644 --- a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md @@ -16,18 +16,19 @@ Choisissez le mode pour les **nouvelles sessions**. Les sessions existantes cons | Mode | Ce que Codex obtient | Qui devrait le choisir | | --- | --- | --- | -| **v1** | Outils classiques avec espace de noms `spawn_agent`, `send_input`, `resume_agent` et `close_agent`. Un spawn peut sélectionner directement un autre modèle. | Les débutants qui ont besoin d'une délégation fiable entre différents fournisseurs, en particulier les enfants natifs vers routés. | -| **base** (par défaut) | Paramètres de modèle en amont : GPT-5.6 Sol/Terra utilisent la v2, Luna utilise la v1, et les modèles non définis explicitement suivent l’indicateur de fonctionnalité `multi_agent_v2` de Codex. | La plupart des utilisateurs. Ce mode respecte la surface prévue par Codex pour chaque modèle, sans en imposer une globalement. | +| **v1** (par défaut pour une nouvelle installation) | Outils classiques avec espace de noms `spawn_agent`, `send_input`, `resume_agent` et `close_agent`. Un spawn peut sélectionner directement un autre modèle. | Les débutants qui ont besoin d'une délégation fiable entre différents fournisseurs, en particulier les enfants natifs vers routés. | +| **base** | Paramètres de modèle en amont : GPT-5.6 Sol/Terra utilisent la v2, Luna utilise la v1, et les modèles non définis explicitement suivent l’indicateur de fonctionnalité `multi_agent_v2` de Codex. | La plupart des utilisateurs. Ce mode respecte la surface prévue par Codex pour chaque modèle, sans en imposer une globalement. | | **v2** | Outils plats `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent` et liste d'agents, avec sessions simultanées. | Utilisateurs souhaitant utiliser le flux de travail simultané le plus récent et comprenant l'héritage de modèle et la limitation des tâches chiffrées ci-dessous. | +| **v2-plaintext** | V2 | Sélectionne V2 pour les nouvelles sessions et transmet les tâches admissibles en clair sans priorité globale. L’entrée exige `--acknowledge-plaintext-agent-messages`. | En **v2**, l'option facultative **Garder ChatGPT sur v1** (`keepNativeChatGptOnV1`) laisse Sol/Terra sur la surface v1 afin qu'ils puissent encore lancer Grok ou Claude. Les parents natifs ChatGPT chiffrent les corps `NEW_TASK` v2 ; les modèles routés ne peuvent pas les lire. Les parents routés restent sur v2, où les tâches enfants sont en texte clair. C'est un interrupteur *à l'intérieur* -de v2, pas un quatrième mode de catalogue. CLI : `ocx v2 mode v2` puis `ocx v2 keep-native-v1 on`. +de v2, distinct du mode `v2-plaintext`. CLI : `ocx v2 mode v2` puis `ocx v2 keep-native-v1 on`. :::tip[Pas sûr ?] -Commencez par **base**. Choisissez **v1** lorsque la délégation entre fournisseurs doit fonctionner de manière prévisible. Forcer **v2** +Commencez par **v1**, le mode par défaut d’une nouvelle installation, pour une délégation prévisible entre fournisseurs. Choisissez **base** pour suivre les paramètres en amont. Forcer **v2** uniquement lorsque vous souhaitez spécifiquement son modèle de session le plus récent dans chaque entrée de catalogue. ::: @@ -207,6 +208,10 @@ canoniques pour les tâches chiffrées. - **Tableau de bord** → **Délégation de sous-agent** : définissez les conseils model/effort et l'activation explicite natif par défaut. - **Sous-agents** : choisissez et ordonnez la liste, puis configurez la chaîne de repli globale. +Le champ expérimental `plaintextV2AgentMessages` est absent d’une configuration neuve. La valeur explicite `true` ou le mode distinct `v2-plaintext` active le compilateur ; quitter ce mode conserve la préférence indépendante. Le mode fixe V2 au catalogue pour les nouvelles sessions sans priorité globale, mais le compilateur suit le réglage actuel pour les requêtes admissibles ultérieures, y compris dans les sessions V2 existantes. Il ne déchiffre pas les tâches existantes et ne réécrit pas l’historique ; sa désactivation ne supprime pas les copies conservées. + +La requête doit utiliser Responses et la destination finale le transport ChatGPT Codex canonique : `adapter: "openai-responses"`, `authMode: "forward"` et `https://chatgpt.com/backend-api/codex`. Les fournisseurs à clé API OpenAI, passerelles compatibles personnalisées, autres destinations et clients non Responses sont exclus. Pour les appels V2 admissibles, le compilateur traite `spawn_agent`, `send_message` et `followup_task` sans requête de récupération supplémentaire. HTTPS reste chiffré, mais le texte peut être conservé dans l’historique Codex, les requêtes des fournisseurs routés et l’état local de réponse ou de débogage. Voir [messages d’agent v2 en clair](/reference/configuration/agents/#plaintext-v2-agent-messages). + ### CLI Utilisez `ocx v2` pour les paramètres de la surface de collaboration et des fonctionnalités natives : @@ -216,6 +221,7 @@ ocx v2 status ocx v2 mode v1 ocx v2 mode default ocx v2 mode v2 +ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages ocx v2 keep-native-v1 on ocx v2 keep-native-v1 off ocx v2 threads 8 diff --git a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md index c153742b1d9..93419358fc1 100644 --- a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md @@ -7,10 +7,23 @@ OpenCodex s'installe avec la surface de sous-agent réglée sur **v1**. Les page Dashboard, Models et Subagents demandent toutes confirmation avant de passer à **base** ou **v2** et renvoient vers cette page. La CLI ne demande rien. -La raison est précise : en v2, une tâche confiée par un modèle ChatGPT natif à -un modèle routé est illisible pour ce dernier. C'est le cas de délégation le -plus courant — un parent GPT qui lance un enfant Grok, Claude ou GLM — et en -v2, il échoue systématiquement. +La raison est précise : sur le chemin v2 chiffré inchangé, une tâche confiée par un modèle ChatGPT natif à un modèle routé est illisible pour ce dernier. Une telle tâche échoue à cette frontière, par exemple lorsqu’un parent GPT lance un enfant Grok, Claude ou GLM. L’option expérimentale ci-dessous modifie la production des nouveaux messages sans déchiffrer les anciens. + +## V2 facultatif avec messages de tâche en clair + +**V2 · tâches en clair** (`v2-plaintext`) sélectionne V2 pour les nouvelles conversations sans activer la priorité V2 globale. Il conserve les noms de tâche V2, la hiérarchie, les suivis, l’annulation et la concurrence, et utilise le compilateur expérimental en clair pour les requêtes Responses ChatGPT canoniques admissibles. Les conversations existantes conservent leur sélection ; une mise à jour ne choisit pas ce mode automatiquement. + +Les choix du catalogue concernent les nouvelles sessions. Le compilateur suit le réglage actuel pour chaque requête canonique admissible ultérieure, y compris dans les sessions V2 existantes. La sélection V2 conservée ne dispense pas ces requêtes de compilation. Le mode et la valeur explicite `plaintextV2AgentMessages: true` activent chacun indépendamment le compilateur. + +Dans ce mode, la continuation locale par identifiant de réponse exige la même identité de clé API opencodex admise et le `thread-id` propre à la requête. Un fil parent ne donne pas accès à la tâche conservée d’un enfant. Sans l’une de ces identités, après un redémarrage du proxy ou pour les anciennes entrées de cache non liées, renvoyez la conversation complète sans `previous_response_id`. L’état conservé dans ce mode reste lié après changement de mode. En `v1` par défaut, l’état non lié conserve le comportement de continuation limité au fil parent, y compris pour les appelants de bouclage. + +Le tableau de bord demande de reconnaître que le texte peut rester dans l’historique Codex, les requêtes du fournisseur sélectionné et l’état local de réponse et de débogage, y compris les instantanés ou fichiers de débordement sur disque. HTTPS et l’authentification restent actifs ; aucun appel de récupération supplémentaire n’est ajouté. Les tâches déjà chiffrées ne sont pas déchiffrées, l’historique n’est pas réécrit et la désactivation ne supprime pas les copies conservées. Un comportement non pris en charge du client ou du backend peut toujours échouer. Les modes v1 et natif-v1/routé-v2 restent disponibles. + +```bash +ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages +``` + +Pour la cible active, vérifiez ensuite `ocx v2 status --live --json`. Pour la cible locale, omettez `--live` dans les deux commandes et vérifiez `ocx v2 status --json` : l’état configuré et la convergence du catalogue ne prouvent pas qu’une délégation réelle réussit. Pour quitter ce mode, choisissez-en explicitement un autre ; la préférence indépendante `plaintextV2AgentMessages` est conservée. ## Ce que vous voyez alors @@ -100,7 +113,7 @@ affiche l'avis une fois et attend votre réponse. Chaque réponse est enregistrée et l'avis ne revient pas. Si vous fermez l'avis sans répondre, il réapparaît à la prochaine ouverture du tableau de bord. -Les changements de mode s'appliquent aux **nouvelles** sessions Codex. Lancez +Les changements de choix du catalogue s'appliquent aux **nouvelles** sessions Codex ; le compilateur en clair suit le réglage actuel pour les requêtes admissibles, y compris les requêtes ultérieures des sessions V2 existantes. Lancez une nouvelle session après votre choix. Si un hôte App durable affiche encore l'ancienne surface, exécutez `ocx sync` et redémarrez cette surface Codex. @@ -139,16 +152,14 @@ s'affichera plus et cette page relèvera de l'historique plutôt que du conseil. ## Changer de mode -Dashboard, Models et Subagents proposent le même sélecteur v1/base/v2 et -demandent tous confirmation avant base ou v2. Depuis la CLI : +Dashboard, Models et Subagents proposent le sélecteur v1/base/v2 et l’option `v2-plaintext` ; cette dernière exige un consentement explicite. Depuis la CLI : ```bash ocx v2 status ocx v2 mode v1 ``` -La CLI ne demande pas de confirmation. C'est le même paramètre : choisissez-le -en tenant compte de ce que décrit cette page. +La CLI ne demande pas de confirmation interactive ; l’entrée en `v2-plaintext` exige le drapeau de consentement indiqué ci-dessus. ## Limite de sous-agents simultanés diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 2f16c1c5fce..1da670f95a2 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -35,6 +35,10 @@ Une commande réussie renvoie le code 0. Une syntaxe non valide, une commande ou Les opérations de suppression destructive, d’importation, de consommation de crédits et de mise à jour qui annoncent une confirmation exigent `--yes` en mode non interactif. Ce drapeau constitue un consentement explicite : son absence ne doit jamais confirmer silencieusement l’opération. +## Entrer dans le mode expérimental de sous-agents en clair + +Utilisez `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`. Les commandes génériques `config set` et `config import` refusent une nouvelle entrée dans ce mode, car la commande dédiée coordonne aussi la transition native. Avant de restaurer une configuration exportée contenant `multiAgentMode: "v2-plaintext"`, effectuez d’abord cette transition avec consentement, puis importez le fichier. Restaurer un mode déjà actif, modifier d’autres paramètres et quitter ce mode restent possibles. La préférence indépendante `plaintextV2AgentMessages` reste inchangée. + ## Version et cibles de répartition internes `ocx --version`, `ocx -v` et `ocx version` affichent une seule ligne de version exploitable par un script, puis se terminent. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index a55deef9004..ac27bbf7361 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -27,9 +27,9 @@ qu'elle a déclenchée (`codexWebSearch` avec `--json`, une ligne `Codex config: sinon) et renvoie vers `ocx sync` quand elle n'a pas pu avoir lieu. L'option fonctionne aussi pour `vision`. -### `ocx v2 |threads |mode-hint >` +### `ocx v2 |threads |mode-hint >` -Gérez l'indicateur de fonctionnalité Codex `multi_agent_v2` et le mode surface multi-agents à trois états. +Gérez l'indicateur de fonctionnalité Codex `multi_agent_v2` et le mode surface multi-agents. | Sous-commande | Actions | | --- | --- | @@ -39,6 +39,7 @@ Gérez l'indicateur de fonctionnalité Codex `multi_agent_v2` et le mode surface | `mode v1` | Forcez tous les modèles à v1, désactivez la v2 native et préservez la limite de threads actifs. | | `mode default` | Respecter les axes de surface du modèle en amont. | | `mode v2` | Forcez tous les modèles à v2, activez la v2 native et préservez la limite de threads actifs. | +| `mode v2-plaintext --acknowledge-plaintext-agent-messages` | Sélectionnez V2 pour les nouvelles sessions et activez les tâches en clair admissibles sans priorité V2 globale. | | `threads ` | Définissez la limite de thread v1/v2 active sur un nombre entier d'au moins 1. | | `mode-hint ` | Définissez l’indice de délégation proactive (mode Ultra) pour chaque modèle et effort. | | `mode-hint --clear` | Supprimez l'indice pour que la politique dérivée de l'effort (ultra = proactive) reprenne. | @@ -56,7 +57,7 @@ ocx v2 mode-hint --clear La sous-commande `mode` écrit `multiAgentMode` dans la configuration opencodex et resynchronise le catalogue Codex. Les changements de mode et d’indicateur déplacent la limite numérique actuelle de fils entre les clés v1/v2 Codex valides ; une transition ratée restaure le `config.toml` original. Les modifications s'appliquent aux nouvelles sessions Codex, tandis que -les sessions de course conservent leur surface épinglée. +les sessions en cours conservent leur surface épinglée. Le compilateur en clair suit toutefois le réglage actuel pour les requêtes admissibles ultérieures, y compris dans les sessions V2 existantes. `mode-hint` écrit `features.multi_agent_v2.multi_agent_mode_hint_text` en Codex `$CODEX_HOME/config.toml` même si `multi_agent_v2` est actuellement désactivé. Le @@ -66,8 +67,7 @@ la politique multi-agents dérivée de l'effort du codex-rs, donc tout modèle e reçoit l’invite de délégation proactive. Cela ne change **pas** l'effort de raisonnement lui-même. Un argument manquant ou une valeur contenant uniquement des espaces est rejeté ; seulement `--clear` supprime l'indice. La bascule **on** du mode Ultra du tableau de bord Sous-agents a une -gate : il nécessite que la fonctionnalité native soit activée avec une surface v2 explicite -(`ocx v2 mode v2`); `ocx v2 on` à lui seul ne satisfait pas à cette porte du tableau de bord. +gate : il nécessite soit la fonctionnalité native activée avec `ocx v2 mode v2`, soit le mode `v2-plaintext`, fondé sur les choix du catalogue sans priorité globale. `ocx v2 on` à lui seul ne satisfait pas à cette porte du tableau de bord. ## Routage combiné diff --git a/docs-site/src/content/docs/fr/reference/configuration/agents.md b/docs-site/src/content/docs/fr/reference/configuration/agents.md index 1d6fab85615..8578d00ee4f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/fr/reference/configuration/agents.md @@ -9,7 +9,7 @@ Les paramètres des agents déterminent la surface de collaboration Codex annonc | Champ | Type | Valeur par défaut | Signification | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` marque tous les modèles du catalogue comme compatibles v1 ; `v2` les marque tous comme compatibles v2. `default` rétablit les choix imposés en amont (Sol/Terra en v2, Luna en v1) et suit sinon l’indicateur natif `multi_agent_v2`. S’applique aux nouvelles sessions. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` marque tous les modèles du catalogue comme compatibles v1 ; `v2` les marque tous comme compatibles v2. `default` rétablit les choix imposés en amont (Sol/Terra en v2, Luna en v1) et suit sinon l’indicateur natif `multi_agent_v2`. `v2-plaintext` active aussi la transmission expérimentale des tâches en clair et désactive la priorité globale. Les choix du catalogue s’appliquent aux nouvelles sessions ; la compilation activée affecte aussi les requêtes ultérieures admissibles des sessions V2 existantes. | | `keepNativeChatGptOnV1?` | `boolean` | `false` | Lorsque `multiAgentMode` vaut `"v2"`, marque les lignes natives ChatGPT (Sol/Terra et les autres modèles du backend ChatGPT) comme v1. Les parents routés restent en v2. Utilisez cette option pour qu'un parent ChatGPT puisse encore lancer Grok ou Claude — les tâches enfants v2 natives sont chiffrées par le service en amont ([#92](https://github.com/lidge-jun/opencodex/issues/92)). Ignoré en `v1` et `default`. | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | Jusqu’à cinq identifiants natifs non qualifiés, qualifiés par un compte `/` ou routés `provider/model`, affichés en tête du sélecteur de sous-agents. Le tableau de bord propose les identifiants natifs non qualifiés et routés pour les nouveaux choix. Lors de l’enregistrement, il conserve les identifiants exacts de compte déjà configurés, même absents du catalogue actuel. Pour ajouter un nouvel identifiant exact de compte, utilisez `ocx agent subagents set` ou modifiez la configuration. Après la [migration unique vers Astra](/reference/configuration/agents/#astra-roster-upgrade), une liste explicitement vide est conservée. | | `injectionModel?` | `string` | — | Modèle de sous-agent natif ou routé privilégié dans les consignes de délégation v2 produites par le proxy. | @@ -22,13 +22,18 @@ Les paramètres des agents déterminent la surface de collaboration Codex annonc | `subagentModelFallbackPollMs?` | `number` | `60000` | Durée de mise en cache de la sonde de disponibilité. Les valeurs inférieures à 1000 ms sont remplacées par la valeur par défaut. | | `effortCap?` | `string` | — | Plafond strict appliqué aux tours principaux v2 admissibles et aux tours enfants créés qui portent les marqueurs requis. Accepte les valeurs de `low` à `ultra`. | | `subagentEffortCap?` | `string` | — | Plafond supplémentaire réservé aux tours enfants créés. Lorsque les deux plafonds s’appliquent, le plus bas l’emporte. | +| `plaintextV2AgentMessages?` | `boolean` | — (non défini) | Option expérimentale : la valeur explicite `true` ou le mode `multiAgentMode: "v2-plaintext"` active la transmission en clair des arguments de message `spawn_agent`, `send_message` et `followup_task` admissibles. | | `agentTaskRecovery?` | `object` | — | Mécanisme expérimental, soumis à activation explicite, pour récupérer les tâches v2 chiffrées par le service en amont lorsqu’elles sont envoyées à des fournisseurs routés. Désactivé sauf si `enabled: true` ; voir [Récupération des tâches v2 chiffrées](#récupération-des-tâches-v2-chiffrées). | -Gérez la surface depuis le tableau de bord ou avec `ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. Les changements de mode s’appliquent aux nouvelles sessions. `maxConcurrentThreadsPerSession` est un champ de `PUT /api/v2`, et non une clé de `config.json`. Après l’activation de v2, `ocx v2 threads ` écrit `max_concurrent_threads_per_session` sous `[features.multi_agent_v2]` dans le fichier `$CODEX_HOME/config.toml` de Codex. +Gérez la surface depuis le tableau de bord ou avec `ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. L’entrée en `v2-plaintext` exige `--acknowledge-plaintext-agent-messages`. Les changements de choix du catalogue s’appliquent aux nouvelles sessions ; le compilateur en clair suit le réglage actuel pour les requêtes admissibles, y compris les requêtes ultérieures des sessions V2 existantes. `maxConcurrentThreadsPerSession` est un champ de `PUT /api/v2`, et non une clé de `config.json`. Après l’activation de v2, `ocx v2 threads ` écrit `max_concurrent_threads_per_session` sous `[features.multi_agent_v2]` dans le fichier `$CODEX_HOME/config.toml` de Codex. Le **mode Ultra** — accessible depuis l’interrupteur Sous-agents du tableau de bord, le champ `multiAgentModeHintText` de `PUT /api/v2` et `ocx v2 mode-hint` — écrit `features.multi_agent_v2.multi_agent_mode_hint_text` dans le fichier `$CODEX_HOME/config.toml` de Codex. La commande CLI `ocx v2 mode-hint` conserve cette clé même lorsque `multi_agent_v2` est désactivé ; elle n’active ni ne désactive la fonctionnalité. Cette indication remplace la politique multi-agents que codex-rs déduit du niveau d’effort : tous les modèles et tous les niveaux d’effort reçoivent alors le prompt de délégation Proactive. Elle ne modifie **pas** le niveau d’effort de raisonnement. -La valeur `null` supprime la clé et rétablit la politique fondée sur l’effort (proactive avec ultra, explicite dans les autres cas). Les valeurs vides ou composées uniquement d’espaces sont rejetées, car une substitution vide mais présente masquerait même le message Proactive associé au niveau ultra. Pour que l’interrupteur du mode Ultra soit **activé** dans la page Sous-agents du tableau de bord, la fonctionnalité native doit être active et la surface v2 doit être explicitement sélectionnée (`multiAgentMode: "v2"`, équivalent à `ocx v2 mode v2`). La seule commande `ocx v2 on` ne satisfait pas cette condition du tableau de bord. +La valeur `null` supprime la clé et rétablit la politique fondée sur l’effort (proactive avec ultra, explicite dans les autres cas). Les valeurs vides ou composées uniquement d’espaces sont rejetées, car une substitution vide mais présente masquerait même le message Proactive associé au niveau ultra. Pour activer l’interrupteur du mode Ultra, sélectionnez explicitement `multiAgentMode: "v2"` avec la fonctionnalité native active, ou `multiAgentMode: "v2-plaintext"`, qui utilise les choix du catalogue sans priorité globale. La seule commande `ocx v2 on` ne satisfait pas cette condition du tableau de bord. + +Sur la route Responses ChatGPT canonique (`adapter: "openai-responses"`, `authMode: "forward"`, `https://chatgpt.com/backend-api/codex`), la valeur explicite `plaintextV2AgentMessages: true` ou le mode `v2-plaintext` active indépendamment le compilateur des messages de tâche admissibles. Les autres destinations et les clients non Responses sont exclus. Le mode `v2-plaintext` sélectionne V2 pour les nouvelles conversations sans modifier la préférence indépendante `plaintextV2AgentMessages`. Le compilateur suit le réglage actuel pour les requêtes admissibles ultérieures, y compris dans les sessions V2 existantes. Il ne déchiffre pas les tâches existantes et ne réécrit pas l’historique. Sa désactivation ne supprime pas les copies déjà conservées dans l’historique, les requêtes du fournisseur ou l’état local de réponse et de débogage. + +Une entrée dans ce mode par `PUT /api/v2` exige `plaintextAgentMessagesAcknowledged: true`. Utilisez sinon la confirmation du tableau de bord ou `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages`. L’API de gestion expose `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models` et `/api/subagent-model-fallback`. Les mises à jour du modèle d’injection sont partielles ; le prompt personnalisé correspond au champ `prompt` de cette API. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 9f71b271f76..36e3c79048c 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -515,8 +515,13 @@ Retrying only the same ID cannot recover missing state. The same refusal applies when the referenced state belongs to a different client task scope, including when the new request appears to carry complete input. The proxy cannot prove that input is complete, so it does not silently remove `previous_response_id` or reveal whether matching state -exists. Retry with the complete conversation and omit `previous_response_id`; matching scopes and -legacy continuations where both scopes are absent or blank continue to replay normally. +exists. When `v2-plaintext` mode or the `plaintextV2AgentMessages` flag is enabled, local replay requires the same admitted OpenCodex caller and the request's own nonblank +`thread-id`; a parent thread, loopback address, upstream credential, or placeholder key is not +that identity. Clients without an admitted caller principal and requests without their own thread must resend the complete +conversation without `previous_response_id`. The default loopback Codex and DSH configurations +do not establish that caller identity. State retained while either setting is enabled remains bound after disabling it. +Default `v1` keeps the existing parent-thread-scoped continuation behavior for unbound state, +including default loopback callers. This is separate from native upstream-owned Responses IDs. The same recovery signal applies to every routed destination, because only the native Responses passthrough can answer a turn whose history this proxy lost — it forwards `previous_response_id` diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index e4a98fb0c04..87487cc4dc2 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -430,8 +430,7 @@ tier that Codex converts to `max`; opencodex then maps or clamps the value for t The model context cap is independent of sub-agent mode. Configure it on the Models page; native OpenAI models retain their real context windows. -The experimental `plaintextV2AgentMessages` field is unset in a fresh config and runs only when set -to `true`. The caller must use the Responses wire, and the final destination must use +The experimental `plaintextV2AgentMessages` field is unset in a fresh config. Explicit `true` or the separately selected `v2-plaintext` mode enables the compiler. That mode also selects V2 for new chats without enabling the global override; leaving it preserves the independent field. Compilation follows the current setting on subsequent eligible requests, including existing V2 sessions. Existing ciphertext is not decrypted or stored history rewritten, and disabling does not delete retained copies. The caller must use the Responses wire, and the final destination must use `adapter: "openai-responses"`, `authMode: "forward"`, and the exact base URL `https://chatgpt.com/backend-api/codex`. OpenAI API-key providers, custom compatible gateways, routes to other providers, and non-Responses callers are excluded. For an eligible new native diff --git a/docs-site/src/content/docs/guides/subagent-v1-default.md b/docs-site/src/content/docs/guides/subagent-v1-default.md index f97a8484b00..49eb2d6d48b 100644 --- a/docs-site/src/content/docs/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/guides/subagent-v1-default.md @@ -7,12 +7,27 @@ OpenCodex installs with the sub-agent surface set to **v1**. The Dashboard, Mode pages all ask you to confirm before moving to **base** or **v2**, and this page is what that confirmation links to. The CLI does not prompt. -The reason is narrow and specific: by default on v2, a task handed from a ChatGPT-native model to a routed +The reason is narrow and specific: on the unchanged encrypted v2 path, a task handed from a ChatGPT-native model to a routed model cannot be read by the routed model. That is the single most common way people delegate — -a GPT parent spawning a Grok, Claude or GLM child — and on v2 it fails by default. -Explicit exceptions are `keepNativeChatGptOnV1`, which keeps native ChatGPT entries on v1 while -routed entries use v2; an authorized destination that can process the encrypted task with -`allowEncryptedV2AgentTasks: true`; or experimental `agentTaskRecovery`, described below. +a GPT parent spawning a Grok, Claude or GLM child. An unreadable encrypted task fails at that boundary. The experimental opt-in below changes how new task messages are produced; it does not decrypt existing ones. + +Explicit exceptions are `keepNativeChatGptOnV1`, which keeps native ChatGPT entries on v1 while routed entries use v2; an authorized destination that can process the encrypted task with `allowEncryptedV2AgentTasks: true`; or experimental `agentTaskRecovery`, described below. + +## Optional V2 with plaintext task messages + +**V2 · plaintext tasks** (`v2-plaintext`) selects V2 for new chats while leaving the global V2 override off. It preserves V2 task names, hierarchy, follow-up messages, cancellation and concurrency, and reuses the experimental plaintext compiler for eligible canonical ChatGPT Responses requests. Existing chats retain their recorded selection. This is an additional explicit option; installing an update does not select it for you. + +Catalog pins apply to new sessions. The compiler follows the current setting on every eligible canonical ChatGPT request, including subsequent requests from existing V2 sessions. Keeping a session's recorded V2 selection does not exempt its later requests from compilation. + +In `v2-plaintext` mode, local response-ID continuations require the same admitted opencodex API-key identity and the request's own `thread-id`. A parent thread ID does not grant access to a child's retained task. Without either identity, after a proxy restart, or for older unbound cache entries, resend the full conversation without `previous_response_id`. State retained in this mode remains bound after switching modes. Default `v1` keeps the existing parent-thread-scoped continuation behavior for unbound state, including loopback callers. Opting out still leaves independently retained copies on disk. + +The dashboard requires acknowledgment that task text can remain in Codex history, selected-provider requests and local response/debug state, including on-disk continuation snapshots or spills. HTTPS and authentication remain enabled. No recovery model call is added. Existing ciphertext is not decrypted and stored history is not rewritten. Disabling the compiler does not delete retained copies. Unsupported backend/client behavior can still fail. The ordinary v1 and mixed native-v1/routed-v2 modes remain available. + +```bash +ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages +``` + +Read `ocx v2 status --live --json` afterward for the live target. For the local target, omit `--live` from both commands and read `ocx v2 status --json`; configured state and catalog convergence are distinct from a successful real delegation. To leave this mode, explicitly choose another mode. Independently set `plaintextV2AgentMessages` values are preserved. ## What you see when it happens @@ -42,10 +57,9 @@ stays in that backend. OpenCodex never had it, so there is nothing for a proxy t nothing to rewrite — the value really is ciphertext, not plaintext behind a flag. That is why this is structural rather than a misconfiguration: no proxy-side setting enables local decryption. -No proxy-side setting enables local decryption. Experimental `agentTaskRecovery` can instead ask the ChatGPT backend to recover eligible tasks; it does not decrypt their ciphertext locally. Its authorization, eligibility and failure limits described above still apply. +Experimental `agentTaskRecovery` can instead ask the ChatGPT backend to recover eligible tasks; it does not decrypt their ciphertext locally. Its authorization, eligibility and failure limits described above still apply. -Three topologies are unaffected, which is worth stating because it explains the shape of the -failure rather than the failure being mysterious: +The following comparison describes the encrypted v2 path without plaintext compilation: | Topology | v1 | v2 | | --- | --- | --- | @@ -72,13 +86,14 @@ OpenCodex recorded the consequence as [#92](https://github.com/lidge-jun/opencod closed as not planned: nothing in this repository can fix it, so the issue is a pointer to the upstream work rather than a task waiting on a maintainer here. -## What the three modes do now +## What the modes do now | Mode | Surface | When to choose it | | --- | --- | --- | | **v1** (default) | Every model advertises the classic namespaced spawn tools. A spawn can name another model directly. | Anyone who delegates across providers. This is the shipped default. | | **base** | Upstream model pins: Sol and Terra use v2, Luna uses v1, unpinned models follow Codex's own flag. | You want Codex's intended per-model surface and you only delegate within one provider. | | **v2** | Every model advertises the flat concurrent tools. | You want the newer concurrent session model and your parent and child are on the same side of the boundary. | +| **v2-plaintext** | V2 for new sessions, with the global override off and plaintext compilation for eligible requests. | You accept the retention of plaintext task messages and want the experimental path. | base is listed second rather than first because its pins put Sol and Terra — the two models most people delegate *from* — on v2. base is not a middle setting for this problem; for a @@ -95,7 +110,7 @@ existing setting; the dashboard raises the notice once and waits for an answer. Either answer is recorded, and the notice does not come back. Dismissing it without answering leaves it for the next time you open the dashboard. -Mode changes apply to **new** Codex sessions. Start a new session after choosing; if a long-running +Catalog pin changes apply to **new** Codex sessions; the plaintext compiler follows the current setting for eligible requests, including later requests from existing V2 sessions. Start a new session after choosing; if a long-running App host still shows the old surface, run `ocx sync` and restart that surface. ## If you want v2 anyway diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 57d16985e00..04806799c35 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -117,6 +117,8 @@ Windows では、ChatGPT/Codex アプリが `%USERPROFILE%\\.codex` を読み取 専用プロバイダー モードでは、`requires_openai_auth = true` は Codex App/TUI アカウント ゲート サーフェスをネイティブ Codex と一致させます。 opencodex は WebSocket 経由で `/v1/responses` も提供します。専用プロバイダーは、`"websockets": true` の場合にのみ `supports_websockets = true` をアドバタイズします。ループバック時 Codex の組み込みプロバイダーは最初に WebSocket を試行し、無効になったプロキシが `426` を返すため、Codex は HTTP/SSE にフォールバックします。 +`v2-plaintext` モードまたは `plaintextV2AgentMessages` フラグが有効な場合、ローカル replay には同じ許可された OpenCodex 呼び出し元と、リクエスト自身の空白でない `thread-id` が必要です。親スレッド、ループバックアドレス、上流の認証情報、プレースホルダーキーはそのアイデンティティではありません。許可された呼び出し元プリンシパルを持たないクライアントや、独自のスレッドを持たないリクエストは、`previous_response_id` を付けずに完全な会話を再送する必要があります。デフォルトのループバック Codex および DSH 構成は、その呼び出し元アイデンティティを確立しません。どちらかの設定が有効な間に保持された状態は、無効化後もそのアイデンティティに結び付いたままです。デフォルトの `v1` では、結び付けられていない状態について、デフォルトのループバック呼び出し元を含め、従来の親スレッドを単位とする継続動作が維持されます。これは上流が所有するネイティブ Responses ID とは別です。 + ## スレッドのアイデンティティと履歴 デフォルトのループバック形式では、Codex のネイティブ `openai` プロバイダーでタグ付けされた新しいスレッドが維持されるため、通常の再開履歴には再マッピングが必要ありません。同期と復元は、一致するバックアップマニフェストだけを適用し、各スレッドの元のプロバイダー、ソース、イベントマーカーを正確に復元します。マニフェストのない `opencodex` 行は変更されません。従来の再ラベル付けを明示的に強制する場合にだけ `ocx recover-history --legacy-openai --yes` を使用してください。このコマンドは意図的に広範囲です。ユーザーメッセージを持ち、現在 `opencodex` とタグ付けされているすべてのスレッドを `openai` に変更し、`exec` を `cli` に正規化してイベントマーカーを設定します。正当な専用プロバイダー履歴も対象です。状態をバックアップし、この全範囲を意図する場合にのみ使用してください。非ループバック専用プロバイダー モードでは、アクティブな間は `opencodex` プロバイダーの下で履歴がミラーリングされ、終了時にバックアップされたメタデータが復元されます。履歴を変更しないように `syncResumeHistory: false` を設定します。 diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index 25a084a0fca..2beb5054b0e 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -13,12 +13,13 @@ description: Codex がすべてのモデルにわたってサブエージェン |モード | Codex が得られるもの |誰がそれを選ぶべきですか | | --- | --- | --- | -| **v1** |クラシックな名前空間の `spawn_agent`、`send_input`、`resume_agent`、および `close_agent` ツール。スポーンは別のモデルを直接選択できます。 |さまざまなプロバイダー間で信頼できる委任を必要とする初心者、特にネイティブからルーティングされる子。 | -| **ベース** (デフォルト) |アップストリーム モデルのピン: GPT-5.6 Sol/Terra は v2 を使用し、Luna は v1 を使用し、ピンなしモデルは Codex の `multi_agent_v2` 機能フラグに従います。 |ほとんどのユーザー。グローバルに強制することなく、Codex が各モデルに対して意図したサーフェスに従います。 | +| **v1** (新規インストールのデフォルト) |クラシックな名前空間の `spawn_agent`、`send_input`、`resume_agent`、および `close_agent` ツール。スポーンは別のモデルを直接選択できます。 |さまざまなプロバイダー間で信頼できる委任を必要とする初心者、特にネイティブからルーティングされる子。 | +| **ベース** |アップストリーム モデルのピン: GPT-5.6 Sol/Terra は v2 を使用し、Luna は v1 を使用し、ピンなしモデルは Codex の `multi_agent_v2` 機能フラグに従います。 |ほとんどのユーザー。グローバルに強制することなく、Codex が各モデルに対して意図したサーフェスに従います。 | | **v2** |フラット `spawn_agent`、`send_message`、`followup_task`、`interrupt_agent`、およびエージェント リスト ツール (同時セッションあり)。 |新しい同時ワークフローを必要とし、モデルの継承と以下の暗号化されたタスクの制限を理解しているユーザー。 | +| **v2-plaintext** | V2 | 新しいセッションで V2 を選び、グローバル強制を無効にしたまま対象タスクを平文で送信します。開始には `--acknowledge-plaintext-agent-messages` が必要です。 | :::tip[わからない?] -**ベース**から始めます。プロバイダー間の委任が予測どおりに機能する必要がある場合は、**v1** を選択してください。すべてのカタログ エントリにわたって特に新しいセッション モデルが必要な場合にのみ、**v2** を強制します。 +新規インストールのデフォルトである **v1** から始めます。アップストリームのピンに従う場合は **ベース** を選択してください。すべてのカタログ エントリにわたって特に新しいセッション モデルが必要な場合にのみ、**v2** を強制します。 ::: ## 仕組み @@ -27,6 +28,7 @@ description: Codex がすべてのモデルにわたってサブエージェン - **v1** はすべてのモデルに `multi_agent_version = "v1"` を刻印します。 - **base** は上流のピンを復元します。固定されていないエントリは、ネイティブ `multi_agent_v2` 機能フラグに従います。 +- **v2-plaintext** はグローバル V2 強制を無効にしてすべてのモデルを V2 に固定し、対象の正規 ChatGPT 経路でタスクメッセージを平文にコンパイルします。 - **v2** はすべてのモデルに `multi_agent_version = "v2"` のスタンプを押します。ただし **ChatGPT を v1 に保つ** を有効にした場合は例外で、ChatGPT ネイティブの行は `"v1"` のまま、ルーティング/コンボの行は `"v2"` になります。 opencodex は、これを最終パスとしてライブ `/v1/models` カタログとディスクに同期されたカタログの両方に適用します。そのため、モードの変更は、新しく作成されたアプリ、CLI、および TUI セッションに一貫して影響します。 @@ -132,7 +134,7 @@ opencodex は、空のタスクまたは読み取り不可能なタスクを転 ### GUI -- **ダッシュボード** → 最初の統計セル: **v1**、**base**、または **v2** を選択します。 +- **ダッシュボード** → 最初の統計セル: **v1**、**base**、**v2**、または **v2-plaintext** を選択します。平文モードに入るには表示される同意確認が必要です。 - **モデル** → 一番上の行のセグメント化されたコントロール: 同じグローバル モードを選択します。 - **ダッシュボード** → **サブエージェント委任**: ガイダンス モデル/作業量およびネイティブ デフォルト オプトインを設定します。 - **サブエージェント**: ロスターを選択して順序付けし、グローバル フォールバック チェーンを構成します。 @@ -146,6 +148,7 @@ ocx v2 status ocx v2 mode v1 ocx v2 mode default ocx v2 mode v2 +ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages ocx v2 threads 8 ``` @@ -173,6 +176,8 @@ ocx agent effort set --subagent max | `/api/subagent-models` |最大 5 つのモデルの注文リスト | | `/api/subagent-model-fallback` |グローバルフォールバック順序とポーリング間隔 | +`PUT /api/v2` で新たに `v2-plaintext` に入るには、`multiAgentMode` とともに `plaintextAgentMessagesAcknowledged: true` を指定します。 + 例えば: ```bash @@ -205,7 +210,7 @@ Codex は最大 5 つの提示枠を、カタログから priority 順に埋め ### モードの変更は実行中のセッションに影響しますか? -いいえ。モードを変更した後、新しい Codex セッションを開始します。長時間実行されているアプリ ホストで依然として古いカタログ状態が表示される場合は、`ocx sync` を実行して、その Codex サーフェスを再起動します。 +カタログの選択は新しいセッションに適用され、実行中のセッションは固定されたサーフェスを維持します。ただし平文コンパイラーの設定は既存 V2 セッションの後続の対象リクエストにも適用されます。長時間実行されているアプリ ホストで依然として古いカタログ状態が表示される場合は、`ocx sync` を実行して、その Codex サーフェスを再起動します。 ### 推論負荷 @@ -214,3 +219,5 @@ Codex は最大 5 つの提示枠を、カタログから priority 順に埋め ### コンテキストキャップ モデル コンテキストの上限は、サブエージェント モードから独立しています。 「モデル」ページで設定します。ネイティブ OpenAI モデルは、実際のコンテキスト ウィンドウを保持します。 + +実験的な `plaintextV2AgentMessages` は新規設定では未設定です。明示的な `true` または独立した `v2-plaintext` モードのどちらでもコンパイラーが有効になり、モードを離れても独立したフラグは保持されます。呼び出し元は Responses を使い、最終送信先は `adapter: "openai-responses"`、`authMode: "forward"`、ベース URL `https://chatgpt.com/backend-api/codex` の正規 ChatGPT 経路である必要があります。他のプロバイダー、互換ゲートウェイ、非 Responses 呼び出しは対象外です。既存の暗号文は復号せず履歴も書き換えず、無効化しても保持済みのコピーは削除しません。[詳細](/reference/configuration/agents/#plaintext-v2-agent-messages)を参照してください。 diff --git a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md index 9643e73c7dc..8ed404e3352 100644 --- a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md @@ -3,9 +3,17 @@ title: サブエージェントの既定サーフェスが v1 である理由 description: v2 の暗号化タスクの制約で何が動かなくなるか、OpenCodex が v1 を既定とする理由、v2 を使う方法を説明します。 --- +カタログの選択は新しいセッションに適用されますが、有効なコンパイラーは既存の V2 セッションからの後続リクエストも、正規の ChatGPT Responses 経路で条件を満たす場合に処理します。無効化しても保持済みのコピーは削除されず、保存済み履歴も書き換えません。V2 のタスク名、階層、フォローアップ、キャンセル、同時実行は維持されます。更新によって自動的に有効にはなりません。 + +**実験的な選択肢: `v2-plaintext`。** グローバル V2 強制を有効にせず、新しいチャットで V2 を選択します。明示的な確認後、対象となる新しいタスクメッセージをアプリケーション層の平文で送信します。HTTPS と認証は維持されます。タスクの本文は履歴、選択したプロバイダーへのリクエスト、ローカルのデバッグ状態に残る可能性があります。既存の暗号文は復号しません。 + +コマンドは `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages` です。ライブ対象は `ocx v2 status --live --json` で確認します。ローカル対象では両方のコマンドから `--live` を省略し、`ocx v2 status --json` で確認します。設定状態とカタログの同期は実際の委任の成功とは別です。終了するには別のモードを明示的に選びます。独立した `plaintextV2AgentMessages` の値は保持されます。ダッシュボードでは、履歴、プロバイダーへのリクエスト、ディスク上の継続スナップショットや spill を含むローカル応答・デバッグ状態にタスク本文が残り得ることへの同意が必要です。復旧モデル呼び出しは追加されません。未対応のバックエンドやクライアントでは失敗する場合があります。通常の v1 とネイティブ v1/ルーティング済み v2 の混合モードも利用できます。 + +平文モードのローカル response ID 継続には、同じ許可済み opencodex API キーのアイデンティティとリクエスト自身の `thread-id` が必要です。親スレッド ID では子の保持タスクにアクセスできません。いずれかがない場合、プロキシ再起動後、または古い未バインドのキャッシュでは、`previous_response_id` を付けずに会話全体を再送します。保持された状態はモード切り替え後もバインドされたままです。既定の `v1` はループバック呼び出し元を含む未バインド状態について従来の親スレッド単位の継続を維持します。 + OpenCodex をインストールすると、サブエージェントのサーフェスは **v1** に設定されます。Dashboard、Models、Subagents の各ページでは、**base** または **v2** に移る前に確認を求め、その確認からこのページにリンクします。CLI では確認は表示されません。 -理由は明確です。v2 では、ChatGPT ネイティブモデルからルーティングされたモデルへ渡すタスクを、受け取ったモデルが読めません。GPT の親から Grok、Claude、GLM の子を作るのは一般的な委任方法ですが、v2 では毎回失敗します。 +理由は明確です。従来の暗号化された v2 経路では、ChatGPT ネイティブモデルからルーティングされたモデルへ渡すタスクを、受け取ったモデルが読めません。GPT の親から Grok、Claude、GLM の子を作るのは一般的な委任方法ですが、読めない暗号化タスクはその境界で失敗します。平文の選択肢は新しいタスクの生成方法を変えますが、既存の暗号文は復号しません。 ## 発生時に表示されるもの @@ -50,13 +58,14 @@ v2 では親が ChatGPT バックエンドで生成された `encrypted_content` OpenCodex は結果を [#92](https://github.com/lidge-jun/opencodex/issues/92) に記録し、対応予定なしとして閉じました。このリポジトリ内では修正できないため、ここでの作業待ちではなく、上流の作業への参照です。 -## 現在の 3 つのモード +## 現在のモード | モード | サーフェス | 選ぶ場合 | | --- | --- | --- | | **v1**(既定) | 全モデルが従来の名前空間付き生成ツールを公開します。生成時に別のモデルを直接指定できます。 | プロバイダー間で委任する場合。出荷時の既定値です。 | | **base** | 上流のモデル固定設定です。Sol と Terra は v2、Luna は v1、固定されないモデルは Codex 自身のフラグに従います。 | Codex がモデルごとに意図したサーフェスを使い、同じプロバイダー内だけで委任する場合。 | | **v2** | 全モデルがフラットな同時実行ツールを公開します。 | 新しい並行セッションモデルを使い、親と子が境界の同じ側にいる場合。 | +| **v2-plaintext** | グローバル強制を無効にして V2 に固定し、対象の正規 ChatGPT リクエストでタスクメッセージを平文化します。 | 平文の保持に同意し、V2 でプロバイダーをまたいで委任したい場合。 | base が先頭でなく 2 番目なのは、委任元としてよく使われる Sol と Terra が v2 に固定されるためです。この問題にとって base は中間設定ではなく、ChatGPT からルーティングされた子を作る場合は v2 と同様に動作します。 @@ -69,7 +78,7 @@ base が先頭でなく 2 番目なのは、委任元としてよく使われる どちらの回答も記録され、通知は再表示されません。回答せずに閉じると、次にダッシュボードを開いたときに再表示されます。 -モード変更は**新しい** Codex セッションに適用されます。変更後に新しいセッションを開始してください。長時間動作している App ホストで以前のサーフェスが残る場合は、`ocx sync` を実行してそのサーフェスを再起動します。 +カタログのモード選択は**新しい** Codex セッションに適用されます。平文コンパイラーは既存 V2 セッションの後続の対象リクエストにも適用されます。変更後に新しいセッションを開始してください。長時間動作している App ホストで以前のサーフェスが残る場合は、`ocx sync` を実行してそのサーフェスを再起動します。 ## それでも v2 を使う場合 @@ -88,7 +97,7 @@ base が先頭でなく 2 番目なのは、委任元としてよく使われる ## モードを変更する -Dashboard、Models、Subagents には同じ v1/base/v2 スイッチがあり、いずれも base または v2 の前に確認を求めます。CLI では次を実行します。 +Dashboard、Models、Subagents には同じ v1/base/v2/v2-plaintext スイッチがあり、平文モードに入る際は同意を求めます。base または v2 の前にも確認を求めます。CLI では次を実行します。 ```bash ocx v2 status diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index c43b4363845..1184ae3ebf9 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -38,6 +38,8 @@ Windows x64 インストールの観測は [`attest` コマンド](/ja/reference 確認を通知する破壊的な削除、インポート、クレジット消費、更新操作には、非対話型での `--yes` が必要です。このフラグは明示的なオプトインです。これを省略しても、黙ってアクションを確認してはなりません。 +実験的な平文サブエージェントモードに入るには `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages` を使います。専用コマンドはネイティブモードの移行も調整するため、汎用の `config set` と `config import` はこのモードへの新規移行を拒否します。`multiAgentMode: "v2-plaintext"` を含むエクスポート設定を復元する前に、同意付きの移行を完了してからインポートしてください。すでに有効なモードの復元、他の設定の変更、平文モードからの退出は可能です。独立した `plaintextV2AgentMessages` の設定は変わりません。 + ## バージョンと内部ディスパッチターゲット `ocx --version`、`ocx -v`、および `ocx version` は、スクリプト対応バージョンの 1 行を出力して終了します。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 961c01c9bf0..61977a82310 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -24,19 +24,23 @@ Codex 側の書き込み(`--json` では `codexWebSearch`、それ以外では `Codex config:` 行)を報告し、書き込みできなかった場合は `ocx sync` を案内します。 このフラグは `vision` でも機能します。 -### `ocx v2 |threads >` +### `ocx v2 |keep-native-v1 |threads |mode-hint >` -Codex `multi_agent_v2` 機能フラグとスリーステート マルチエージェント サーフェス モードを管理します。 +Codex `multi_agent_v2` 機能フラグと4 状態のマルチエージェント サーフェス モードを管理します。 |サブコマンド |アクション | | --- | --- | | `status` (デフォルト) |現在の v2 フラグ、マルチエージェント モード、およびスレッドの同時実行性をレポートします。 | -| `on` | `multi_agent_v2` 機能を有効にし、カタログを再同期します。 | +| `on` | グローバル `multi_agent_v2` を有効にし、ローカル変更ではカタログを再同期します。v2 ハイブリッドのピンが有効な間は拒否されます。 | | `off` | `multi_agent_v2` 機能を無効にし、カタログを再同期します。 | | `mode v1` |すべてのモデルを強制的に v1 にし、ネイティブ v2 を無効にして、アクティブなスレッド制限を保持します。 | | `mode default` |上流のモデル サーフェス ピンを尊重します。 | -| `mode v2` |すべてのモデルを強制的に v2 にし、ネイティブ v2 を有効にして、アクティブなスレッド制限を維持します。 | -| `threads ` |アクティブな v1/v2 スレッド制限を少なくとも 1 の整数に設定します。 +| `mode v2` |モデルを v2 にし、スレッド制限を維持します。`keep-native-v1` がオフならグローバル V2 を有効にし、オンならグローバル強制を無効にしてカタログのピンを使います。 | +| `mode v2-plaintext --acknowledge-plaintext-agent-messages` | 新しいセッションで V2 を選び、グローバル強制を無効にしたまま対象タスクを平文で送信します。開始には `--acknowledge-plaintext-agent-messages` が必要です。 | +| `keep-native-v1 on\|off` | `mode v2` で ChatGPT ネイティブを v1、ルーティング済みモデルを v2 に保ち、グローバル V2 強制を無効にします。 | +| `threads ` |アクティブな v1/v2 スレッド制限を少なくとも 1 の整数に設定します。 | +| `mode-hint ` | すべてのモデルと effort のプロアクティブ委任ヒントを設定します。 | +| `mode-hint --clear` | ヒントを削除し、effort に基づく方針に戻します。 | ```bash ocx v2 status @@ -46,7 +50,7 @@ ocx v2 on ocx v2 threads 16 ``` -`mode` サブコマンドは、`multiAgentMode` を opencodex 設定に書き込み、Codex カタログを再同期します。モードとフラグの遷移により、現在の数値スレッド制限が有効な v1/v2 Codex キー間で移動します。移行が失敗すると、元の `config.toml` が復元されます。変更は新しい Codex セッションに適用されますが、実行中のセッションでは固定されたサーフェスが維持されます。 +`mode` サブコマンドは、`multiAgentMode` を opencodex 設定に書き込み、Codex カタログを再同期します。モードとフラグの遷移により、現在の数値スレッド制限が有効な v1/v2 Codex キー間で移動します。移行が失敗すると、元の `config.toml` が復元されます。カタログの変更は新しい Codex セッションに適用され、実行中のセッションは固定されたサーフェスを維持します。ただし平文コンパイラーは既存 V2 セッションの後続の対象リクエストにも現在の設定を適用します。 ## コンボルーティング 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 d0623c713d8..4480402578e 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -9,7 +9,7 @@ description: マルチエージェント サーフェス、委任ガイダンス |フィールド |タイプ |デフォルト |意味 | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` はすべてのカタログ モデルを v1 としてスタンプします。 `v2` はすべてのモデルを v2 としてスタンプします。 `default` はアップストリーム ピン (Sol/Terra v2、Luna v1) を復元し、それ以外の場合はネイティブの `multi_agent_v2` フラグに従います。新しいセッションに適用されます。 | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` はすべてのカタログ モデルを v1 としてスタンプします。 `v2` はすべてのモデルを v2 としてスタンプします。 `default` はアップストリーム ピン (Sol/Terra v2、Luna v1) を復元し、それ以外の場合はネイティブの `multi_agent_v2` フラグに従います。`v2-plaintext` は新しいセッションのカタログを V2 に固定し、グローバル強制を無効にして対象タスクを平文にコンパイルします。有効なコンパイルは既存 V2 セッションの後続の対象リクエストにも適用されます。開始には `--acknowledge-plaintext-agent-messages` が必要です。 | | `subagentModels?` | `string[]` | `gpt-6-astra`、`gpt-6.1-sol`、`gpt-6-luna` | 最大 5 つの bare native id、account-qualified `/` id、または routed `provider/model` id をサブエージェントの選択欄に優先表示します。新しく選べるのは bare native id と routed id です。保存時には、現在のカタログにないものも含め、設定済みの正確な account-qualified id を保持します。新しい正確な account-qualified id を追加するには `ocx agent subagents set` を使うか設定を編集してください。[Astra への一度限りの移行](/reference/configuration/agents/#astra-roster-upgrade)後は明示的な空リストも保持されます。 | | `injectionModel?` | `string` | — |プロキシ作成の v2 委任ガイダンスで使用される、優先されるネイティブまたはルーティングされたサブエージェント モデル。 | | `injectionEffort?` | `string` | — |優先努力 (`low` ~ `ultra`)。`injectionModel` でのみ意味があります。 | @@ -21,14 +21,15 @@ description: マルチエージェント サーフェス、委任ガイダンス | `subagentModelFallbackPollMs?` | `number` | `60000` |可用性プローブのキャッシュ間隔。 1000 ミリ秒未満の値はデフォルトに戻ります。 | | `effortCap?` | `string` | — | v2 のメイン ターンとマークされた子ターンの条件を満たすためのハード シーリング。 `low` ~ `ultra` を受け入れます。 | | `subagentEffortCap?` | `string` | — |スポーンされた子のターンのみの追加の上限。両方の上限が適用される場合は、低い方が優先されます。 | +| `plaintextV2AgentMessages?` | `boolean` | — (未設定) | 実験的なオプトイン。明示的な `true` または `multiAgentMode: "v2-plaintext"` で、対象の ChatGPT ネイティブ V2 親に `spawn_agent`、`send_message`、`followup_task` のメッセージ引数を平文で出力させます。[詳細](#plaintext-v2-agent-messages)を参照してください。 | -ダッシュボードまたは `ocx v2 status|on|off|mode |threads ` でサーフェスを管理します。モードの変更は新しいセッションに適用されます。 `maxConcurrentThreadsPerSession` は `PUT /api/v2` フィールドであり、`config.json` キーではありません。 `ocx v2 threads ` は、v2 が有効になった後、Codex の `$CODEX_HOME/config.toml` の `[features.multi_agent_v2]` の下に `max_concurrent_threads_per_session` を書き込みます。 +ダッシュボードまたは `ocx v2 status|on|off|mode |threads ` でサーフェスを管理します。カタログのピンの変更は新しいセッションに適用されます。 `maxConcurrentThreadsPerSession` は `PUT /api/v2` フィールドであり、`config.json` キーではありません。 `ocx v2 threads ` は、v2 が有効になった後、Codex の `$CODEX_HOME/config.toml` の `[features.multi_agent_v2]` の下に `max_concurrent_threads_per_session` を書き込みます。 -管理 API は、`GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models`、および `/api/subagent-model-fallback` を公開します。インジェクションモデルの更新は部分的です。カスタム プロンプトは、その API の `prompt` フィールドです。 +管理 API は、`GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models`、および `/api/subagent-model-fallback` を公開します。`PUT /api/v2` で新たに `v2-plaintext` に入るには `plaintextAgentMessagesAcknowledged: true` が必要です。インジェクションモデルの更新は部分的です。カスタム プロンプトは、その API の `prompt` フィールドです。 ## 常時プロアクティブ委任 -Subagents → 詳細設定の **常時プロアクティブ委任**(旧 **Ultra mode**)は、推論のエフォートを変更せず、委任を開始する条件だけを変更します。推奨プリセットでも、ユーザーの指示、権限の境界、タスクの範囲、ツールの規則は維持されます。 +Subagents → 詳細設定の **常時プロアクティブ委任**(旧 **Ultra mode**)は、推論のエフォートを変更せず、委任を開始する条件だけを変更します。有効化にはグローバル V2 が有効な `v2` モードまたは `v2-plaintext` モードとサーバーの推奨値が必要です。既存ヒントの解除は V2 が無効でも可能です。推奨プリセットでも、ユーザーの指示、権限の境界、タスクの範囲、ツールの規則は維持されます。 `GET` と `PUT /api/v2` は、追加で `multiAgentModeHintRecommendation: { text, revision }` を返します。ダッシュボードはプリセットの有効化と復元にサーバー提供のテキストを使い、固定文へのフォールバックは行いません。旧サーバーが推奨値を返さない場合や値の形式が不正な場合、プリセットの適用と復元は利用できませんが、既存のカスタムヒントの編集と削除は引き続き利用できます。プリセットの復元はローカルの下書きだけを変更し、保存操作で確定します。 @@ -81,6 +82,12 @@ opencodex は、無効、ルーティング不能、異常、冷却期間、ま } ``` +## Plaintext v2 agent messages + +`plaintextV2AgentMessages` は新規設定では未設定です。明示的な `true` または独立した `multiAgentMode: "v2-plaintext"` で実験的な平文コンパイラーを有効にできます。後者はグローバル V2 強制を無効にしてカタログを V2 に固定します。ダッシュボードの確認、または `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages` で入ります。モードは独立した真偽値を上書きせず、退出後はその元の設定に従います。 + +コンパイラーは既存 V2 セッションの後続リクエストを含む、条件を満たす正規 ChatGPT リクエストに現在の設定を適用します。呼び出し元は Responses を使い、最終送信先は `adapter: "openai-responses"`、`authMode: "forward"`、正確なベース URL `https://chatgpt.com/backend-api/codex` を使う必要があります。OpenAI API キーのプロバイダー、独自ゲートウェイ、他のプロバイダーへのルート、非 Responses 呼び出しは対象外です。既存の暗号文は復号せず、保存済み履歴も書き換えません。無効化しても履歴、プロバイダーへのリクエスト、ローカル応答・デバッグ状態に保持されたコピーは削除されません。HTTPS は維持されます。[英語版の詳細](/reference/configuration/agents/#plaintext-v2-agent-messages)を参照してください。 + ## 推論負荷上限 キャップは v2 コラボレーション機能にのみ適用されます。メイン ターンは、そのツールが v2 を公開するときに資格を持ちますが、子ターンは、リーフ ツールがコラボレーションを公開しなくなった場合でも、`x-codex-turn-metadata` に正確な codex-rs `x-openai-subagent: collab_spawn` または `"subagent_kind": "thread_spawn"` マーカーが含まれるときに資格を持ちます。 V1 メイン ターン、`multiAgentMode: "v1"`、圧縮、レビュー、およびメモリ統合ターンはバイパス キャップです。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index e5adb56b101..b5631d2aa98 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -252,8 +252,12 @@ HTTP 클라이언트는 이 오류를 직접 처리하고 `previous_response_id` 참조한 상태의 client task scope가 현재 요청과 다를 때도 같은 거부 응답을 반환합니다. 새 요청이 전체 입력처럼 보여도 프록시는 그것이 완전한 대화인지 증명할 수 없으므로 `previous_response_id`를 조용히 제거하거나 일치하는 상태의 존재 여부를 노출하지 않습니다. -전체 대화를 담고 `previous_response_id`를 뺀 요청으로 명시적으로 다시 시도하세요. 정규화된 -scope가 같거나 양쪽 scope가 모두 없거나 비어 있는 기존 unscoped continuation은 계속 replay합니다. +`v2-plaintext` 모드 또는 `plaintextV2AgentMessages` 플래그가 활성화된 경우 로컬 재생에는 같은 수락된 OpenCodex 호출자와 요청 자체의 비어 있지 않은 `thread-id`가 필요합니다. +부모 스레드, 루프백 주소, 상류 인증 정보나 임시 키만으로는 이 소유권을 증명할 수 없습니다. +수락된 호출자 주체가 없거나 요청 자체의 스레드가 없는 클라이언트는 `previous_response_id` 없이 전체 대화를 다시 보내야 합니다. +기본 루프백 Codex·DSH 설정은 이 호출자 인증을 제공하지 않습니다. 어느 한 설정이 활성화된 동안 보존된 상태는 비활성화 후에도 해당 신원에 결속된 채로 남습니다. +기본 `v1`에서는 결속되지 않은 상태에 대해 기본 루프백 호출자를 포함하여 기존의 부모 스레드 범위 이어가기 동작이 유지됩니다. 네이티브 상류 서버가 소유한 +Responses ID의 처리 경로는 이 로컬 재생 규칙과 별개입니다. routed 목적지에는 모두 같은 복구 신호가 적용됩니다. 프록시가 잃어버린 기록을 대신 볼 수 있는 것은 네이티브 Responses 패스스루뿐입니다. 체인을 저장해 둔 백엔드로 `previous_response_id`를 diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index baece5604ce..8f7eced6f07 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -1,5 +1,5 @@ --- -title: 서브에이전트 서피스 (v1 / base / v2) +title: 서브에이전트 서피스 (v1 / base / v2 / v2-plaintext) description: Codex가 모든 모델에서 서브에이전트를 생성하고 관리하는 방식을 제어합니다. --- @@ -13,12 +13,13 @@ description: Codex가 모든 모델에서 서브에이전트를 생성하고 관 | 모드 | Codex에 제공되는 것 | 누구에게 적합한가 | | --- | --- | --- | -| **v1** | 네임스페이스 방식의 클래식 `spawn_agent`, `send_input`, `resume_agent`, `close_agent` 툴을 제공합니다. 스폰 시 다른 모델을 직접 고를 수 있습니다. | 서로 다른 프로바이더를 넘나드는 위임을 안정적으로 써야 하는 초보자에게 적합합니다. 특히 네이티브에서 라우팅된 자식으로 넘어가는 경우에 좋습니다. | -| **base** (기본값) | 업스트림 모델 핀을 따릅니다. GPT-5.6 Sol/Terra는 v2를, Luna는 v1을 사용하고, 핀이 없는 모델은 Codex의 `multi_agent_v2` 기능 플래그를 따릅니다. | 대부분의 사용자에게 적합합니다. 전역 강제 없이 각 모델에 대해 Codex가 의도한 서피스를 따릅니다. | +| **v1** (기본값) | 네임스페이스 방식의 클래식 `spawn_agent`, `send_input`, `resume_agent`, `close_agent` 툴을 제공합니다. 스폰 시 다른 모델을 직접 고를 수 있습니다. | 서로 다른 프로바이더를 넘나드는 위임을 안정적으로 써야 하는 초보자에게 적합합니다. 특히 네이티브에서 라우팅된 자식으로 넘어가는 경우에 좋습니다. | +| **base** | 업스트림 모델 핀을 따릅니다. GPT-5.6 Sol/Terra는 v2를, Luna는 v1을 사용하고, 핀이 없는 모델은 Codex의 `multi_agent_v2` 기능 플래그를 따릅니다. | 전역 강제 없이 각 모델에 대해 Codex가 의도한 서피스를 따르려는 사용자에게 적합합니다. | | **v2** | 플랫 `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent`, 에이전트 목록 툴을 제공하며, 동시 세션을 지원합니다. | 더 새로운 동시 워크플로를 원하고, 모델 상속과 아래의 암호화 작업 제한을 이해하는 사용자에게 적합합니다. | +| **v2-plaintext** | V2 | V2를 새 세션에 선택하고 전역 강제를 끈 채 적격 작업을 평문으로 전달합니다. 진입하려면 `--acknowledge-plaintext-agent-messages`가 필요합니다. | :::tip[헷갈리시나요?] -**base**부터 시작하세요. 서로 다른 프로바이더를 안정적으로 넘나드는 위임이 필요하면 **v1**을 고르세요. 모든 카탈로그 항목에서 더 새로운 세션 모델을 명시적으로 쓰고 싶을 때만 **v2**를 강제로 선택하세요. +서로 다른 프로바이더를 안정적으로 넘나드는 위임에는 기본값인 **v1**을 사용하세요. 업스트림 모델 핀을 따르려면 **base**를, 새로운 세션 모델을 명시적으로 쓰려면 **v2**를 선택하세요. **v2-plaintext**는 평문 작업 전달에 명시적으로 동의하는 선택지입니다. ::: ## 동작 방식 @@ -28,6 +29,7 @@ description: Codex가 모든 모델에서 서브에이전트를 생성하고 관 - **v1**은 모든 모델에 `multi_agent_version = "v1"`을 설정합니다. - **base**는 업스트림 핀을 복원합니다. 핀이 없는 항목은 기본 `multi_agent_v2` 기능 플래그를 따릅니다. - **v2**는 모든 모델에 `multi_agent_version = "v2"`를 설정합니다. 단 **ChatGPT를 v1로 유지**를 켜면 예외입니다: ChatGPT 네이티브 항목은 `"v1"`로 남고, 라우팅/콤보 항목은 `"v2"`가 됩니다. +- **v2-plaintext**는 전역 V2 강제를 끄고 새 세션의 카탈로그 항목을 V2로 고정하며, 적격 작업 메시지를 평문으로 컴파일합니다. opencodex는 이 값을 Codex가 읽는 실시간 `/v1/models` 카탈로그와 디스크에 동기화된 카탈로그 모두에 마지막 단계로 적용합니다. 그래서 모드를 바꾸면 새로 만들어지는 App, CLI, TUI 세션에 일관되게 반영됩니다. @@ -114,7 +116,9 @@ v1에는 선호 모델, 로스터, 폴백 목록, 사용자 정의 프롬프트 ## 암호화된 v2 작업 전달 -Codex는 v2 네이티브→라우팅 자식 작업을 백엔드 암호화된 `encrypted_content`로만 보낼 수 있습니다. 이 페이로드는 네이티브 ChatGPT 백엔드가 읽을 수 있지만 외부 프로바이더는 읽을 수 없습니다. 이것이 알려진 [#92 제한](https://github.com/lidge-jun/opencodex/issues/92)입니다. +암호화된 v2 경로에서는 Codex가 네이티브→라우팅 자식 작업을 백엔드 암호화된 `encrypted_content`로 보낼 수 있습니다. 이 페이로드는 네이티브 ChatGPT 백엔드가 읽을 수 있지만 외부 프로바이더는 읽을 수 없습니다. 이것이 알려진 [#92 제한](https://github.com/lidge-jun/opencodex/issues/92)입니다. + +새 설정에서 `plaintextV2AgentMessages`는 설정되지 않습니다. 이 필드를 명시적으로 `true`로 설정하거나 별도로 `v2-plaintext` 모드를 선택하면 컴파일러가 활성화됩니다. 이 모드는 새 채팅에 V2를 선택하고 전역 강제를 끄며, 모드를 벗어나도 독립적인 필드 값은 보존됩니다. 컴파일러는 현재 설정을 따라 기존 V2 세션의 후속 적격 요청에도 적용됩니다. 기존 암호문을 복호화하거나 저장된 이력을 다시 쓰지 않으며, 비활성화해도 보존된 사본은 삭제되지 않습니다. 호출자는 Responses 프로토콜을 사용해야 하고 최종 대상은 `adapter: "openai-responses"`, `authMode: "forward"`, 정확한 기본 URL `https://chatgpt.com/backend-api/codex`를 사용하는 공식 ChatGPT 전달 경로여야 합니다. OpenAI API 키 제공자, 사용자 지정 호환 게이트웨이, 다른 제공자로의 라우트와 Responses가 아닌 호출자는 제외됩니다. opencodex는 읽을 수 없거나 빈 작업을 그대로 넘기지 않고 안전하게 실패합니다. @@ -130,7 +134,7 @@ opencodex는 읽을 수 없거나 빈 작업을 그대로 넘기지 않고 안 ### GUI -- **Dashboard** → 첫 번째 상태 셀: **v1**, **base**, **v2**를 고릅니다. +- **Dashboard** → 첫 번째 상태 셀: **v1**, **base**, **v2**, **v2-plaintext**를 고릅니다. 평문 모드 진입에는 작업 텍스트 보존에 대한 확인이 필요합니다. - **Models** → 맨 위 행의 세그먼트 선택기: 같은 전역 모드를 고릅니다. - **Dashboard** → **Sub-agent delegation**: 가이드 모델/추론 강도와 네이티브 기본값 사용 여부를 설정합니다. - **Subagents**: 로스터를 선택하고 순서를 정한 뒤 전역 폴백 체인을 설정합니다. @@ -163,6 +167,8 @@ ocx agent effort set --subagent max 관리 API는 대응하는 `GET`과 `PUT` 엔드포인트를 제공합니다. +`PUT /api/v2`로 `v2-plaintext`에 진입하려면 `plaintextAgentMessagesAcknowledged: true`가 필요합니다. + | 엔드포인트 | 관리하는 항목 | | --- | --- | | `/api/v2` | 서피스 모드, 네이티브 기능 플래그, 스레드 설정 | diff --git a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md index 61343fbe441..a80462c876b 100644 --- a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md @@ -5,7 +5,23 @@ description: v2의 암호화 작업 제한으로 생기는 문제, opencodex가 opencodex를 설치하면 서브에이전트 서피스가 **v1**로 설정됩니다. Dashboard, Models, Subagents 페이지에서 **base**나 **v2**로 바꾸려면 확인을 요청하며, 그 확인 창에서 이 문서로 연결됩니다. CLI에서는 확인 메시지가 나오지 않습니다. -이유는 분명합니다. v2에서 ChatGPT 네이티브 모델이 라우팅된 모델에 맡긴 작업은 라우팅된 모델이 읽을 수 없습니다. GPT 부모가 Grok, Claude, GLM 자식을 만드는 것은 가장 흔한 위임 방식인데, v2에서는 매번 실패합니다. +이유는 암호화된 v2 전달 경로에 있습니다. ChatGPT 네이티브 모델이 만든 작업 암호문은 다른 제공자가 읽을 수 없어 경계에서 실패합니다. 아래 실험적 선택지는 새 작업 메시지를 평문으로 생성하도록 바꾸며, 기존 암호문을 복호화하지 않습니다. + +## 선택 기능: V2와 평문 작업 전달 + +**V2 · 평문 작업 전달**(`v2-plaintext`)은 전역 V2 강제를 끈 상태에서 새 채팅의 기본 선택을 V2로 지정합니다. V2 작업 이름·계층·후속 메시지·취소·동시 실행 기능을 유지하고, 조건에 맞는 공식 ChatGPT Responses 전달에 기존 실험적 평문 컴파일러를 사용합니다. 기존 채팅은 기록된 선택을 유지합니다. 업데이트만으로 이 모드가 선택되지는 않습니다. + +카탈로그 고정값은 새 세션에 적용됩니다. 컴파일러는 요청 시점의 설정에 따라 동작하므로, 기존 V2 세션의 후속 요청도 조건에 맞는 공식 ChatGPT 경로라면 적용 대상입니다. 기록된 V2 선택을 유지하는 것과 후속 요청의 컴파일 여부는 별개입니다. + +`v2-plaintext` 모드에서 로컬 응답 ID 이어가기에는 동일하게 허용된 opencodex API 키 신원과 요청 자체의 `thread-id`가 필요합니다. 부모 스레드 ID로 자식의 보존된 작업에 접근할 수는 없습니다. 어느 하나라도 없거나 프록시를 재시작한 경우, 또는 예전에 저장된 결속되지 않은 캐시 항목이라면 `previous_response_id` 없이 전체 대화를 다시 보내세요. 이 모드에서 보존된 상태는 모드를 바꾼 뒤에도 결속됩니다. 기본 `v1`은 루프백 호출자를 포함해 결속되지 않은 상태에 기존의 부모 스레드 범위 이어가기 동작을 유지합니다. 사용을 중단해도 별도로 보존된 사본은 디스크에 남습니다. + +대시보드에서는 작업 텍스트가 Codex 이력, 선택한 제공자의 요청, 디스크에 저장되는 연속 대화 스냅샷·spill 파일을 포함한 로컬 응답·디버그 상태에 평문으로 남을 수 있음을 확인해야 합니다. HTTPS와 인증은 유지되며 복구용 모델 호출을 추가하지 않습니다. 기존 암호문을 복호화하거나 저장된 기록을 다시 쓰지 않습니다. 컴파일러를 비활성화해도 보존된 사본은 삭제되지 않습니다. 지원되지 않는 백엔드·클라이언트 동작은 실패할 수 있습니다. 일반 v1 및 네이티브 v1/라우팅 v2 혼합 모드도 유지됩니다. + +```bash +ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages +``` + +라이브 대상은 `ocx v2 status --live --json`으로 확인하세요. 로컬 대상은 두 명령에서 `--live`를 빼고 `ocx v2 status --json`으로 확인하세요. 설정 저장·카탈로그 반영과 실제 위임 성공은 별도 검증입니다. 다른 모드를 명시적으로 선택하면 이 모드에서 나옵니다. 별도로 설정한 `plaintextV2AgentMessages` 값은 보존됩니다. ## 문제가 생기면 보이는 것 @@ -28,11 +44,11 @@ HTTP 400을 반환하며 암호문은 응답에 포함하지 않습니다. 읽 v1에서는 부모가 자식 작업을 평문으로 내보냅니다. opencodex가 이를 읽고 라우팅하므로 자식이 실행할 수 있는 작업을 받습니다. -v2에서는 부모가 ChatGPT 백엔드에서 만든 `encrypted_content`로 작업을 내보냅니다. 키는 그 백엔드에만 있습니다. opencodex에는 키가 없으므로 프록시가 복호화하거나 다시 쓸 수 없습니다. 플래그 뒤에 숨은 평문이 아니라 실제 암호문입니다. 따라서 설정 오류가 아니라 구조적인 문제이며 프록시 설정으로 로컬 복호화를 활성화할 수 없습니다. +암호화된 v2 경로에서는 부모가 ChatGPT 백엔드에서 만든 `encrypted_content`로 작업을 내보냅니다. 키는 그 백엔드에만 있습니다. opencodex에는 키가 없으므로 프록시가 복호화하거나 다시 쓸 수 없습니다. 플래그 뒤에 숨은 평문이 아니라 실제 암호문입니다. 평문 모드는 새 적격 작업을 다르게 생성하지만 기존 암호문을 해결하지는 못합니다. 프록시 설정으로 로컬 복호화를 활성화할 수는 없습니다. 실험적인 `agentTaskRecovery`는 적격 작업을 ChatGPT 백엔드에 요청해 복구할 수 있지만 로컬에서 암호문을 해독하지는 않습니다. 위의 승인, 적격성 및 실패 제한은 그대로 적용됩니다. -영향받지 않는 연결 방식은 세 가지입니다. 어떤 경계에서 실패하는지 보면 이유가 드러납니다. +다음 비교는 평문 컴파일러를 사용하지 않는 암호화된 v2 경로를 설명합니다. | 연결 방식 | v1 | v2 | | --- | --- | --- | @@ -48,15 +64,16 @@ v2에서는 부모가 ChatGPT 백엔드에서 만든 `encrypted_content`로 작 송신 측 문제는 아직 열려 있습니다. Windows, macOS, Linux의 CLI 0.146~0.151에서 재현된 [#36376](https://github.com/openai/codex/issues/36376)과 송신 측 전달 정책의 부재를 직접 지적한 [#37197](https://github.com/openai/codex/issues/37197)입니다. 어느 쪽에도 유지관리자의 해결 약속이나 예정 시점이 없습니다. -opencodex는 결과를 [#92](https://github.com/lidge-jun/opencodex/issues/92)에 기록하고 계획 없음으로 닫았습니다. 이 저장소에서 해결할 수 없으므로 이슈는 여기 유지관리자를 기다리는 작업이 아니라 업스트림 작업을 가리킵니다. +opencodex는 암호화된 경로의 결과를 [#92](https://github.com/lidge-jun/opencodex/issues/92)에 기록하고 계획 없음으로 닫았습니다. 기존 암호문을 읽으려면 업스트림 지원이 필요하며, 평문 모드는 새 적격 작업을 위한 별도의 선택지입니다. -## 현재 세 모드의 동작 +## 현재 모드의 동작 | 모드 | 서피스 | 선택할 때 | | --- | --- | --- | | **v1** (기본값) | 모든 모델이 기존 네임스페이스 방식 스폰 도구를 표시합니다. 스폰 시 다른 모델을 직접 지정할 수 있습니다. | 프로바이더 간 위임을 사용하는 경우. 설치 시 기본값입니다. | | **base** | 업스트림 모델 핀을 따릅니다. Sol과 Terra는 v2, Luna는 v1을 사용하며, 핀이 없는 모델은 Codex 자체 플래그를 따릅니다. | Codex가 모델별로 의도한 서피스를 원하고 한 프로바이더 안에서만 위임하는 경우. | | **v2** | 모든 모델이 새로운 플랫 동시 실행 도구를 표시합니다. | 새로운 동시 세션 모델을 원하고 부모와 자식이 같은 경계 쪽에 있는 경우. | +| **v2-plaintext** | 전역 강제를 끄고 새 세션에 V2를 선택하며 적격 작업 메시지를 평문으로 컴파일합니다. | 평문 작업 전달과 보존 가능성에 명시적으로 동의하는 경우. | base가 첫 번째가 아니라 두 번째인 이유는 대부분의 위임 출발점인 Sol과 Terra가 핀에 따라 v2를 사용하기 때문입니다. 이 문제에서 base는 절충 설정이 아닙니다. ChatGPT에서 라우팅된 모델로 스폰할 때는 v2처럼 동작합니다. @@ -69,7 +86,7 @@ base가 첫 번째가 아니라 두 번째인 이유는 대부분의 위임 출 어느 쪽이든 응답이 기록되어 알림이 다시 나타나지 않습니다. 응답하지 않고 닫으면 다음 대시보드 방문 때 다시 보입니다. -모드 변경은 **새** Codex 세션에 적용됩니다. 선택한 뒤 새 세션을 시작하세요. 오래 실행된 App 호스트에 예전 서피스가 남아 있으면 `ocx sync`를 실행하고 해당 서피스를 다시 시작하세요. +카탈로그 고정값 변경은 **새** Codex 세션에 적용됩니다. 평문 컴파일러는 현재 설정에 따라 기존 V2 세션의 후속 적격 요청도 처리합니다. 선택한 뒤 새 세션을 시작하세요. 오래 실행된 App 호스트에 예전 서피스가 남아 있으면 `ocx sync`를 실행하고 해당 서피스를 다시 시작하세요. ## 그래도 v2를 원한다면 @@ -88,7 +105,7 @@ base가 첫 번째가 아니라 두 번째인 이유는 대부분의 위임 출 ## 모드 변경 -Dashboard, Models, Subagents 모두 같은 v1/base/v2 스위치를 제공하며 base나 v2를 선택하면 확인을 요청합니다. CLI에서는 다음을 실행하세요. +Dashboard, Models, Subagents에서 모드를 선택할 수 있으며 평문 모드 진입에는 별도의 확인이 필요합니다. CLI에서는 다음을 실행하세요. ```bash ocx v2 status diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 26c7c0ff0a7..60622cf132b 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -55,6 +55,8 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 `ocx config validate `과 `ocx config import --yes`는 선행 BOM이 있는 UTF-8 JSON도 읽습니다. 표준 입력(`-`)에도 적용되므로 Windows PowerShell이나 편집기의 UTF-8 내보내기를 사용할 수 있습니다. UTF-16은 지원하지 않습니다. +실험적인 평문 서브에이전트 모드에 진입하려면 `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`를 사용하세요. 일반 `config set`과 `config import`는 새로 이 모드에 진입하는 요청을 거부합니다. 전용 명령이 네이티브 모드 전환도 함께 처리하기 때문입니다. `multiAgentMode: "v2-plaintext"`가 포함된 내보내기 파일을 복원하려면 먼저 확인을 거쳐 해당 모드로 전환한 다음 파일을 가져오세요. 이미 활성화된 모드를 복원하거나 다른 설정을 변경하거나 평문 모드에서 나가는 것은 허용됩니다. 독립적인 `plaintextV2AgentMessages` 설정은 변경되지 않습니다. + ## Default alias listing `ocx alias --json`은 `ocx alias list --json`과 같습니다. `--json`은 명시한 alias 작업의 앞이나 뒤에 둘 수 있습니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 102cff039a0..18c91eaafa6 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -46,9 +46,9 @@ ocx effort set --subagent - 설정을 유지하면서 두 상한을 해제합니다. 상한이 적용되는 요청 surface는 [Sub-agent surfaces](/ko/guides/sub-agent-surface/)를 참고하세요. -### `ocx v2 |threads >` +### `ocx v2 |keep-native-v1 |threads |mode-hint >` -Codex `multi_agent_v2` 기능 플래그와 세 상태 멀티 에이전트 surface mode를 관리합니다. +Codex `multi_agent_v2` 기능 플래그와 네 상태 멀티 에이전트 surface mode를 관리합니다. | 하위 명령 | 동작 | | --- | --- | @@ -57,8 +57,12 @@ Codex `multi_agent_v2` 기능 플래그와 세 상태 멀티 에이전트 surfac | `off` | `multi_agent_v2` 기능을 끄고 catalog를 다시 동기화합니다. | | `mode v1` | 모든 모델을 v1으로 고정하고, native v2를 끄며, 활성 thread 한도를 유지합니다. | | `mode default` | 업스트림에서 지정한 model surface pin을 따릅니다. | -| `mode v2` | 모든 모델을 v2로 고정하고, native v2를 켜며, 활성 thread 한도를 유지합니다. | +| `mode v2` | 모델을 v2로 고정하고 활성 thread 한도를 유지합니다. `keep-native-v1`이 꺼져 있으면 전역 native v2를 켜고, 켜져 있으면 전역 강제를 끄고 catalog pin을 사용합니다. | +| `mode v2-plaintext --acknowledge-plaintext-agent-messages` | V2를 새 세션에 선택하고 전역 강제를 끈 채 적격 작업을 평문으로 전달합니다. 진입하려면 `--acknowledge-plaintext-agent-messages`가 필요합니다. | +| `keep-native-v1 on\|off` | `mode v2`에서 ChatGPT 네이티브 모델을 v1로, 라우팅된 모델을 v2로 유지합니다. 켜면 catalog 동기화 전에 전역 V2 강제를 끕니다. | | `threads ` | 활성 v1/v2 thread 한도를 1 이상의 정수로 설정합니다. | +| `mode-hint ` | 모든 모델과 추론 노력에 능동 위임 힌트(Ultra 모드)를 설정합니다. | +| `mode-hint --clear` | 힌트를 제거해 추론 노력에 따른 정책을 복원합니다. | ```bash ocx v2 status @@ -70,7 +74,9 @@ ocx v2 threads 16 `mode` 하위 명령은 `multiAgentMode`를 opencodex config에 쓰고 Codex catalog를 다시 동기화합니다. mode와 flag 전환은 현재 숫자 thread 한도를 유효한 v1/v2 Codex key 사이로 옮깁니다. -전환이 실패하면 원래의 `config.toml`이 복원됩니다. 변경은 새 Codex 세션에만 적용되고, 실행 중인 세션은 고정된 surface를 유지합니다. +전환이 실패하면 원래의 `config.toml`이 복원됩니다. 카탈로그 변경은 새 Codex 세션에 적용되고 실행 중인 세션은 고정된 surface를 유지하지만, 활성화된 평문 컴파일러는 기존 V2 세션의 후속 적격 요청에도 적용됩니다. + +Subagents 대시보드의 Ultra 모드 스위치는 네이티브 기능이 켜진 명시적 `ocx v2 mode v2` 또는 전역 강제 없이 카탈로그 pin을 사용하는 `v2-plaintext` 모드가 필요합니다. `ocx v2 on`만으로는 이 조건을 충족하지 않습니다. ## 콤보 라우팅 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 67e758e90f6..e2a74761a37 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -3,13 +3,15 @@ title: 에이전트 설정 description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 체인, 기본값 동기화, 노력 상한을 다룹니다. --- +`multiAgentMode: "v2-plaintext"`는 새 채팅에 V2를 선택하면서 전역 V2 강제를 끄고, 조건에 맞는 공식 ChatGPT Responses 경로에 실험적 평문 작업 전달을 적용하는 명시적 선택입니다. 대시보드 확인 또는 `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages`로 진입합니다. 작업 텍스트는 이력·선택한 제공자의 요청·로컬 디버그 상태에 남을 수 있지만 HTTPS와 인증은 유지됩니다. 기존 암호문을 복호화하지 않으며 기존 채팅 기록도 변경하지 않습니다. 별도 `plaintextV2AgentMessages` 설정은 다른 모드로 돌아갈 때도 보존됩니다. 컴파일러는 현재 설정을 따르므로 기존 V2 세션의 후속 적격 요청도 적용 대상입니다. 비활성화해도 이력·제공자 요청·로컬 응답·디버그 상태에 이미 보존된 사본은 삭제되지 않습니다. + 에이전트 설정은 어떤 Codex 협업 표면을 노출할지와, opencodex가 위임 작업을 어떻게 안내하고, 라우팅하고, 제한할지를 제어합니다. ## 에이전트 필드 | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1`은 카탈로그의 모든 모델에 v1을 표시하고, `v2`는 모든 모델에 v2를 표시합니다. `default`는 상위 고정값(Sol/Terra는 v2, Luna는 v1)을 복원하고, 그 외에는 네이티브 `multi_agent_v2` 플래그를 따릅니다. 새 세션에 적용됩니다. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1`은 카탈로그의 모든 모델에 v1을 표시하고, `v2`는 모든 모델에 v2를 표시합니다. `default`는 상위 고정값(Sol/Terra는 v2, Luna는 v1)을 복원하고, 그 외에는 네이티브 `multi_agent_v2` 플래그를 따릅니다. `v2-plaintext`는 실험적 평문 작업 전달을 함께 켜고 전역 강제는 끕니다. 카탈로그 고정값은 새 세션에 적용되며, 활성화된 컴파일러는 기존 V2 세션의 후속 적격 요청에도 적용됩니다. | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | 최대 다섯 개의 bare native id, account-qualified `/` id 또는 routed `provider/model` id를 서브에이전트 선택기에 우선 표시합니다. 새 선택 항목으로는 bare native와 routed id를 제공합니다. 저장할 때는 현재 카탈로그에 없는 항목을 포함하여 이미 설정된 정확한 account-qualified id를 보존합니다. 새 정확한 account-qualified id를 추가하려면 `ocx agent subagents set`을 사용하거나 설정을 편집하세요. [Astra 최초 업그레이드](/reference/configuration/agents/#astra-roster-upgrade) 이후에는 명시적으로 빈 목록도 보존됩니다. | | `injectionModel?` | `string` | — | 프록시가 작성한 v2 위임 안내에서 사용하는 선호 네이티브 또는 라우팅된 서브에이전트 모델입니다. | | `injectionEffort?` | `string` | — | 선호 노력(`low`부터 `ultra`까지)입니다. `injectionModel`이 있을 때만 의미가 있습니다. | @@ -21,15 +23,22 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 | `subagentModelFallbackPollMs?` | `number` | `60000` | 사용 가능성 검사 캐시 간격입니다. 1000 ms 미만의 값은 기본값으로 돌아갑니다. | | `effortCap?` | `string` | — | 자격을 갖춘 v2 메인 턴과 표시된 생성 하위 턴에 대한 하드 상한입니다. `low`부터 `ultra`까지 허용합니다. | | `subagentEffortCap?` | `string` | — | 생성된 하위 턴에만 적용되는 추가 상한입니다. 두 상한이 모두 적용되면 더 낮은 값이 이깁니다. | +| `plaintextV2AgentMessages?` | `boolean` | — (설정되지 않음) | 실험적 선택 기능입니다. 명시적 `true` 또는 `multiAgentMode: "v2-plaintext"`는 적격 네이티브 ChatGPT v2 부모가 `spawn_agent`, `send_message`, `followup_task`의 메시지 인수를 평문으로 내보내도록 합니다. | -이 표면은 대시보드나 `ocx v2 status|on|off|mode |threads `로 관리합니다. 모드 변경은 새 세션에 적용됩니다. `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2` 필드입니다. `ocx v2 threads `는 v2가 활성화된 뒤 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]` 아래에 `max_concurrent_threads_per_session`을 기록합니다. +이 표면은 대시보드나 `ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `로 관리합니다. `v2-plaintext` 진입에는 `--acknowledge-plaintext-agent-messages`가 필요합니다. 카탈로그 고정값 변경은 새 세션에 적용됩니다. 평문 컴파일러는 현재 설정에 따라 기존 V2 세션의 후속 적격 요청도 처리합니다. `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2` 필드입니다. `ocx v2 threads `는 v2가 활성화된 뒤 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]` 아래에 `max_concurrent_threads_per_session`을 기록합니다. 관리 API는 `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`를 제공합니다. injection-model 업데이트는 부분 업데이트입니다. 사용자 지정 프롬프트는 이 API의 `prompt` 필드입니다. +`PUT /api/v2`로 `v2-plaintext`에 진입하려면 `plaintextAgentMessagesAcknowledged: true`가 필요합니다. 일반 `config set`과 `config import`는 이 모드로 새로 진입할 수 없습니다. 해당 모드가 포함된 내보내기를 복원하려면 먼저 확인을 거쳐 모드로 전환한 뒤 가져오세요. + +새 설정에서 `plaintextV2AgentMessages`는 설정되지 않으며, 명시적으로 `true`로 설정하는 방법과 `v2-plaintext` 모드 선택은 독립적인 활성화 방법입니다. 호출자는 Responses 프로토콜을 사용하고 최종 대상은 `adapter: "openai-responses"`, `authMode: "forward"`, 정확한 기본 URL `https://chatgpt.com/backend-api/codex`를 사용하는 공식 ChatGPT 전달 경로여야 합니다. OpenAI API 키 제공자, 사용자 지정 호환 게이트웨이, 다른 제공자 라우트 및 Responses가 아닌 호출자는 제외됩니다. 독립 필드는 `ocx config set plaintextV2AgentMessages true`로 설정할 수 있으며 변경 후 프록시를 재시작하세요. + ## 항상 능동 위임 서브에이전트 → 고급의 **항상 능동 위임**(이전 이름: **울트라 모드**)은 추론 노력을 바꾸지 않고 위임을 시작하는 조건만 변경합니다. 권장 프리셋에서도 사용자 지침, 권한 경계, 작업 범위, 도구 규칙은 유지됩니다. +대시보드 스위치를 켜려면 네이티브 기능이 활성화된 명시적 `multiAgentMode: "v2"` 또는 전역 강제를 끄고 카탈로그 pin을 사용하는 `multiAgentMode: "v2-plaintext"`가 필요합니다. `ocx v2 on`만으로는 조건을 충족하지 않습니다. + `GET`과 `PUT /api/v2`는 `multiAgentModeHintRecommendation: { text, revision }`도 반환합니다. 대시보드는 프리셋을 켜거나 복원할 때 서버가 제공한 텍스트를 사용하며, 고정된 대체 문구는 사용하지 않습니다. 이전 서버가 권장값을 제공하지 않거나 잘못된 형식으로 반환하면 프리셋 적용·복원은 사용할 수 없으며, 기존 사용자 지정 힌트의 편집·삭제는 계속 사용할 수 있습니다. **프리셋 복원**은 로컬 초안만 바꾸고, **저장**을 눌러야 저장됩니다. 설정 조회, 관련 없는 업데이트, 업그레이드는 저장된 힌트를 자동 변경하지 않습니다. 힌트를 명시적으로 업데이트할 때 본문이 알려진 두 가지 이전 OpenCodex 프리셋 중 하나와 바이트 단위로 정확히 일치하는 경우에만 현재 권장 문구로 바뀝니다. 그 밖의 유효한 사용자 지정 텍스트는 공백 차이가 있는 변형까지 바이트 단위로 보존됩니다. 기존 v2 활성화·기능 지원 확인·힌트 해제 규칙은 그대로이며, 변경은 새 Codex 세션에 적용됩니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 8fee3e60814..58910cbf81e 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -338,6 +338,8 @@ Integer options such as `--limit` require decimal whole numbers within JavaScrip `ocx config validate ` and `ocx config import --yes` accept UTF-8 JSON with or without a leading BOM, including stdin (`-`). This supports UTF-8 exports from Windows PowerShell and editors. UTF-16 input is not accepted. +To enter the experimental plaintext subagent mode, use `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`. Generic `config set` and `config import` refuse a new entry into this mode because the dedicated command also coordinates the native mode transition. Before restoring an exported config that contains `multiAgentMode: "v2-plaintext"`, complete that acknowledged transition first, then import the file. Restoring an already-active mode, changing other settings, and leaving plaintext mode remain allowed. The independent `plaintextV2AgentMessages` preference is unchanged. + ## Default alias listing `ocx alias --json` is equivalent to `ocx alias list --json`. The output flag can precede or follow an explicit alias action. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index e105c217f29..8a0357525d2 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -172,9 +172,9 @@ An ignored subagent field does not remove a valid main cap. `ocx effort clear` c while retaining the separate injection-effort setting. See [Sub-agent surfaces](/guides/sub-agent-surface/) for the request surfaces where caps apply. -### `ocx v2 |keep-native-v1 |threads |mode-hint >` +### `ocx v2 |keep-native-v1 |threads |mode-hint >` -Manage the Codex `multi_agent_v2` feature flag and the three-state multi-agent surface mode. +Manage the Codex `multi_agent_v2` feature flag and the multi-agent surface mode. These operate locally by default. `--live` uses the selected running proxy with no local fallback; both targets support `--json`. Re-read status on the same target after a change and distinguish stored settings from new-session behavior. @@ -187,6 +187,7 @@ after a change and distinguish stored settings from new-session behavior. | `mode v1` | Force all models to v1, disable native v2, and preserve the active thread limit. | | `mode default` | Respect upstream model surface pins. | | `mode v2` | Force models to v2 and preserve the active thread limit. With `keep-native-v1` off, enable global native v2; with it on, disable the global override and use catalog pins. | +| `mode v2-plaintext` | Force models to v2 through catalog pins with the global override off and enable experimental plaintext task delivery. Requires `--acknowledge-plaintext-agent-messages`. | | `keep-native-v1 on\|off` | Under `mode v2`, keep ChatGPT-native models on v1 and routed models on v2. Enabling it disables the global V2 override before catalog sync. | | `threads ` | Set the active v1/v2 thread limit to an integer of at least 1. | | `mode-hint ` | Set the Proactive delegation hint (Ultra mode) for every model and effort. | @@ -219,8 +220,9 @@ codex-rs's effort-derived multi-agent policy, so any model and any reasoning eff receives the Proactive delegation prompt. It does **not** change reasoning effort itself. A missing argument or a whitespace-only value is rejected; only `--clear` removes the hint. The Subagents dashboard's Ultra mode **on** toggle has a stricter -gate: it requires the native feature to be enabled with an explicit v2 surface -(`ocx v2 mode v2`); `ocx v2 on` alone does not satisfy that dashboard gate. +gate: it requires either an enabled native feature with an explicit v2 surface +(`ocx v2 mode v2`), or `v2-plaintext`, which uses catalog pins with the global +override off. `ocx v2 on` alone does not satisfy that dashboard gate. #### Explicit v2 target and outcome diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 4943edfce3d..3bdbc15a76b 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -45,7 +45,7 @@ still depends on upstream support for your account. | Field | Type | Default | Meaning | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. Applies to new sessions. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. `v2-plaintext` also enables experimental plaintext task delivery and disables the global override. Catalog pins apply to new sessions; enabled compilation also affects subsequent eligible requests from existing V2 sessions. | | `keepNativeChatGptOnV1?` | `boolean` | `false` | When `multiAgentMode` is `"v2"`, disable the global V2 override, stamp ChatGPT-native rows as v1, and keep routed rows on v2. Codex resolves the global override before catalog pins, so both parts are required for a ChatGPT parent to spawn routed children without backend-encrypted tasks ([#92](https://github.com/lidge-jun/opencodex/issues/92)). Ignored in `v1` and `default`. | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | Up to five bare native, account-qualified `/`, or routed `provider/model` ids featured first in the sub-agent picker. The dashboard offers bare native and routed ids as new choices and preserves already-configured exact account-qualified ids when saving, including ids unavailable in the current catalog. To add a new exact account-qualified id, use `ocx agent subagents set` or edit the configuration. After the [one-time Astra upgrade](#astra-roster-upgrade), an explicit empty list is preserved. | | `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored v2 delegation guidance. | @@ -60,12 +60,12 @@ still depends on upstream support for your account. | `codexRoleCapability?` | `{ artificialAnalysis?: { apiKey?: string } }` | No measured source | Measured-score source for [role auto-assign](/guides/integrations/#auto-assign). Artificial Analysis uses your API key (literal, `${ENV}` or keychain reference) once per day to read its free model list, every domain scored by its Intelligence Index; scores stay out of the response. Missing or unavailable data falls back to configured tiers and prices. A retired `huggingFace` key is ignored. | | `effortCap?` | `string` | — | Hard ceiling for qualifying v2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | -| `plaintextV2AgentMessages?` | `boolean` | — (unset) | Experimental opt-in. It runs only when explicitly set to `true` and asks eligible native ChatGPT v2 parents to emit `spawn_agent`, `send_message`, and `followup_task` message arguments as plaintext. See [Plaintext v2 agent messages](#plaintext-v2-agent-messages). | +| `plaintextV2AgentMessages?` | `boolean` | — (unset) | Experimental opt-in. Explicit `true` or `multiAgentMode: "v2-plaintext"` asks eligible native ChatGPT v2 parents to emit `spawn_agent`, `send_message`, and `followup_task` message arguments as plaintext. See [Plaintext v2 agent messages](#plaintext-v2-agent-messages). | | `agentTaskRecovery?` | `object` | — | Experimental opt-in recovery for backend-encrypted v2 tasks sent to routed providers. Disabled unless `enabled: true`; see [Encrypted v2 task recovery](#encrypted-v2-task-recovery). | Manage the surface with the dashboard or -`ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. -Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT /api/v2` field, not a +`ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `; entering `v2-plaintext` requires `--acknowledge-plaintext-agent-messages`. +Catalog pin changes apply to new sessions; the plaintext compiler follows the current setting for eligible requests, including later requests from existing V2 sessions. `maxConcurrentThreadsPerSession` is a `PUT /api/v2` field, not a `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. @@ -86,8 +86,9 @@ 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 **Always proactive delegation** toggle requires both the native feature and -an explicit v2 surface (`multiAgentMode: "v2"`, equivalent to `ocx v2 mode v2`); +Subagents dashboard's **Always proactive delegation** toggle requires either the native feature +with an explicit v2 surface (`multiAgentMode: "v2"`, equivalent to `ocx v2 mode v2`), +or `multiAgentMode: "v2-plaintext"`, which uses catalog pins with the global override off. `ocx v2 on` alone does not satisfy that dashboard gate. `GET` and `PUT /api/v2` also return `multiAgentModeHintRecommendation: { text, revision }`. @@ -196,7 +197,7 @@ on a mid-thread model switch. ## Plaintext v2 agent messages -`plaintextV2AgentMessages` is unset in a fresh config and runs only when explicitly set to `true`. +`plaintextV2AgentMessages` is unset in a fresh config. Explicit `true` or the separately selected `multiAgentMode: "v2-plaintext"` enables this experimental path. The mode also sets V2 catalog pins while disabling the global override; existing chat history is not rewritten. Enter through the dashboard confirmation or `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages`. The mode does not overwrite the independent boolean, so leaving it restores that boolean's prior behavior. The compiler follows the current setting for subsequent eligible canonical ChatGPT requests, including existing V2 sessions. It does not decrypt existing ciphertext or rewrite stored history. Disabling it does not delete copies already retained in history, provider requests, or local response/debug state. The caller must use the Responses wire, and the final destination must use the canonical ChatGPT Codex forward transport: `adapter: "openai-responses"`, `authMode: "forward"`, and the exact base URL `https://chatgpt.com/backend-api/codex`. OpenAI API-key providers, custom OpenAI-compatible diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 035244e70cb..47f415b9f3e 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -186,6 +186,8 @@ runtime-home Orca, тогда как приложение ChatGPT/Codex всё loopback встроенный провайдер Codex может сначала пробовать WebSocket, и отключённый прокси ответит `426`, после чего Codex откатится на HTTP/SSE. +Когда включён режим `v2-plaintext` или флаг `plaintextV2AgentMessages`, для локального replay требуются тот же допущенный вызывающий субъект OpenCodex и собственный непустой `thread-id` запроса. Родительский тред, loopback-адрес, учётные данные upstream или ключ-заполнитель не являются этой идентичностью. Клиенты без допущенного вызывающего субъекта и запросы без собственного треда должны повторно отправить полный разговор без `previous_response_id`. Конфигурации loopback Codex и DSH по умолчанию не устанавливают эту идентичность. Состояние, сохранённое при включении любого из этих параметров, остаётся привязанным после его отключения. В режиме `v1` по умолчанию непривязанное состояние сохраняет прежнее продолжение в пределах родительского треда, в том числе для вызывающих сторон loopback по умолчанию. Это отдельно от нативных Responses ID, принадлежащих upstream. + ## Идентичность тредов и история Форма loopback по умолчанию сохраняет новые треды помеченными нативным провайдером Codex diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index 5e6cbfa807a..7815f2ac52c 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -14,17 +14,17 @@ description: Управляйте тем, как Codex создаёт и обс ## Режимы Режим выбирается для **новых сессий**. Уже запущенные сессии сохраняют ту поверхность, с которой -они стартовали. +они стартовали. Однако включённый компилятор обрабатывает последующие подходящие запросы существующих сессий V2. | Режим | Что получает Codex | Кому подходит | | --- | --- | --- | -| **v1** | Классические namespaced-инструменты `spawn_agent`, `send_input`, `resume_agent` и `close_agent`. Spawn может сразу выбрать другую модель. | Новички, которым нужно надёжное делегирование между разными провайдерами, особенно для потомков native-to-routed. | -| **base** (default) | Upstream pin'ы моделей: GPT-5.6 Sol/Terra используют v2, Luna использует v1, а модели без pin'а следуют feature flag'у Codex `multi_agent_v2`. | Большинство пользователей. Этот режим следует ожидаемой surface для каждой модели и не навязывает одну глобально. | +| **v1** (по умолчанию) | Классические namespaced-инструменты `spawn_agent`, `send_input`, `resume_agent` и `close_agent`. Spawn может сразу выбрать другую модель. | Новички, которым нужно надёжное делегирование между разными провайдерами, особенно для потомков native-to-routed. | +| **base** | Upstream pin'ы моделей: GPT-5.6 Sol/Terra используют v2, Luna использует v1, а модели без pin'а следуют feature flag'у Codex `multi_agent_v2`. | Большинство пользователей. Этот режим следует ожидаемой surface для каждой модели и не навязывает одну глобально. | | **v2** | Плоские инструменты `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent` и agent-list, с конкурентными сессиями. | Пользователи, которым нужен новый concurrent-workflow и которые понимают наследование модели и ограничение encrypted task ниже. | +| **v2-plaintext** | V2 | Выбирает V2 для новых сессий и передаёт подходящие задачи открытым текстом без глобального принуждения. Для включения нужен `--acknowledge-plaintext-agent-messages`. | :::tip[Не уверены?] -Начинайте с **base**. Выбирайте **v1**, когда межпровайдерское делегирование должно работать -предсказуемо. Жёстко включайте **v2** только тогда, когда вам целенаправленно нужна именно её +Начинайте с **v1**, когда межпровайдерское делегирование должно работать предсказуемо. Жёстко включайте **v2** только тогда, когда вам целенаправленно нужна именно её новая модель сессии для каждой записи каталога. ::: @@ -36,6 +36,7 @@ Codex: - **v1** записывает `multi_agent_version = "v1"` для каждой модели. - **base** восстанавливает upstream pin'ы. У записей без pin'а поведение определяется feature flag'ом `multi_agent_v2`. +- **v2-plaintext** закрепляет V2 в каталоге для новых сессий, отключает глобальное принудительное V2 и включает компилятор сообщений задач. - **v2** записывает `multi_agent_version = "v2"` для каждой модели, кроме случая, когда включён режим **Оставить ChatGPT на v1**: собственные строки ChatGPT остаются `"v1"`, а маршрутизируемые и комбо-строки остаются `"v2"`. opencodex применяет это как финальный проход и к живому каталогу `/v1/models`, и к каталогу, @@ -181,7 +182,7 @@ endpoint ChatGPT, но расходует квоту, добавляет зад ### GUI -- **Dashboard** → первая ячейка со статистикой: выберите **v1**, **base** или **v2**. +- **Dashboard** → первая ячейка со статистикой: выберите **v1**, **base**, **v2** или **v2-plaintext**. - **Models** → сегментированный переключатель в верхнем ряду: выберите тот же глобальный режим. - **Dashboard** → **Sub-agent delegation**: задайте модель/effort для guidance и opt-in для native-default. @@ -196,6 +197,7 @@ ocx v2 status ocx v2 mode v1 ocx v2 mode default ocx v2 mode v2 +ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages ocx v2 threads 8 ``` @@ -215,6 +217,8 @@ ocx agent effort set --subagent max ### API +При переходе в `v2-plaintext` дашборд запрашивает согласие; `PUT /api/v2` требует `plaintextAgentMessagesAcknowledged: true`. + Management API предоставляет соответствующие `GET` и `PUT` endpoint'ы: | Эндпоинт | Чем управляет | @@ -276,3 +280,5 @@ Codex заполняет до пяти предлагаемых мест из к Ограничение контекста модели не зависит от режима подагентов. Настраивайте его на странице Models; нативные модели OpenAI сохраняют свои реальные окна контекста. + +Экспериментальный `plaintextV2AgentMessages` в новой конфигурации не задан. Явное `true` или отдельно выбранный режим `v2-plaintext` включает компилятор; выход из режима сохраняет независимое значение флага. Компиляция следует текущей настройке и для последующих подходящих запросов существующих сессий V2. Вызывающая сторона должна использовать Responses, а конечный маршрут — `adapter: "openai-responses"`, `authMode: "forward"` и точный URL `https://chatgpt.com/backend-api/codex`. Другие провайдеры, совместимые шлюзы и клиенты без Responses исключены. Компилятор обрабатывает `spawn_agent`, `send_message` и `followup_task` без дополнительного запроса восстановления. HTTPS сохраняется, но текст может остаться в истории Codex, запросах провайдеру и локальном состоянии ответов или отладки. Существующий шифротекст не расшифровывается, история не переписывается, а отключение не удаляет сохранённые копии. См. [конфигурацию агентов](/ru/reference/configuration/agents/#plaintext-v2-agent-messages). diff --git a/docs-site/src/content/docs/ru/guides/subagent-v1-default.md b/docs-site/src/content/docs/ru/guides/subagent-v1-default.md index 4375754c998..de0880768c8 100644 --- a/docs-site/src/content/docs/ru/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ru/guides/subagent-v1-default.md @@ -3,14 +3,22 @@ title: Почему v1 — режим подагентов по умолчани description: Что ломает ограничение шифрованных задач v2, почему OpenCodex теперь поставляется с v1 и что делать, если вам нужен v2. --- +Выбор в каталоге действует для новых сеансов, но включённый компилятор обрабатывает и последующие подходящие запросы существующих сеансов V2 через канонический маршрут ChatGPT. Отключение не удаляет уже сохранённые копии и не переписывает сохранённую историю. + +**Экспериментальный вариант: `v2-plaintext`.** Он выбирает V2 для новых чатов, не включая глобальное принудительное V2. После явного подтверждения новые подходящие сообщения задач передаются открытым текстом на уровне приложения; HTTPS и аутентификация сохраняются. Текст может оставаться в истории, запросах выбранному провайдеру и локальных отладочных данных. Уже зашифрованные задачи не расшифровываются. + +Команда: `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages`. Для активной цели проверьте `ocx v2 status --live --json`; для локальной уберите `--live` из обеих команд и проверьте `ocx v2 status --json`: состояние конфигурации и синхронизация каталога не доказывают успешное делегирование. Для выхода явно выберите другой режим; независимое значение `plaintextV2AgentMessages` сохраняется. Режим использует компилятор для подходящих запросов Responses через канонический маршрут ChatGPT и сохраняет имена задач V2, иерархию, последующие сообщения, отмену и параллельность. + +Для локального продолжения по ID ответа нужны тот же допущенный API-ключ opencodex и собственный `thread-id` запроса; ID родительского треда не даёт доступа к сохранённой задаче дочернего. Без этих идентификаторов, после перезапуска прокси или для старых непривязанных записей кэша отправьте весь разговор без `previous_response_id`. Состояние, сохранённое в этом режиме, остаётся привязанным после выхода; в `v1` по умолчанию непривязанное состояние продолжает работать в области родительского треда, включая loopback. Сохранённые копии остаются на диске. Ограничения ниже относятся к зашифрованному пути V2 без этой опции. Прежние режимы остаются доступны. + OpenCodex устанавливается с режимом подагентов **v1**. Страницы Dashboard, Models и Subagents просят подтверждение перед переходом на **base** или **v2** и ссылаются на эту страницу. -CLI подтверждения не запрашивает. +Для перехода в `v2-plaintext` CLI требует явный флаг подтверждения. -Причина конкретна: на v2 модель с маршрутизацией не может прочитать задачу, +Причина конкретна: на неизменённом зашифрованном пути v2 модель с маршрутизацией не может прочитать задачу, переданную ей нативной моделью ChatGPT. Это самый распространённый способ делегирования — основной GPT запускает подагента Grok, Claude или GLM — и на -v2 он каждый раз завершается ошибкой. +v2 нечитаемая зашифрованная задача завершается ошибкой на этой границе. Экспериментальный режим выше меняет создание новых сообщений, но не расшифровывает существующие. ## Что вы увидите @@ -44,7 +52,7 @@ backend ChatGPT. Ключ остаётся там. У OpenCodex его нико Настройки прокси не включают локальную расшифровку. Экспериментальная `agentTaskRecovery` может запросить восстановление подходящей задачи у backend ChatGPT, но не расшифровывает её локально. Описанные выше требования разрешения, допустимости и обработки сбоев сохраняются. -Три схемы не затронуты. Их полезно перечислить, чтобы стало ясно, где именно +В таблице ниже описан зашифрованный путь v2 без экспериментального компилятора. Она показывает, где именно возникает ошибка: | Схема | v1 | v2 | @@ -76,13 +84,14 @@ OpenCodex зафиксировал последствие в [#92](https://githu нельзя. Задача служит указателем на работу upstream, а не ожидает исправления сопровождающими здесь. -## Что теперь делают три режима +## Что теперь делают режимы | Режим | Инструменты | Когда выбирать | | --- | --- | --- | | **v1** (по умолчанию) | Каждая модель объявляет классические инструменты запуска в пространстве имён. При запуске можно сразу указать другую модель. | Если вы делегируете между провайдерами. Это режим поставки по умолчанию. | | **base** | Настройки моделей upstream: Sol и Terra используют v2, Luna — v1, остальные следуют собственному флагу Codex. | Если вам нужен предусмотренный Codex режим для каждой модели и вы делегируете только внутри одного провайдера. | | **v2** | Каждая модель объявляет плоские инструменты параллельной работы. | Если вам нужна новая модель параллельных сессий, а основной агент и подагент находятся по одну сторону границы. | +| **v2-plaintext** | V2 для новых сессий; подходящие сообщения задач передаются открытым текстом. | При явном согласии на сохранение текста задач. | base стоит вторым, а не первым, потому что его настройки переводят Sol и Terra — две модели, *из которых* чаще всего делегируют, — на v2. Для этой проблемы base @@ -101,7 +110,7 @@ base стоит вторым, а не первым, потому что его Любой ответ записывается, и уведомление больше не появляется. Если закрыть его без ответа, оно снова появится при следующем открытии дашборда. -Изменение режима действует на **новые** сессии Codex. Начните новую сессию; +Изменения закреплений каталога действуют на **новые** сессии Codex; компилятор открытого текста следует текущей настройке для подходящих запросов, включая последующие запросы существующих V2-сессий. Начните новую сессию; если долго работающий хост приложения всё ещё показывает старые инструменты, выполните `ocx sync` и перезапустите соответствующий интерфейс. @@ -138,7 +147,7 @@ base стоит вторым, а не первым, потому что его ## Изменение режима -Dashboard, Models и Subagents имеют одинаковый переключатель v1/base/v2, и все +Dashboard, Models и Subagents имеют одинаковый переключатель v1/base/v2/v2-plaintext, и все три запрашивают подтверждение перед base или v2. В CLI: ```bash @@ -146,7 +155,7 @@ ocx v2 status ocx v2 mode v1 ``` -CLI подтверждение не запрашивает. Настройка та же, поэтому выбирайте её, +Для `v2-plaintext` CLI требует флаг подтверждения, указанный выше. Выбирайте режим, учитывая изложенные здесь ограничения. ## Лимит одновременно работающих подагентов diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 5535896db74..3572453f53b 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -58,6 +58,8 @@ browser-state CLI не покрывает; настройка Cloudflare Tunnel подтверждение, в неинтерактивном использовании требуют `--yes`. Этот флаг — явное согласие; отсутствие флага не должно молча подтверждать действие. +Чтобы включить экспериментальный режим, выполните `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`. Общие `config set` и `config import` отказывают при новом переходе в режим, поскольку специальная команда также согласует переход нативного режима. Для восстановления экспорта с `multiAgentMode: "v2-plaintext"` сначала выполните этот переход с подтверждением, затем импортируйте файл. Восстановление уже активного режима, изменение других настроек и выход из режима разрешены. Независимое значение `plaintextV2AgentMessages` не меняется. + ## Version и внутренние dispatch-target'ы `ocx --version`, `ocx -v` и `ocx version` печатают одну строку с версией, пригодную для diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 2b051ead1d7..a68f254affc 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -28,9 +28,9 @@ ocx agent sidecar web --enabled off иначе завершающая строка `Codex config:`) и предлагает `ocx sync`, если запись не удалась. Флаг работает и для `vision`. -### `ocx v2 |threads >` +### `ocx v2 |threads >` -Управляйте feature flag'ом Codex `multi_agent_v2` и трёхсостоянием multi-agent surface mode. +Управляйте feature flag'ом Codex `multi_agent_v2` и четырёхсостоянием multi-agent surface mode. | Подкоманда | Действие | | --- | --- | @@ -39,7 +39,11 @@ ocx agent sidecar web --enabled off | `off` | Выключить `multi_agent_v2` и пересинхронизировать каталог. | | `mode v1` | Принудительно перевести все модели на v1, отключить native v2 и сохранить текущий thread limit. | | `mode default` | Уважать upstream pin'ы surface у моделей. | -| `mode v2` | Принудительно перевести все модели на v2, включить native v2 и сохранить текущий thread limit. | +| `mode v2` | Выбрать v2 для моделей и сохранить thread limit. При выключенном `keep-native-v1` включается глобальный native v2; при включённом глобальное принуждение отключается и используются pin'ы каталога. | +| `keep-native-v1 on\|off` | В режиме v2 оставить нативные модели ChatGPT на v1, а маршрутизируемые — на v2; включение отключает глобальное принуждение V2 перед синхронизацией каталога. | +| `mode-hint ` | Задать подсказку проактивного делегирования для всех моделей и уровней effort. | +| `mode-hint --clear` | Удалить подсказку и вернуть политику, зависящую от effort. | +| `mode v2-plaintext --acknowledge-plaintext-agent-messages` | Выбирает V2 для новых сессий и передаёт подходящие задачи открытым текстом без глобального принуждения. Для включения нужен `--acknowledge-plaintext-agent-messages`. | | `threads ` | Задать активный v1/v2 thread limit как целое число не меньше 1. | ```bash @@ -54,7 +58,7 @@ ocx v2 threads 16 Codex. При переходах mode и feature flag текущий числовой thread limit переносится между допустимыми ключами Codex для v1/v2; если переход не удался, исходный `config.toml` восстанавливается. Изменения применяются к новым сессиям Codex, а уже запущенные сохраняют свою -закреплённую surface. +закреплённую surface. При включённой компиляции последующие подходящие запросы существующих V2-сессий также обрабатываются; шифротекст не расшифровывается, а отключение не удаляет сохранённые копии. Переключатель Ultra в дашборде доступен при явно выбранном `v2` с включённой нативной функцией или при `v2-plaintext`, где V2 закреплён в каталоге; одного `ocx v2 on` недостаточно. ## Combo routing 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 ef8a4da0d81..b98c47ce054 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -10,7 +10,8 @@ description: Multi-agent surface, guidance при делегировании, pr | Поле | Тип | По умолчанию | Значение | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` штампует все модели как v1; `v2` штампует все модели как v2. `default` восстанавливает upstream pin'ы (Sol/Terra — v2, Luna — v1) и для остальных следует native flag `multi_agent_v2`. Применяется к новым сессиям. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` штампует все модели как v1; `v2` штампует все модели как v2. `default` восстанавливает upstream pin'ы (Sol/Terra — v2, Luna — v1) и для остальных следует native flag `multi_agent_v2`. Закрепления каталога применяются к новым сессиям. `v2-plaintext` выбирает V2 для новых сессий и передаёт подходящие задачи открытым текстом без глобального принуждения. Для включения нужен `--acknowledge-plaintext-agent-messages`. | +| `plaintextV2AgentMessages?` | `boolean` | — (не задано) | Экспериментальная опция: явное `true` или `multiAgentMode: "v2-plaintext"` включает передачу аргументов сообщений `spawn_agent`, `send_message` и `followup_task` открытым текстом для подходящих запросов. | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | До пяти bare native-id, account-qualified id `/` или routed-id `provider/model`, которые показываются первыми в списке подагентов. Для нового выбора панель предлагает bare native- и routed-id. При сохранении она сохраняет уже настроенные точные account-qualified id, включая отсутствующие в текущем каталоге. Для добавления нового точного account-qualified id используйте `ocx agent subagents set` или измените конфигурацию. После [однократного обновления Astra](/reference/configuration/agents/#astra-roster-upgrade) явно пустой список сохраняется. | | `injectionModel?` | `string` | — | Предпочитаемая native- или routed-модель подагента, которую proxy использует в собственном guidance v2. | | `injectionEffort?` | `string` | — | Предпочитаемый effort (`low`–`ultra`), имеющий смысл только вместе с `injectionModel`. | @@ -23,19 +24,19 @@ description: Multi-agent surface, guidance при делегировании, pr | `effortCap?` | `string` | — | Жёсткий потолок effort для qualifying v2 main-turn'ов и помеченных spawned-child turn'ов. Принимает `low`–`ultra`. | | `subagentEffortCap?` | `string` | — | Дополнительный потолок только для spawned-child turn'ов. Если применимы оба cap'а, выигрывает более низкий. | -Управляйте surface через дашборд или `ocx v2 status|on|off|mode |threads `. -Смена режима применяется к новым сессиям. `maxConcurrentThreadsPerSession` — это поле +Управляйте surface через дашборд или `ocx v2 status|on|off|mode |threads `. +Изменения закреплений каталога применяются к новым сессиям; компилятор открытого текста следует текущей настройке для подходящих запросов, включая последующие запросы существующих V2-сессий. `maxConcurrentThreadsPerSession` — это поле `PUT /api/v2`, а не ключ `config.json`; `ocx v2 threads ` записывает `max_concurrent_threads_per_session` в `[features.multi_agent_v2]` файла `$CODEX_HOME/config.toml` после включения v2. Management API предоставляет `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models` и `/api/subagent-model-fallback`. Обновления injection-model частичные; -custom prompt на этом API передаётся полем `prompt`. +custom prompt на этом API передаётся полем `prompt`. При переходе в `v2-plaintext` запрос `PUT /api/v2` требует `plaintextAgentMessagesAcknowledged: true`; дашборд запрашивает согласие. CLI: `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`. Общие `config set` и `config import` отказывают при новом переходе: перед восстановлением экспорта с `multiAgentMode: "v2-plaintext"` сначала выполните подтверждённый переход, затем импортируйте файл. Уже активный режим можно восстановить, другие настройки — изменить, а из режима — выйти. ## Всегда проактивное делегирование -**Всегда проактивное делегирование** в Subagents → Дополнительно (прежнее название — **Ultra mode**) меняет только условие запуска делегирования, не меняя reasoning effort. Рекомендуемый preset сохраняет инструкции пользователя, границы полномочий, область задачи и правила работы с инструментами. +**Всегда проактивное делегирование** в Subagents → Дополнительно (прежнее название — **Ultra mode**) меняет только условие запуска делегирования, не меняя reasoning effort. Рекомендуемый preset сохраняет инструкции пользователя, границы полномочий, область задачи и правила работы с инструментами. Переключатель дашборда доступен при явно выбранном `v2` с включённой нативной функцией или при `v2-plaintext`, где V2 закреплён в каталоге; одного `ocx v2 on` недостаточно. `GET` и `PUT /api/v2` дополнительно возвращают `multiAgentModeHintRecommendation: { text, revision }`. При включении или восстановлении preset дашборд использует текст сервера без встроенного запасного варианта. Если старый сервер не возвращает рекомендацию или возвращает некорректное значение, применение и восстановление preset недоступны; существующий custom hint по-прежнему можно редактировать или удалять. Восстановление preset меняет только локальный черновик; действие сохранения записывает его. @@ -140,3 +141,9 @@ Cap'ы умеют только понижать effort. Они опускают Если нужен объясняющий вариант для начинающих о поведении v1, default и v2, см. [Поверхность подагентов](/guides/sub-agent-surface/). + +## Plaintext v2 agent messages + +`plaintextV2AgentMessages` по умолчанию не задан. Явное `true` или отдельно выбранный `multiAgentMode: "v2-plaintext"` включает экспериментальный компилятор. Режим закрепляет V2 в каталоге для новых сессий при выключенном глобальном принуждении и не меняет независимый флаг; после выхода из режима действует прежнее значение флага. Компилятор следует текущей настройке и для последующих подходящих запросов существующих V2-сессий. Существующий шифротекст не расшифровывается, история не переписывается, а отключение не удаляет копии из истории, запросов провайдеру или локального состояния ответов и отладки. + +Вызывающая сторона должна использовать Responses, а конечный маршрут — `adapter: "openai-responses"`, `authMode: "forward"` и точный URL `https://chatgpt.com/backend-api/codex`. Провайдеры с API-ключом OpenAI, совместимые шлюзы, маршруты к другим провайдерам и клиенты без Responses исключены. Для подходящих запросов компилятор обрабатывает аргументы `spawn_agent`, `send_message` и `followup_task`, не выполняя дополнительный запрос восстановления. HTTPS и аутентификация сохраняются; текст может оставаться в истории Codex, запросах провайдеру, `responses-state.json` и его spill-файлах и в `usage-debug.jsonl` при включённой отладке. Поведение зависит от недокументированных деталей ChatGPT и Codex и может перестать работать после обновления. Независимый флаг можно установить командой `ocx config set plaintextV2AgentMessages true`; после изменения перезапустите прокси. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 30862e73896..e6233a9147d 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -217,6 +217,8 @@ olduğunda `supports_websockets = true` bildirir; geri döngüde Codex'in yerle sağlayıcısı önce WebSocket'i deneyebilir ve devre dışı bırakılmış bir proxy `426` döndürür, böylece Codex HTTP/SSE'ye geri döner. +`v2-plaintext` modu veya `plaintextV2AgentMessages` bayrağı etkin olduğunda yerel yeniden oynatma, aynı kabul edilmiş OpenCodex çağıranını ve isteğin kendi boş olmayan `thread-id` değerini gerektirir. Üst iş parçacığı, geri döngü adresi, yukarı akış kimlik bilgisi veya yer tutucu anahtar bu kimlik değildir. Kabul edilmiş çağıran kimliği olmayan istemcilerin ve kendi iş parçacığı olmayan isteklerin `previous_response_id` olmadan tüm konuşmayı yeniden göndermesi gerekir. Varsayılan geri döngü Codex ve DSH yapılandırmaları bu çağıran kimliğini oluşturmaz. Ayarlardan biri etkinken saklanan durum, ayar devre dışı bırakıldıktan sonra da bağlı kalır. Varsayılan `v1` modunda bağlı olmayan durum, varsayılan geri döngü çağıranları dahil mevcut üst iş parçacığı kapsamındaki devam davranışını korur. Bu, doğrudan yukarı akış hizmetinin sahip olduğu Responses kimliklerinden ayrıdır. + ## İş parçacığı kimliği ve geçmişi Varsayılan geri döngü formu yeni iş parçacıklarının Codex'in yerel `openai` diff --git a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md index 472cbd048fa..d507743fdb3 100644 --- a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md @@ -18,12 +18,13 @@ ajanınızın ne zaman devretmesi gerektiğine karar vermez. | Mod | Codex ne alır? | Kim seçmelidir? | | --- | --- | --- | -| **v1** | Klasik ad alanlı `spawn_agent`, `send_input`, `resume_agent` ve `close_agent` araçları. Bir spawn doğrudan başka bir modeli seçebilir. | Farklı sağlayıcılar arasında, özellikle yerelden yönlendirilen çocuklara güvenilir delegasyona ihtiyaç duyan yeni başlayanlar. | -| **base** (varsayılan) | Yukarı akış model sabitlemeleri: GPT-5.6 Sol/Terra v2 kullanır, Luna v1 kullanır ve sabitlenmemiş modeller Codex'in `multi_agent_v2` özellik bayrağını takip eder. | Çoğu kullanıcı. Küresel olarak birini zorlamadan her model için Codex'in hedeflenen arayüzünü takip eder. | +| **v1** (varsayılan) | Klasik ad alanlı `spawn_agent`, `send_input`, `resume_agent` ve `close_agent` araçları. Bir spawn doğrudan başka bir modeli seçebilir. | Farklı sağlayıcılar arasında, özellikle yerelden yönlendirilen çocuklara güvenilir delegasyona ihtiyaç duyan yeni başlayanlar. | +| **base** | Yukarı akış model sabitlemeleri: GPT-5.6 Sol/Terra v2 kullanır, Luna v1 kullanır ve sabitlenmemiş modeller Codex'in `multi_agent_v2` özellik bayrağını takip eder. | Çoğu kullanıcı. Küresel olarak birini zorlamadan her model için Codex'in hedeflenen arayüzünü takip eder. | | **v2** | Eşzamanlı oturumlarla düz `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent` ve ajan listesi araçları. | Daha yeni eşzamanlı iş akışını isteyen ve model kalıtımını ve aşağıdaki şifrelenmiş görev sınırlamasını anlayan kullanıcılar. | +| **v2-plaintext** | V2 | Yeni oturumlar için V2 seçer ve genel zorlamayı kapalı tutarak uygun görevleri düz metin iletir. Giriş için `--acknowledge-plaintext-agent-messages` gerekir. | :::tip[Emin değil misiniz?] -**base** ile başlayın. Sağlayıcılar arası yetkilendirmenin öngörülebilir şekilde +**v1** varsayılanıyla başlayın. Sağlayıcılar arası yetkilendirmenin öngörülebilir şekilde çalışması gerektiğinde **v1**'i seçin. Yalnızca her katalog girdisi genelinde daha yeni oturum modelini özellikle istediğinizde **v2**'ye zorlayın. ::: @@ -359,3 +360,16 @@ sabitler. Model bağlam sınırı alt ajan modundan bağımsızdır. Modeller sayfasında yapılandırın; yerel OpenAI modelleri gerçek bağlam pencerelerini korur. + +Deneysel `plaintextV2AgentMessages` yeni yapılandırmada ayarlanmamıştır. Açık `true` +veya ayrı seçilen `v2-plaintext` modu derleyiciyi etkinleştirir. Mod genel zorlamayı +kapatırken yeni sohbetler için V2 seçer; moddan çıkmak bağımsız alanı değiştirmez. +Derleme mevcut V2 oturumlarının sonraki uygun istekleri dahil geçerli ayarı izler. +Mevcut şifreli metin çözülmez, saklanan geçmiş yeniden yazılmaz ve devre dışı bırakma +saklanan kopyaları silmez. Çağıran Responses protokolünü kullanmalı ve son hedef +`adapter: "openai-responses"`, `authMode: "forward"` ve tam +`https://chatgpt.com/backend-api/codex` adresine sahip kurallı ChatGPT rotası olmalıdır; +API anahtarlı OpenAI sağlayıcıları, özel ağ geçitleri, diğer sağlayıcılar ve Responses +dışı çağıranlar hariçtir. Görev metni Codex geçmişinde, sağlayıcı isteklerinde ve +yerel yanıt/hata ayıklama durumunda kalabilir; HTTPS şifrelemesi korunur. +Ayrıntılar için [ajan yapılandırmasına](/tr/reference/configuration/agents/#düz-metin-v2-ajan-mesajları) bakın. diff --git a/docs-site/src/content/docs/tr/guides/subagent-v1-default.md b/docs-site/src/content/docs/tr/guides/subagent-v1-default.md index 9a282fdb27c..82f9d9f442d 100644 --- a/docs-site/src/content/docs/tr/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/tr/guides/subagent-v1-default.md @@ -7,10 +7,44 @@ OpenCodex, alt ajan arayüzü **v1** olarak kurulur. Dashboard, Models ve Subage sayfaları **base** veya **v2** moduna geçmeden önce onay ister; bu sayfa da o onayın bağlantı hedefidir. CLI onay istemez. -Nedeni dar ve nettir: v2'de ChatGPT yerel modelinden yönlendirilen modele -devredilen görevi yönlendirilen model okuyamaz. İnsanların en sık kullandığı -devir biçimi budur — GPT ebeveyninin Grok, Claude veya GLM çocuğu oluşturması — -ve v2'de her seferinde başarısız olur. +Değişmemiş şifreli v2 yolunda, ChatGPT yerel modelinden yönlendirilen modele +devredilen okunamayan görev sağlayıcı sınırında başarısız olur. Aşağıdaki deneysel +seçenek yeni görev mesajlarının üretimini değiştirir; mevcut şifreli mesajları çözmez. + +## Düz metin görev mesajlarıyla isteğe bağlı V2 + +**V2 · düz metin görevler** (`v2-plaintext`), genel V2 zorlamasını kapalı tutarak +yeni sohbetler için V2 katalog sabitlemelerini seçer. V2 görev adlarını, hiyerarşiyi, +takip mesajlarını, iptali ve eşzamanlılığı korur; uygun kurallı ChatGPT Responses +istekleri için deneysel düz metin derleyicisini kullanır. Mevcut sohbetler kayıtlı +seçimlerini korur ve güncelleme bu açık seçeneği kendiliğinden seçmez. Derleyici, +mevcut V2 oturumlarının sonraki uygun istekleri dahil, her uygun istekte geçerli +ayarı izler. + +Bu modda yerel yanıt kimliğiyle devam, kabul edilmiş aynı opencodex API anahtarı +kimliğini ve isteğin kendi `thread-id` değerini gerektirir; üst iş parçacığı kimliği +çocuğun saklanan görevine erişim sağlamaz. Kimliklerden biri yoksa, proxy yeniden +başlatıldıysa veya eski bağlı olmayan önbellek girdileri kullanılıyorsa konuşmanın +tamamını `previous_response_id` olmadan yeniden gönderin. Bu modda saklanan durum, +mod değiştikten sonra da bağlı kalır. Varsayılan `v1`, geri döngü çağıranları dahil +bağlı olmayan durum için mevcut üst iş parçacığı kapsamındaki devamı korur. + +Kontrol paneli, görev metninin Codex geçmişinde, seçilen sağlayıcıya gönderilen +isteklerde ve disk üzerindeki devam anlık görüntüleri veya taşmaları dahil yerel +yanıt/hata ayıklama durumunda kalabileceğinin onaylanmasını ister. HTTPS ve kimlik +doğrulama korunur; ek kurtarma modeli çağrısı yapılmaz. Mevcut şifreli metnin şifresi +çözülmez, saklanan geçmiş yeniden yazılmaz ve devre dışı bırakma saklanan kopyaları +silmez. Desteklenmeyen arka uç veya istemci davranışı yine başarısız olabilir. Normal +v1 ve karma yerel-v1/yönlendirilmiş-v2 modları kullanılabilir. + +```bash +ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages +``` + +Canlı hedefi `ocx v2 status --live --json` ile kontrol edin. Yerel hedef için her iki komuttan `--live` seçeneğini çıkarın ve `ocx v2 status --json` +ile durumu okuyun; yapılandırılan durum ve katalog yakınsaması, başarılı bir gerçek +görev devriyle aynı şey değildir. Çıkmak için başka bir mod seçin; bağımsız olarak +ayarlanmış `plaintextV2AgentMessages` değeri korunur. ## Bu durumda ne görürsünüz? @@ -36,15 +70,15 @@ güvenle yanlış yanıt üretmesine yol açardı. v1'de ebeveyn çocuğun görevini düz metin olarak üretir. OpenCodex onu okur, yönlendirir ve yönlendirilen çocuk uygulanabilir bir görev alır. -v2'de ebeveyn, ChatGPT arka ucunun ürettiği `encrypted_content` olarak görevi +Şifreli v2 yolunda ebeveyn, ChatGPT arka ucunun ürettiği `encrypted_content` olarak görevi verir. Anahtar o arka uçta kalır. OpenCodex anahtara hiç sahip olmadığından proxy'nin çözebileceği veya yeniden yazabileceği bir şey yoktur: değer, bir bayrağın arkasındaki düz metin değil gerçekten şifreli metindir. Bu yüzden -sorun yapılandırma hatası değil yapısaldır; proxy tarafı ayarlar yerel şifre çözmeyi etkinleştiremez. +mevcut şifreli metnin sorunu yapılandırma hatası değil yapısaldır; düz metin seçeneği onu çözmez, yeni mesajları farklı üretir. Hiçbir proxy ayarı yerel şifre çözmeyi etkinleştirmez. Deneysel `agentTaskRecovery`, uygun görevleri kurtarmak için ChatGPT arka ucuna başvurabilir; şifreli metni yerel olarak çözmez. Yukarıdaki yetkilendirme, uygunluk ve hata sınırları geçerlidir. -Üç topoloji etkilenmez; bu, hatanın şeklini açıklar: +Aşağıdaki karşılaştırma şifreli v2 yolu içindir; düz metin seçeneği uygun yeni görevleri farklı üretir: | Topoloji | v1 | v2 | | --- | --- | --- | @@ -72,13 +106,14 @@ olarak kaydetti ve planlanmadığı için kapattı: bu depoda çözülecek bir yoktur; konu, buradaki bakım sorumlularını bekleyen görev değil yukarı akış işine işarettir. -## Üç mod şimdi ne yapıyor? +## Modlar şimdi ne yapıyor? | Mod | Arayüz | Ne zaman seçilmeli? | | --- | --- | --- | | **v1** (varsayılan) | Her model klasik ad alanlı spawn araçlarını sunar. Spawn başka bir modeli doğrudan adlandırabilir. | Sağlayıcılar arasında görev devreden herkes. Sunulan varsayılan budur. | | **base** | Yukarı akış model sabitlemeleri: Sol ve Terra v2, Luna v1 kullanır; sabitlenmemiş modeller Codex'in kendi bayrağını izler. | Codex'in model başına amaçlanan arayüzünü ve yalnızca tek sağlayıcı içinde görev devrini istiyorsanız. | | **v2** | Her model düz eşzamanlı araçları sunar. | Yeni eşzamanlı oturum modelini istiyorsanız ve ebeveyn ile çocuk sınırın aynı tarafındaysa. | +| **v2-plaintext** | Genel zorlama kapalıyken yeni oturumlar için V2; uygun ChatGPT görev mesajları düz metin. | Açık onayla deneysel düz metin iletimini istiyorsanız. | base ilk değil ikinci sıradadır, çünkü sabitlemeleri insanların en sık görev devrettiği iki model olan Sol ve Terra'yı v2'ye koyar. base bu sorun için ara @@ -97,7 +132,7 @@ yanıtınızı bekler. Her iki yanıt da kaydedilir ve bildirim geri gelmez. Yanıtlamadan kapatırsanız kontrol panelini sonraki açışınızda yeniden görünür. -Mod değişiklikleri **yeni** Codex oturumlarına uygulanır. Seçimden sonra yeni +Katalog modu değişiklikleri **yeni** Codex oturumlarına uygulanır; düz metin derleyicisi mevcut V2 oturumlarının sonraki uygun isteklerini de etkiler. Seçimden sonra yeni oturum başlatın; uzun süredir çalışan bir App ana bilgisayarı eski arayüzü gösteriyorsa `ocx sync` çalıştırıp o Codex arayüzünü yeniden başlatın. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index 7df93536661..729a23b7d86 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -65,6 +65,15 @@ Onay bildiren yıkıcı kaldırma, içe aktarma, kredi tüketimi ve güncelleme işlemleri etkileşimsiz kullanımda `--yes` gerektirir. Bayrak açık bir katılımdır; atlanması eylemi sessizce onaylamamalıdır. +## Düz metin alt ajan moduna geçiş + +Deneysel moda girmek için `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages` +kullanın. Genel `config set` ve `config import`, yerel mod geçişini özel komut +yönettiğinden bu moda yeni girişi reddeder. `multiAgentMode: "v2-plaintext"` içeren +dışa aktarılmış bir yapılandırmayı geri yüklemeden önce onaylı geçişi tamamlayın, +sonra dosyayı içe aktarın. Zaten etkin modu geri yüklemek, diğer ayarları değiştirmek +ve moddan çıkmak mümkündür. Bağımsız `plaintextV2AgentMessages` tercihi değişmez. + ## Sürüm ve dahili dağıtım hedefleri `ocx --version`, `ocx -v` ve `ocx version` betik dostu tek bir sürüm satırı diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index e7b5323efb1..583d7601cfd 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -29,9 +29,9 @@ komut tetiklediği Codex tarafı yazmayı bildirir (`--json` içinde `codexWebSe halde son satırda `Codex config:`) ve yazma yapılamadığında `ocx sync` adresini gösterir. Bayrak `vision` için de çalışır. -### `ocx v2 |threads |mode-hint >` +### `ocx v2 |keep-native-v1 |threads |mode-hint >` -Codex `multi_agent_v2` özellik bayrağını ve üç durumlu çoklu ajan yüzey modunu +Codex `multi_agent_v2` özellik bayrağını ve dört durumlu çoklu ajan yüzey modunu yönetin. | Alt komut | Eylem | @@ -41,7 +41,9 @@ yönetin. | `off` | `multi_agent_v2` özelliğini devre dışı bırakın ve kataloğu yeniden senkronize edin. | | `mode v1` | Tüm modelleri v1'e zorlayın, yerel v2'yi devre dışı bırakın ve aktif iş parçacığı sınırını koruyun. | | `mode default` | Yukarı akış model yüzeyi sabitlemelerine saygı gösterin. | -| `mode v2` | Tüm modelleri v2'ye zorlayın, yerel v2'yi etkinleştirin ve aktif iş parçacığı sınırını koruyun. | +| `mode v2` | İş parçacığı sınırını koruyarak modelleri v2 yapın. `keep-native-v1` kapalıysa genel yerel v2 etkinleşir; açıksa genel zorlama kapatılır ve katalog sabitlemeleri kullanılır. | +| `keep-native-v1 on\|off` | `mode v2` içinde ChatGPT yerel modellerini v1, yönlendirilen modelleri v2 üzerinde tutun. Etkinleştirme katalog eşitlemesinden önce genel V2 zorlamasını kapatır. | +| `mode v2-plaintext --acknowledge-plaintext-agent-messages` | Yeni oturumlar için V2 seçer ve genel zorlamayı kapalı tutarak uygun görevleri düz metin iletir. Giriş için `--acknowledge-plaintext-agent-messages` gerekir. | | `threads ` | Aktif v1/v2 iş parçacığı sınırını en az 1 olan bir tamsayıya ayarlayın. | | `mode-hint ` | Her model ve çaba için Proaktif yetkilendirme ipucunu (Ultra modu) ayarlayın. | | `mode-hint --clear` | İpucunu kaldırın, böylece çabadan türetilen politika (ultra = proaktif) devam eder. | @@ -72,8 +74,9 @@ böylece herhangi bir model ve herhangi bir akıl yürütme çabası Proaktif yetkilendirme istemini alır. Akıl yürütme çabasının kendisini **değiştirmez**. Eksik bir argüman veya yalnızca boşluk içeren bir değer reddedilir; yalnızca `--clear` ipucunu kaldırır. Alt Ajanlar kontrol panelinin Ultra modu **açık** -anahtarının daha katı bir kapısı vardır: yerel özelliğin açık bir v2 yüzeyiyle -(`ocx v2 mode v2`) etkinleştirilmesini gerektirir; tek başına `ocx v2 on` bu +anahtarının daha katı bir kapısı vardır: yerel özelliğin açık v2 yüzeyiyle +(`ocx v2 mode v2`) etkinleştirilmesini veya genel zorlama kapalıyken +`v2-plaintext` modunu gerektirir; tek başına `ocx v2 on` bu kontrol paneli kapısını karşılamaz. ## Kombo yönlendirme diff --git a/docs-site/src/content/docs/tr/reference/configuration/agents.md b/docs-site/src/content/docs/tr/reference/configuration/agents.md index b3cfd1e3dfa..3d4ccdcd3d3 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/tr/reference/configuration/agents.md @@ -11,7 +11,7 @@ kontrol eder. | Alan | Tip | Varsayılan | Anlamı | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` her katalog modelini v1 olarak damgalar; `v2` her modeli v2 olarak damgalar. `default` yukarı akış sabitlemelerini geri yükler (Sol/Terra v2, Luna v1) ve aksi takdirde yerel `multi_agent_v2` bayrağını takip eder. Yeni oturumlara uygulanır. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` her katalog modelini v1 olarak damgalar; `v2` her modeli v2 olarak damgalar. `default` yukarı akış sabitlemelerini geri yükler (Sol/Terra v2, Luna v1) ve aksi takdirde yerel `multi_agent_v2` bayrağını takip eder. Katalog sabitlemeleri yeni oturumlara uygulanır. `v2-plaintext` genel zorlamayı kapalı tutarak V2 seçer ve uygun görevleri düz metin iletir; derleme mevcut V2 oturumlarının sonraki uygun isteklerini de etkiler. Giriş için `--acknowledge-plaintext-agent-messages` gerekir. | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | Alt ajan seçicisinde öne çıkan en fazla beş yalın yerel, hesap nitelikli `/` veya yönlendirilen `provider/model` kimliği. Kontrol paneli yeni seçimler için yalın yerel ve yönlendirilen kimlikleri sunar. Kaydederken, geçerli katalogda bulunmayanlar dahil, önceden yapılandırılmış tam hesap nitelikli kimlikleri korur. Yeni bir tam hesap nitelikli kimlik eklemek için `ocx agent subagents set` kullanın veya yapılandırmayı düzenleyin. [Tek seferlik Astra yükseltmesinden](/reference/configuration/agents/#astra-roster-upgrade) sonra açıkça boş bırakılan liste korunur. | | `injectionModel?` | `string` | — | Proxy kaynaklı v2 yetkilendirme rehberliğinde kullanılan tercih edilen yerel veya yönlendirilen alt ajan modeli. | | `injectionEffort?` | `string` | — | Yalnızca `injectionModel` ile anlamlı olan tercih edilen çaba (`low` ile `ultra` arası). | @@ -23,11 +23,11 @@ kontrol eder. | `subagentModelFallbackPollMs?` | `number` | `60000` | Kullanılabilirlik probu önbellek aralığı. 1000 ms'nin altındaki değerler varsayılana geri döner. | | `effortCap?` | `string` | — | Uygun v2 ana turları ve işaretlenmiş çocuk turları için kesin tavan. `low` ile `ultra` arasını kabul eder. | | `subagentEffortCap?` | `string` | — | Yalnızca oluşturulan çocuk turları için ek tavan. Her iki sınır da geçerli olduğunda daha düşük olan kazanır. | +| `plaintextV2AgentMessages?` | `boolean` | — (ayarlanmamış) | Deneysel tercih. Açık `true` veya `multiAgentMode: "v2-plaintext"`, uygun yerel ChatGPT v2 ebeveynlerinin `spawn_agent`, `send_message` ve `followup_task` mesaj argümanlarını düz metin üretmesini ister. Bkz. [Düz metin v2 ajan mesajları](#düz-metin-v2-ajan-mesajları). | | `agentTaskRecovery?` | `object` | — | Yönlendirilen sağlayıcılara gönderilen arka uçta şifrelenmiş v2 görevleri için deneysel isteğe bağlı kurtarma. `enabled: true` olmadıkça devre dışıdır; bkz. [Şifrelenmiş v2 görev kurtarma](#şifrelenmiş-v2-görev-kurtarma). | -Arayüzü kontrol paneli veya `ocx v2 status|on|off|mode |threads -|mode-hint ` ile yönetin. Mod değişiklikleri yeni oturumlara -uygulanır. `maxConcurrentThreadsPerSession`, bir `config.json` anahtarı değil, +Arayüzü kontrol paneli veya `ocx v2 status|on|off|mode |keep-native-v1 |threads +|mode-hint ` ile yönetin. Katalog sabitlemesi değişiklikleri yeni oturumlara uygulanır; düz metin derleyicisi mevcut ayarı, mevcut V2 oturumlarının sonraki uygun istekleri dahil uygun isteklerde izler. `maxConcurrentThreadsPerSession`, bir `config.json` anahtarı değil, bir `PUT /api/v2` alanıdır; `ocx v2 threads `, v2 etkinleştirildikten sonra Codex'in `$CODEX_HOME/config.toml` dosyasındaki `[features.multi_agent_v2]` altına `max_concurrent_threads_per_session` yazar. @@ -44,8 +44,9 @@ yürütme çabasını **değiştirmez**. Bir `null` değeri anahtarı kaldırır çabadan türetilen politika (ultra = proaktif, aksi takdirde açık) devam eder; boş veya yalnızca boşluk içeren değerler reddedilir çünkü mevcut bir boş geçersiz kılma ultra kaynaklı Proaktif mesajı bile bastırır. Alt Ajanlar kontrol -panelinin Ultra modu **açık** anahtarı hem yerel özelliği hem de açık bir v2 -yüzeyini (`multiAgentMode: "v2"`, `ocx v2 mode v2` ile eşdeğer) gerektirir; tek +panelinin Ultra modu **açık** anahtarı, yerel özellik etkinken açık v2 +yüzeyini (`multiAgentMode: "v2"`, `ocx v2 mode v2` ile eşdeğer) veya genel +zorlamayı kapalı tutan `multiAgentMode: "v2-plaintext"` modunu gerektirir; tek başına `ocx v2 on` bu kontrol paneli geçidini karşılamaz. Yönetim API'si `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, @@ -147,6 +148,25 @@ kurtarır. } ``` +## Düz metin v2 ajan mesajları + +`plaintextV2AgentMessages` yeni yapılandırmada ayarlanmamıştır. Açık `true` veya +ayrı seçilen `multiAgentMode: "v2-plaintext"` bu deneysel yolu etkinleştirir. Mod, +genel zorlamayı kapatırken yeni oturumlar için V2 katalog sabitlemelerini ayarlar. +Kontrol panelindeki onayla veya `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages` +ile girin; `PUT /api/v2` üzerinden giriş için `plaintextAgentMessagesAcknowledged: true` +gerekir. Mod bağımsız boole değerini değiştirmez; moddan çıkınca önceki davranışı korunur. + +Derleyici mevcut V2 oturumları dahil sonraki uygun isteklerde geçerli ayarı izler. +Çağıran Responses protokolünü kullanmalı ve son hedef kurallı ChatGPT Codex iletimi +olmalıdır: `adapter: "openai-responses"`, `authMode: "forward"` ve tam olarak +`https://chatgpt.com/backend-api/codex`. API anahtarlı OpenAI sağlayıcıları, özel +uyumlu ağ geçitleri, diğer sağlayıcılara rotalar ve Responses dışı çağıranlar hariçtir. +Mevcut şifreli metin çözülmez, kayıtlı geçmiş yeniden yazılmaz ve devre dışı bırakma +geçmişte, sağlayıcı isteklerinde veya yerel yanıt/hata ayıklama durumunda tutulmuş +kopyaları silmez. [Alt ajan yüzeyleri](/tr/guides/sub-agent-surface/) ve +[v1 varsayılanı](/tr/guides/subagent-v1-default/) kapsamı ve riskleri açıklar. + ## Şifrelenmiş v2 görev kurtarma `agentTaskRecovery`, yönlendirilen bir sağlayıcıya ulaşan arka uçta şifrelenmiş diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index e807acd44e9..dd564fc4ea7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -173,6 +173,8 @@ opencodex 也会通过 WebSocket 提供 `/v1/responses`。专用 provider 只有 `supports_websockets = true`;在 loopback 情况下,Codex 的内置 provider 可能会先尝试 WebSocket,而关闭的 proxy 会返回 `426`,从而让 Codex fallback 到 HTTP/SSE。 +启用 `v2-plaintext` 模式或 `plaintextV2AgentMessages` 标志时,本地重放要求同一个获准的 OpenCodex 调用方和请求自身的非空白 `thread-id`。父线程、loopback 地址、上游凭据或占位密钥都不是该身份。没有获准调用方主体标识的客户端以及没有自身线程的请求必须省略 `previous_response_id`,重新发送完整对话。默认的 loopback Codex 与 DSH 配置不建立该调用方身份。任一设置启用期间保留的状态在禁用后仍保持绑定。默认 `v1` 对未绑定的状态保留现有的父线程范围续接行为,包括默认 loopback 调用方。这与上游原生拥有的 Responses ID 相互独立。 + ## 线程标识与历史记录 默认的 loopback 形式会让新线程继续标记为 Codex 原生的 `openai` provider,因此正常的 resume history 不需要 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index a21b5265200..051bdc6dca0 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -13,12 +13,13 @@ description: 控制 Codex 如何在所有模型上生成和管理子代理。 | 模式 | Codex 获得什么 | 适合谁 | | --- | --- | --- | -| **v1** | 经典的命名空间 `spawn_agent`、`send_input`、`resume_agent` 和 `close_agent` 工具。spawn 可以直接选择另一个模型。 | 需要在不同 provider 之间可靠委派的初学者,尤其是原生到路由子级。 | -| **base**(默认) | 上游模型固定值:GPT-5.6 Sol/Terra 使用 v2,Luna 使用 v1,未固定的模型遵循 Codex 的 `multi_agent_v2` 功能开关。 | 大多数用户。它按 Codex 对每个模型预期的界面运行,而不是全局强制一种。 | +| **v1**(全新安装的默认值) | 经典的命名空间 `spawn_agent`、`send_input`、`resume_agent` 和 `close_agent` 工具。spawn 可以直接选择另一个模型。 | 需要在不同 provider 之间可靠委派的初学者,尤其是原生到路由子级。 | +| **base** | 上游模型固定值:GPT-5.6 Sol/Terra 使用 v2,Luna 使用 v1,未固定的模型遵循 Codex 的 `multi_agent_v2` 功能开关。 | 大多数用户。它按 Codex 对每个模型预期的界面运行,而不是全局强制一种。 | | **v2** | 扁平的 `spawn_agent`、`send_message`、`followup_task`、`interrupt_agent` 和 agent-list 工具,并支持并发会话。 | 想要更新的并发工作流,并理解模型继承以及下面加密任务限制的用户。 | +| **v2-plaintext** | 为新会话固定 V2 目录项,关闭全局强制,并在规范的 ChatGPT 转发路径上以明文编译符合条件的任务消息。 | 明确接受明文任务可能被保留的用户;进入时需要确认。 | :::tip[不确定?] -先从 **base** 开始。只有当跨 provider 委派必须可预测地工作时才选 **v1**。只有在你明确想让所有目录条目都使用更新的会话模型时,才强制使用 **v2**。 +先从全新安装的默认模式 **v1** 开始,以便跨 provider 委派可预测地工作。要遵循上游固定值,请选择 **base**。只有在你明确想让所有目录条目都使用更新的会话模型时,才强制使用 **v2**。 ::: ## 工作原理 @@ -28,6 +29,7 @@ description: 控制 Codex 如何在所有模型上生成和管理子代理。 - **v1** 会把所有模型的 `multi_agent_version` 设为 `"v1"`。 - **base** 会恢复上游固定值。未固定的条目会遵循原生 `multi_agent_v2` 功能开关。 - **v2** 会把所有模型的 `multi_agent_version` 设为 `"v2"`;但启用 **让 ChatGPT 保持 v1** 时例外:ChatGPT 原生条目保持 `"v1"`,路由/组合条目仍为 `"v2"`。 +- **v2-plaintext** 为新会话将目录项固定为 `"v2"`,关闭全局 V2 强制并启用明文编译器。 opencodex 会把这一点作为最后一步同时应用到实时的 `/v1/models` 目录和同步到磁盘的目录。因此,模式更改会一致影响新建的 App、CLI 和 TUI 会话。 @@ -183,6 +185,8 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model":"anthropic/claude-sonnet-5","effort":"xhigh"}' ``` +首次通过 `PUT /api/v2` 进入 `v2-plaintext` 时,必须同时提交 `plaintextAgentMessagesAcknowledged: true`;仪表板会要求用户确认,CLI 则使用 `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`。 + ## FAQ ### 选择委派模型会强制 Codex spawn 它吗? @@ -203,7 +207,7 @@ Codex 按 priority 从目录中填充最多五个列出位置。设置的模型 ### 模式更改会影响正在运行的会话吗? -不会。更改模式后请启动一个新的 Codex 会话。如果长时间运行的 App host 仍然显示旧的目录状态,请运行 `ocx sync` 并重启那个 Codex 界面。 +目录固定值只影响新会话,已有会话保留其界面。但明文编译器遵循当前设置,也会影响已有 V2 会话后续符合条件的请求。要使用新的目录固定值,请启动新的 Codex 会话;如果长时间运行的 App host 仍显示旧目录状态,请运行 `ocx sync` 并重启该界面。 ### 推理强度 @@ -213,7 +217,7 @@ Codex 按 priority 从目录中填充最多五个列出位置。设置的模型 模型上下文上限与子代理模式无关。请在 Models 页面配置它;原生 OpenAI 模型会保留其真实的上下文窗口。 -新配置不会写入实验性的 `plaintextV2AgentMessages` 字段,只有显式设置为 `true` 才会启用。 +新配置不会写入实验性的 `plaintextV2AgentMessages` 字段;显式设置为 `true` 或选择 `v2-plaintext` 模式均会启用编译器。该模式为新会话选择 V2 并关闭全局强制,不覆盖独立字段。编译器遵循当前设置,也影响已有 V2 会话的后续符合条件的请求;已有密文不会被解密,已保存的历史不会被改写,禁用不会删除已保留的副本。 调用方必须使用 Responses 格式,最终目标必须采用 `adapter: "openai-responses"`、 `authMode: "forward"` 和准确的基础地址 `https://chatgpt.com/backend-api/codex`。OpenAI API key provider、自定义兼容网关、最终发往其他 provider 的请求,以及非 Responses 调用都不会被改写。 diff --git a/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md b/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md index 2f1894cfa2f..f2e2afc898a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md @@ -5,7 +5,23 @@ description: v2 加密任务限制会破坏什么、OpenCodex 为何现在默认 OpenCodex 安装后的子代理界面默认为 **v1**。Dashboard、Models 和 Subagents 页面在切换到 **base** 或 **v2** 前都会要求确认,并链接到本页。CLI 不会提示。 -原因明确而具体:在 v2 上,从 ChatGPT 原生模型分派给路由模型的任务无法被路由模型读取。这恰恰是最常见的委派方式——GPT 父代理启动 Grok、Claude 或 GLM 子代理——而在 v2 上每次都会失败。 +原因明确而具体:在未改变的加密 v2 路径上,从 ChatGPT 原生模型分派给路由模型的任务无法被路由模型读取。这是常见的委派方式——GPT 父代理启动 Grok、Claude 或 GLM 子代理——无法读取的加密任务会在此边界失败。下面的实验性选项改变新任务消息的生成方式,不会解密已有消息。 + +## 可选的明文任务 V2 + +**V2 · 明文任务**(`v2-plaintext`)为新会话固定 V2 目录项,同时关闭全局 V2 强制。它保留 V2 的任务名称、层级、后续消息、取消和并发功能,并对符合条件的规范 ChatGPT Responses 请求使用实验性明文编译器。已有会话保留其记录的选择;更新不会自动选择此模式。 + +目录固定值适用于新会话,编译器则遵循当前设置,处理已有 V2 会话后续符合条件的请求。显式设置独立的 `plaintextV2AgentMessages: true` 也会启用编译器。 + +明文编译启用时,本地响应 ID 续接要求同一个获准的 opencodex API 密钥身份以及请求自身的 `thread-id`;父线程 ID 不能授权访问子任务。缺少任一身份、代理重启后或使用较旧的未绑定缓存时,请省略 `previous_response_id` 并重新发送完整对话。启用期间保留的状态在切换模式后仍保持绑定;默认 `v1` 对未绑定状态保留按父线程范围续接的行为,包括 loopback 调用方。 + +仪表板要求确认任务文本可能保留在 Codex 历史、所选提供商请求、本地响应与调试状态中,包括磁盘上的续接快照或 spill 文件。HTTPS 和身份验证仍启用,不会增加恢复模型调用。已有密文不会被解密,已保存的历史不会被改写;禁用编译器也不会删除已保留的副本。不受支持的后端或客户端行为仍可能失败。普通 v1 和原生 v1 / 路由 v2 混合模式仍可使用。 + +```bash +ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages +``` + +使用 `ocx v2 status --live --json` 查看实时目标。配置本地目标时,两个命令都省略 `--live`,并使用 `ocx v2 status --json` 查看;配置状态和目录收敛并不等于真实委派成功。要退出该模式,请明确选择其他模式;独立设置的 `plaintextV2AgentMessages` 值会被保留。 ## 发生时会看到什么 @@ -28,11 +44,11 @@ OpenCodex 安装后的子代理界面默认为 **v1**。Dashboard、Models 和 S 在 v1 上,父代理以纯文本发出子代理的任务。OpenCodex 读取并路由它,子代理因而收到可执行的内容。 -在 v2 上,父代理以 ChatGPT 后端生成的 `encrypted_content` 发出任务。密钥留在该后端,OpenCodex 从未持有它,因此代理既无法解密也无法改写;该值确实是密文,并不是由标志隐藏的明文。这是结构限制,而非配置错误;代理端设置无法启用本地解密。 +在加密 v2 路径上,父代理以 ChatGPT 后端生成的 `encrypted_content` 发出任务。密钥留在该后端,OpenCodex 从未持有它,因此代理既无法解密也无法改写;该值确实是密文,并不是由标志隐藏的明文。明文编译器只能影响符合条件的新消息,不能修复已有密文。 代理设置无法启用本地解密。实验性 `agentTaskRecovery` 可以请求 ChatGPT 后端恢复符合条件的任务,但不会在本地解密其密文。上述授权、适用条件和失败处理限制仍然有效。 -有三种拓扑不受影响;了解它们也有助于理解失败发生的位置: +下表比较未启用明文编译器时的拓扑,也有助于理解失败发生的位置: | 拓扑 | v1 | v2 | | --- | --- | --- | @@ -50,13 +66,14 @@ OpenCodex 安装后的子代理界面默认为 **v1**。Dashboard、Models 和 S OpenCodex 在 [#92](https://github.com/lidge-jun/opencodex/issues/92) 记录了影响,并以“不计划处理”关闭:此仓库无法修复,因此该 issue 是上游工作的索引,并非等待这里的维护者处理的任务。 -## 三种模式目前的作用 +## 模式目前的作用 | 模式 | 界面 | 适用情况 | | --- | --- | --- | | **v1**(默认) | 每个模型都提供经典的带命名空间的启动工具。启动时可直接指定另一模型。 | 跨提供商委派的用户。这是当前发布的默认值。 | | **base** | 上游模型固定设置:Sol 和 Terra 使用 v2,Luna 使用 v1,未固定的模型遵循 Codex 自身的标志。 | 希望采用 Codex 预期的各模型界面,且只在同一提供商内委派。 | | **v2** | 每个模型都提供扁平的并发工具。 | 希望使用较新的并发会话模型,且父代理与子代理位于边界同侧。 | +| **v2-plaintext** | 新会话使用 V2 目录固定值,全局强制关闭;符合条件的规范 ChatGPT 请求使用明文任务。 | 明确接受任务文本可能被保留、希望使用 V2 委派的用户。 | base 排在第二而非第一,是因为它将大多数人用于*发起*委派的 Sol 和 Terra 固定在 v2。对于这一问题,base 不是折中选项;ChatGPT 向路由模型启动子代理时,它的行为与 v2 相同。 @@ -69,7 +86,7 @@ base 排在第二而非第一,是因为它将大多数人用于*发起*委派 两种选择都会被记录,通知不再出现。如果直接关闭通知而未作选择,下次打开仪表盘时仍会看到它。 -模式变更只对**新建** Codex 会话生效。选择后请新建会话;如果长期运行的 App 宿主仍显示旧界面,请执行 `ocx sync` 并重启该界面。 +目录固定值变更对**新建** Codex 会话生效;明文编译器的当前设置也会影响已有 V2 会话后续符合条件的请求。要使用新的目录固定值,请新建会话;如果长期运行的 App 宿主仍显示旧界面,请执行 `ocx sync` 并重启该界面。 ## 如果仍想使用 v2 @@ -88,7 +105,7 @@ base 排在第二而非第一,是因为它将大多数人用于*发起*委派 ## 更改模式 -Dashboard、Models 和 Subagents 都提供相同的 v1/base/v2 开关,而且切换到 base 或 v2 时都会要求确认。CLI 命令如下: +Dashboard、Models 和 Subagents 提供模式开关;切换到 base、v2 或 v2-plaintext 时需要确认。CLI 的明文模式需要上文所示的确认标志。其他 CLI 命令如下: ```bash ocx v2 status diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 4ee66a405a2..bf7510316d2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -35,6 +35,10 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 声明需要确认的破坏性删除、导入、额度消耗和更新操作,在非交互用法中都要求 `--yes`。该标志是显式选择加入;省略它时,绝不能在静默情况下确认该操作。 +## 恢复明文子代理模式配置 + +进入实验性明文子代理模式请使用 `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`。通用的 `config set` 和 `config import` 会拒绝新进入该模式,因为专用命令还会协调原生模式转换。恢复包含 `multiAgentMode: "v2-plaintext"` 的导出配置前,先完成上述确认转换,再导入文件。恢复已经启用的模式、修改其他设置和退出明文模式仍然允许;独立的 `plaintextV2AgentMessages` 首选项不受影响。 + ## 版本与内部分发目标 `ocx --version`、`ocx -v` 和 `ocx version` 会打印一行适合脚本读取的版本信息并退出。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index ef7917dc81e..4f45d4b30e7 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -22,9 +22,9 @@ Codex 集成会把 `web_search = "disabled"` 写入 `~/.codex/config.toml`,这 命令会报告由此触发的 Codex 侧写入(`--json` 中的 `codexWebSearch`,否则为末尾的 `Codex config:` 行),并在无法写入时提示 `ocx sync`。该标志对 `vision` 同样有效。 -### `ocx v2 |threads >` +### `ocx v2 |keep-native-v1 |threads |mode-hint >` -管理 Codex 的 `multi_agent_v2` 功能标志和三态多代理 surface 模式。 +管理 Codex 的 `multi_agent_v2` 功能标志和四态多代理 surface 模式。 | 子命令 | 动作 | | --- | --- | @@ -33,8 +33,12 @@ Codex 集成会把 `web_search = "disabled"` 写入 `~/.codex/config.toml`,这 | `off` | 禁用 `multi_agent_v2` 功能并重新同步目录。 | | `mode v1` | 强制所有模型使用 v1,禁用原生 v2,并保留当前线程上限。 | | `mode default` | 遵循上游模型的 surface 固定配置。 | -| `mode v2` | 强制所有模型使用 v2,启用原生 v2,并保留当前线程上限。 | +| `mode v2` | 强制模型使用 v2 并保留线程上限。关闭 `keep-native-v1` 时启用全局原生 v2;开启时关闭全局强制并使用目录固定值。 | +| `mode v2-plaintext --acknowledge-plaintext-agent-messages` | 为新会话选择 V2,关闭全局强制,并以明文传递符合条件的任务。进入需要 `--acknowledge-plaintext-agent-messages`。 | +| `keep-native-v1 on\|off` | 在 `mode v2` 下让 ChatGPT 原生模型保持 v1、路由模型使用 v2;开启时先关闭全局 V2 强制,再同步目录。 | | `threads ` | 将当前 v1/v2 线程上限设为一个至少为 1 的整数。 | +| `mode-hint ` | 为所有模型和 effort 设置主动委派提示(Ultra mode)。 | +| `mode-hint --clear` | 移除提示,恢复由 effort 决定的策略(ultra 为主动委派)。 | ```bash ocx v2 status @@ -44,7 +48,7 @@ ocx v2 on ocx v2 threads 16 ``` -`mode` 子命令会将 `multiAgentMode` 写入 opencodex 配置,并重新同步 Codex 目录。模式和标志的切换会在有效的 v1/v2 Codex 键之间迁移当前的数值线程上限;如果切换失败,会恢复原始的 `config.toml`。更改只会应用于新的 Codex 会话,正在运行的会话会保持其已固定的 surface。 +`mode` 子命令会将 `multiAgentMode` 写入 opencodex 配置,并重新同步 Codex 目录。模式和标志的切换会在有效的 v1/v2 Codex 键之间迁移当前的数值线程上限;如果切换失败,会恢复原始的 `config.toml`。目录固定值应用于新会话,已有会话保留其 surface;明文编译器的当前设置也影响已有 V2 会话后续符合条件的请求。仪表板的 Ultra mode 开关可在原生功能启用且模式为 `v2` 时使用,也可在 `v2-plaintext` 模式下使用;仅运行 `ocx v2 on` 不满足条件。 ## Combo routing 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 961bbf33c61..a3aa6577f91 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 @@ -9,7 +9,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` 会把目录中的每个模型都标记为 v1;`v2` 会把每个模型都标记为 v2。`default` 会恢复上游固定值(Sol/Terra 为 v2,Luna 为 v1),否则遵循原生 `multi_agent_v2` 标志。适用于新会话。 | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` 会把目录中的每个模型都标记为 v1;`v2` 会把每个模型都标记为 v2。`default` 会恢复上游固定值(Sol/Terra 为 v2,Luna 为 v1),否则遵循原生 `multi_agent_v2` 标志。`v2-plaintext` 启用实验性明文任务传递并关闭全局强制。目录固定值适用于新会话;启用的编译器也影响已有 V2 会话后续符合条件的请求。 | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | 最多五个原生 id、账户限定的 `/` id 或路由 `provider/model` id,优先显示在子代理选择器中。新增选项只提供原生和路由 id;保存时会保留已配置的精确账户限定 id,包括当前目录中不可用的 id。如需添加新的精确账户限定 id,请使用 `ocx agent subagents set` 或编辑配置。[Astra 一次性升级](/reference/configuration/agents/#astra-roster-upgrade)后,显式空列表会被保留。 | | `injectionModel?` | `string` | — | 在代理生成的 v2 委派引导中使用的首选原生或路由后的子代理模型。 | | `injectionEffort?` | `string` | — | 首选 effort(`low` 到 `ultra`),只有在 `injectionModel` 存在时才有意义。 | @@ -21,9 +21,9 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | `subagentModelFallbackPollMs?` | `number` | `60000` | 可用性探测缓存间隔。低于 1000 ms 的值会回退到默认值。 | | `effortCap?` | `string` | — | 对符合条件的 v2 主轮次和标记的派生子轮次设置硬上限。接受 `low` 到 `ultra`。 | | `subagentEffortCap?` | `string` | — | 仅针对派生子轮次的额外上限。两个上限同时适用时,较低者生效。 | -| `plaintextV2AgentMessages?` | `boolean` | —(未设置) | 实验性选项。只有显式设置为 `true` 才会启用。符合条件的新 `spawn_agent`、`send_message` 和 `followup_task` 调用会使用明文消息参数。详见[明文 v2 代理消息](#明文-v2-代理消息)。 | +| `plaintextV2AgentMessages?` | `boolean` | —(未设置) | 实验性选项。显式设置为 `true` 或选择 `multiAgentMode: "v2-plaintext"` 均会启用。符合条件的新 `spawn_agent`、`send_message` 和 `followup_task` 调用会使用明文消息参数。详见[明文 v2 代理消息](#明文-v2-代理消息)。 | -通过仪表板或 `ocx v2 status|on|off|mode |threads ` 管理该界面。模式变更会应用于新会话。`maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 字段,不是 `config.json` 键;`ocx v2 threads ` 会在启用 v2 后,将 `max_concurrent_threads_per_session` 写入 Codex 的 `$CODEX_HOME/config.toml` 中的 `[features.multi_agent_v2]` 下。 +通过仪表板或 `ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint ` 管理该界面。通过 CLI 进入 `v2-plaintext` 需要 `--acknowledge-plaintext-agent-messages`;仪表板要求确认,通过 `PUT /api/v2` 新进入时必须提交 `plaintextAgentMessagesAcknowledged: true`。目录模式变更应用于新会话,编译器的当前设置也影响已有 V2 会话后续符合条件的请求。`maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 字段,不是 `config.json` 键;`ocx v2 threads ` 会在启用 v2 后,将 `max_concurrent_threads_per_session` 写入 Codex 的 `$CODEX_HOME/config.toml` 中的 `[features.multi_agent_v2]` 下。 管理 API 公开 `GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models` 和 `/api/subagent-model-fallback`。injection-model 更新是部分更新;自定义 prompt 是该 API 上的 `prompt` 字段。 @@ -31,6 +31,8 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 Subagents → 高级中的 **始终主动委派**(原名 **Ultra mode**)只改变触发委派的条件,不改变推理 effort。推荐预设仍遵循用户指令、权限边界、任务范围和工具规则。 +仪表板的开关要求原生功能已启用且显式选择 `v2`,或者选择 `v2-plaintext`;仅运行 `ocx v2 on` 不满足条件。 + `GET` 和 `PUT /api/v2` 还会返回 `multiAgentModeHintRecommendation: { text, revision }`。仪表板在启用或恢复预设时使用服务器提供的文本,不会回退到硬编码文案。如果旧服务器没有提供推荐值,或返回的值格式无效,则无法应用或恢复预设;仍可编辑或清除现有的自定义提示。恢复预设只修改本地草稿,保存操作才会将其写入配置。 读取设置、无关更新和版本升级不会迁移已保存的提示。只有显式更新提示,且正文与两种已知旧版 OpenCodex 预设之一逐字节完全一致时,才会替换为当前推荐文本。其他有效的自定义文本,包括仅空白字符不同的变体,都会逐字节保留。现有的 v2 启用、功能支持检查和清除提示规则保持不变;更改会应用于新的 Codex 会话。 @@ -83,7 +85,9 @@ opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或 ## 明文 v2 代理消息 -新配置不会写入 `plaintextV2AgentMessages`。只有显式设置为 `true` 才会启用。调用方必须使用 Responses +新配置不会写入 `plaintextV2AgentMessages`。显式设置为 `true` 或选择 `multiAgentMode: "v2-plaintext"` 均会启用此路径。该模式关闭全局强制并为新会话固定 V2;独立布尔值不会被覆盖。编译器遵循当前设置,也影响已有 V2 会话的后续符合条件的请求。已有密文不会被解密,历史不会被改写,禁用也不会删除历史、提供商请求或本地响应与调试状态中已保留的副本。通过仪表板确认或 `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages` 进入该模式。 + +调用方必须使用 Responses 格式,最终目标必须采用规范的 ChatGPT Codex 转发配置,即 `adapter: "openai-responses"`、 `authMode: "forward"` 和准确的基础地址 `https://chatgpt.com/backend-api/codex`。OpenAI API key provider、自定义 OpenAI 兼容网关、最终发往其他 provider 的请求,以及非 Responses 调用都不会被改写。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 2d00f3344aa..53c85f4d4a5 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -178,6 +178,8 @@ Codex 保持一致。opencodex 也透過 WebSocket 提供 `/v1/responses`。專 `"websockets": true` 時宣告 `supports_websockets = true`;loopback 模式下,Codex 的內建 provider 可能先嘗試 WebSocket,若 proxy 未啟用此功能則回傳 `426`,讓 Codex fallback 到 HTTP/SSE。 +啟用 `v2-plaintext` 模式或 `plaintextV2AgentMessages` 旗標時,本機重放要求同一個獲准的 OpenCodex 呼叫方,以及請求自身非空白的 `thread-id`。父執行緒、loopback 位址、上游憑證或預留位置金鑰都不是該身分。沒有獲准呼叫方主體身分的用戶端,以及沒有自身執行緒的請求,必須省略 `previous_response_id`,重新傳送完整對話。預設的 loopback Codex 與 DSH 設定不會建立該呼叫方身分。任一設定啟用期間保留的狀態在停用後仍維持綁定。預設 `v1` 對未綁定的狀態保留既有的父執行緒範圍續接行為,包括預設 loopback 呼叫方。這與上游原生擁有的 Responses ID 各自獨立。 + ## Thread identity 與歷史記錄 預設 loopback 形式會讓新 thread 保持使用 Codex 原生的 `openai` provider 標記,因此一般 resume diff --git a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md index 0e918f014c3..bc512f2800d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md @@ -1,5 +1,5 @@ --- -title: 子代理介面(v1 / base / v2) +title: 子代理介面(v1 / base / v2 / v2-plaintext) description: 全域控制 Codex 在所有模型上生成和管理子代理的方式。 --- @@ -19,9 +19,10 @@ opencodex 允許你為目錄中的所有模型選擇多代理協作介面。儀 | 模式 | 介面 | 行為 | | --- | --- | --- | -| **v1** | `multi_agent_v1` | 使用經典的名稱空間代理工具,以及 `send_input` / `close_agent` / `resume_agent`。`spawn_agent` 的模型覆蓋可以在其他模型上生成子代理。 | -| **base**(預設) | 上游固定值 | 恢復上游模型的固定值:gpt-5.6-sol、gpt-5.6-terra 和 gpt-6.1-terra 使用 v2,gpt-5.6-luna 使用 v1;未固定的模型遵循 Codex 的 `multi_agent_v2` 功能開關。生成行為取決於該模型最終使用的介面。 | +| **v1**(全新安裝的預設值) | `multi_agent_v1` | 使用經典的名稱空間代理工具,以及 `send_input` / `close_agent` / `resume_agent`。`spawn_agent` 的模型覆蓋可以在其他模型上生成子代理。 | +| **base** | 上游固定值 | 恢復上游模型的固定值:gpt-5.6-sol、gpt-5.6-terra 和 gpt-6.1-terra 使用 v2,gpt-5.6-luna 使用 v1;未固定的模型遵循 Codex 的 `multi_agent_v2` 功能開關。生成行為取決於該模型最終使用的介面。 | | **v2** | `multi_agent_v2` | 使用扁平的 `spawn_agent` 工具、併發會話,以及 `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`。全量歷史 fork 時子代理繼承父模型;`fork_turns: "none"`(或部分 fork)時接受 `model` / `reasoning_effort` 覆蓋。如果原生→路由子代理只收到後端加密的任務內容,未明確信任的外部路由會回傳 `unreadable_encrypted_agent_task`;明確信任的直接金鑰驗證 Responses 路由可以原樣接收,而混合組合仍優先選擇可解密的原生目標([#92](https://github.com/lidge-jun/opencodex/issues/92))。 | +| **v2-plaintext** | V2 | 為新工作階段選擇 V2、關閉全域強制,並以明文傳遞符合條件的工作。進入需要 `--acknowledge-plaintext-agent-messages`。 | ## 運作原理 @@ -29,6 +30,7 @@ opencodex 允許你為目錄中的所有模型選擇多代理協作介面。儀 - **v1 模式**:強制所有條目使用 `multi_agent_version = "v1"`,覆蓋上游固定值。 - **base 模式**:恢復上游預設值。已固定的模型使用快照值;未固定的模型不寫入該欄位,交由 Codex 功能開關決定。 +- **v2-plaintext 模式**:為新工作階段將所有目錄條目固定為 V2,關閉全域強制並啟用合格請求的明文編譯。 - **v2 模式**:強制所有條目使用 `multi_agent_version = "v2"`,覆蓋上游固定值;但啟用 **讓 ChatGPT 保持 v1** 時例外:ChatGPT 原生條目維持 `"v1"`,路由/組合條目仍為 `"v2"`。 無論是即時 `/v1/models` 目錄回應,還是磁碟目錄同步,這項覆蓋都會作為最後一步執行。因此,無論條目原本如何生成,新會話都會使用一致的模式。 @@ -149,11 +151,17 @@ opencodex 會安全失敗,而不是轉發空或無法讀取的任務: 恢復此格式,但會消耗配額、增加延遲,並依賴非公開後端行為。任何失敗都保留原本的 `unreadable_encrypted_agent_task` 錯誤。詳見[英文設定參考](/reference/configuration/agents/#encrypted-v2-task-recovery)。 +## 明文 v2 任務訊息 + +新設定中的 `plaintextV2AgentMessages` 預設未設定。明確設為 `true` 或選擇 `v2-plaintext` 模式都會啟用編譯器;離開模式不會改變獨立的布林值。它只處理 Responses 呼叫,且最終目的地必須是標準 ChatGPT Codex forward 路徑:`adapter: "openai-responses"`、`authMode: "forward"` 和 `https://chatgpt.com/backend-api/codex`。OpenAI API 金鑰 provider、自訂閘道、其他最終 provider 及非 Responses 呼叫不受改寫。 + +啟用後也會處理既有 V2 工作階段的後續合格請求;既有密文不會解密,已儲存歷史不會改寫,停用不會刪除已保留的副本。詳見[代理設定](/zh-tw/reference/configuration/agents/#plaintext-v2-agent-messages)。 + ## 更改模式 ### GUI -- **Dashboard** → 第一個狀態單元:選擇 **v1**、**base** 或 **v2**。 +- **Dashboard** → 第一個狀態單元:選擇 **v1**、**base**、**v2** 或 **v2-plaintext**;進入明文模式需要在 GUI 中確認。 - **Models** 頁面 → 使用頂部的分段控制元件。 - 兩個頁面都有 **?** 按鈕,可開啟幫助彈窗並返回本文。 - **Dashboard** → **子代理委託**:選擇首選模型和可選的推理強度。在 v2 上,注入的指引會要求以 `fork_turns: "none"` 生成,使模型覆蓋得以應用。如果原生→路由子代理只收到加密任務內容,請使用原生目標、v1,或明確信任的直接金鑰驗證 Responses relay;其他僅外部目標的傳輸會明確回傳 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 @@ -164,6 +172,7 @@ opencodex 會安全失敗,而不是轉發空或無法讀取的任務: ocx v2 mode v1 # 強制所有模型使用 v1 ocx v2 mode default # 恢復上游固定值 ocx v2 mode v2 # 強制所有模型使用 v2 +ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages # 選用明文任務 ocx v2 status # 顯示目前模式和 Codex 功能開關 ``` @@ -179,7 +188,7 @@ curl -X PUT http://localhost:10100/api/v2 \ -d '{"multiAgentMode": "v2"}' ``` -`/api/v2` 的 PUT 端點還接受 `enabled`(布林值,Codex 功能開關)和 `maxConcurrentThreadsPerSession`(整數)。它會驗證請求、儲存模式、重新同步目錄,並提示模式更改從新會話開始生效。 +首次進入 `v2-plaintext` 時,`PUT /api/v2` 必須同時提供 `plaintextAgentMessagesAcknowledged: true`。`/api/v2` 的 PUT 端點還接受 `enabled`(布林值,Codex 功能開關)和 `maxConcurrentThreadsPerSession`(整數)。它會驗證請求、儲存模式、重新同步目錄,並提示模式更改從新會話開始生效。 委託選擇器使用另一個端點: @@ -227,7 +236,7 @@ Codex 依 priority 從目錄填入最多五個列出位置。設定的模型少 ### 模式變更會影響執行中的工作階段嗎? -不會。變更模式後請開啟新的 Codex 工作階段。若長時間執行的 App host 仍顯示過時的目錄狀態,請執行 +目錄介面選擇適用於新工作階段;但啟用明文編譯後,既有 V2 工作階段的後續合格請求也會受影響。變更目錄介面後請開啟新的 Codex 工作階段。若長時間執行的 App host 仍顯示過時的目錄狀態,請執行 `ocx sync` 並重新啟動該 Codex 介面。 ### 當 opencodex 無法信任目錄時會發生什麼? diff --git a/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md b/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md index 73f37355dab..b40d4572085 100644 --- a/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md @@ -5,7 +5,21 @@ description: v2 加密任務限制會造成什麼問題、OpenCodex 為何改以 OpenCodex 安裝後,子代理介面預設為 **v1**。Dashboard、Models 與 Subagents 頁面在切換至 **base** 或 **v2** 前都會要求確認,並連結至本頁說明。CLI 不會提示。 -原因很明確:在 v2 上,由 ChatGPT 原生模型交給路由模型的任務,路由模型無法讀取。這恰好是最常見的委派方式,即 GPT 父代理啟動 Grok、Claude 或 GLM 子代理,而在 v2 上每次都會失敗。 +原因很明確:在未變更的加密 v2 路徑上,ChatGPT 原生模型交給路由模型的任務可能無法被後者讀取。例如 GPT 父代理啟動 Grok、Claude 或 GLM 子代理時,無法讀取的加密任務會在邊界失敗。下述實驗選項改變新任務訊息的產生方式,但不解密既有密文。 + +## 選用以明文傳遞任務訊息的 V2 + +**V2 · 明文任務**(`v2-plaintext`)為新聊天選擇 V2 目錄 pin,且不啟用全域 V2 強制。它保留 V2 任務名稱、層級、後續訊息、取消與並行功能,並對符合條件的標準 ChatGPT Responses 請求使用實驗性明文編譯器。既有聊天保留其記錄的介面選擇;更新不會自動選取此模式。目錄 pin 適用於新工作階段,但編譯器依目前設定處理每個合格請求,包括既有 V2 工作階段的後續請求。 + +此模式下,本機 response ID 續接需要相同的獲准 opencodex API 金鑰身分和請求自身的 `thread-id`;父執行緒 ID 不能存取子任務。缺少任一身分、代理重啟後或使用舊的未綁定快取項目時,請省略 `previous_response_id` 並重新傳送完整對話。此模式保留的狀態在切換模式後仍維持綁定;預設 `v1` 對未綁定狀態保留既有的父執行緒範圍續接行為,包括 loopback 呼叫方。 + +儀表板要求確認任務文字可能保留在 Codex 歷史、所選 provider 請求及本機回應/除錯狀態,包括磁碟上的續接快照或溢出檔案。HTTPS 與驗證保持啟用,不會新增恢復模型呼叫。既有密文不會被解密,已儲存歷史不會改寫;停用也不會刪除已保留的副本。不支援的後端或用戶端行為仍可能失敗。一般 v1 與原生 v1/路由 v2 混合模式仍可使用。 + +```bash +ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages +``` + +使用 `ocx v2 status --live --json` 查看即時目標。設定本機目標時,兩個指令都省略 `--live`,並使用 `ocx v2 status --json` 查看;設定狀態和目錄收斂不代表實際委派成功。若要離開此模式,請明確選擇其他模式;獨立設定的 `plaintextV2AgentMessages` 值會保留。 ## 發生時會看到什麼 @@ -28,11 +42,11 @@ OpenCodex 安裝後,子代理介面預設為 **v1**。Dashboard、Models 與 S 在 v1 上,父代理會以純文字送出子代理任務。OpenCodex 能讀取並路由,子代理也能收到可執行的內容。 -在 v2 上,父代理送出的任務是由 ChatGPT 後端產生的 `encrypted_content`。金鑰留在該後端;OpenCodex 從未持有,所以代理無從解密或重寫。這個值是真正的密文,而非旗標掩蓋下的明文。因此這是結構性限制,不是設定錯誤;代理端設定無法啟用本機解密。 +在未變更的加密 v2 路徑上,父代理送出的任務是由 ChatGPT 後端產生的 `encrypted_content`。金鑰留在該後端;OpenCodex 從未持有,所以代理無從解密或重寫。這個值是真正的密文,而非旗標掩蓋下的明文。因此既有密文的不可讀性是結構性限制;明文選項改變的是新任務的產生方式。 代理設定無法啟用本機解密。實驗性 `agentTaskRecovery` 可以要求 ChatGPT 後端復原符合條件的任務,但不會在本機解密其密文。上述授權、適用條件和失敗處理限制仍然有效。 -有三種拓撲不受影響;了解它們有助於釐清失敗範圍: +下表比較未啟用明文編譯器時的加密 v2 路徑: | 拓撲 | v1 | v2 | | --- | --- | --- | @@ -57,6 +71,7 @@ OpenCodex 將影響記錄於 [#92](https://github.com/lidge-jun/opencodex/issu | **v1**(預設) | 每個模型都宣告傳統的具名稱空間 spawn 工具。啟動子代理時可直接指定其他模型。 | 需要跨供應商委派的使用者。這是目前發布的預設值。 | | **base** | 上游模型釘選:Sol 與 Terra 使用 v2,Luna 使用 v1;未釘選的模型遵循 Codex 自身旗標。 | 想使用 Codex 預期的各模型介面,而且只在同一供應商內委派。 | | **v2** | 每個模型都宣告扁平化的並行工具。 | 想使用較新的並行工作階段模型,且父子代理位於邊界同一側。 | +| **v2-plaintext** | 為新工作階段選擇 V2,關閉全域強制,並為合格請求啟用明文編譯。 | 願意確認明文任務可能被保留,並選用實驗性路徑。 | base 排在第二而非第一,因為其釘選讓大多數人用來*發起*委派的 Sol 與 Terra 採用 v2。對這個問題而言,base 不是折衷設定;從 ChatGPT 啟動路由子代理時,它的行為與 v2 相同。 @@ -69,7 +84,7 @@ base 排在第二而非第一,因為其釘選讓大多數人用來*發起*委 兩種選擇都會被記錄,通知不會再出現。若未回答就關閉通知,下次開啟儀表板時仍會顯示。 -模式變更只適用於**新的** Codex 工作階段。選擇後請啟動新工作階段;若長時間執行的 App host 仍顯示舊介面,請執行 `ocx sync` 並重新啟動該介面。 +目錄 pin 變更適用於**新的** Codex 工作階段;明文編譯器依目前設定處理合格請求,包括既有 V2 工作階段的後續請求。選擇後請啟動新工作階段;若長時間執行的 App host 仍顯示舊介面,請執行 `ocx sync` 並重新啟動該介面。 ## 如果仍想使用 v2 @@ -88,7 +103,7 @@ base 排在第二而非第一,因為其釘選讓大多數人用來*發起*委 ## 變更模式 -Dashboard、Models 與 Subagents 都提供同一個 v1/base/v2 開關,三個頁面在選擇 base 或 v2 前都會詢問。CLI 指令如下: +Dashboard、Models 與 Subagents 都提供 v1/base/v2/v2-plaintext 選擇,三個頁面在選擇 base、v2 或 v2-plaintext 前都會要求確認。CLI 指令如下: ```bash ocx v2 status diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index 56909f99bcf..829f54578a3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -60,3 +60,7 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 執行儀表板更新任務。它們是實作細節,不是穩定的使用者面向命令。儀表板會記錄 worker PID、恢復 worker 已死但仍在進行中的任務、把超過十分鐘且沒有 PID 的舊 active 記錄 視為過期,並保護執行中的 worker 免受並行更新影響。 + +## 明文子代理模式與設定還原 + +進入實驗性明文子代理模式請執行 `ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`。一般 `config set` 和 `config import` 會拒絕新進入此模式,因為專用命令也會協調原生模式轉換。還原含有 `multiAgentMode: "v2-plaintext"` 的匯出設定前,請先完成上述確認轉換,再匯入檔案。還原已啟用的模式、更改其他設定及離開明文模式仍受允許;獨立的 `plaintextV2AgentMessages` 偏好不變。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index cfdd4070fb4..6ea4ec0c748 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -22,18 +22,22 @@ Codex 整合會把 `web_search = "disabled"` 寫入 `~/.codex/config.toml`,這 指令會回報由此觸發的 Codex 端寫入(`--json` 中的 `codexWebSearch`,否則為結尾的 `Codex config:` 行),並在無法寫入時提示 `ocx sync`。該旗標對 `vision` 同樣有效。 -### `ocx v2 |threads >` +### `ocx v2 |threads >` -管理 Codex 的 `multi_agent_v2` 功能旗標與三態多代理介面模式。 +管理 Codex 的 `multi_agent_v2` 功能旗標與四態多代理介面模式。 | 子指令 | 動作 | | --- | --- | | `status`(預設) | 回報目前 v2 旗標、多代理模式與執行緒並行數。 | -| `on` | 啟用 `multi_agent_v2` 功能並重新同步目錄。 | +| `on` | 啟用 `multi_agent_v2` 功能並重新同步目錄;`v2-plaintext` 或混合 pin 生效時會拒絕,因為全域強制會蓋過目錄 pin。 | | `off` | 停用 `multi_agent_v2` 功能並重新同步目錄。 | | `mode v1` | 將所有模型強制為 v1、停用原生 v2,並保留現用執行緒上限。 | | `mode default` | 遵循上游模型介面 pin。 | -| `mode v2` | 將所有模型強制為 v2、啟用原生 v2,並保留現用執行緒上限。 | +| `mode v2` | 將所有模型設為 v2 並保留執行緒上限;`keep-native-v1` 關閉時啟用全域原生 v2,開啟時關閉全域強制並使用目錄 pin。 | +| `mode v2-plaintext --acknowledge-plaintext-agent-messages` | 為新工作階段選擇 V2、關閉全域強制,並以明文傳遞符合條件的工作。進入需要 `--acknowledge-plaintext-agent-messages`。 | +| `keep-native-v1 on\|off` | 在 `mode v2` 下讓原生 ChatGPT 使用 v1、路由模型使用 v2;啟用時關閉全域 V2 強制。 | +| `mode-hint ` | 為所有模型與推理強度設定主動委派提示。 | +| `mode-hint --clear` | 移除提示,恢復依推理強度決定的策略。 | | `threads ` | 將現用 v1/v2 執行緒上限設為不小於 1 的整數。 | ```bash @@ -44,7 +48,7 @@ ocx v2 on ocx v2 threads 16 ``` -`mode` 子指令將 `multiAgentMode` 寫入 opencodex 設定並重新同步 Codex 目錄。模式與旗標轉換會在有效的 v1/v2 Codex key 之間移動目前的數值執行緒上限;失敗的轉換會還原原始的 `config.toml`。變更套用於新的 Codex session,執行中的 session 則保留其 pin 的介面。 +`mode` 子指令將 `multiAgentMode` 寫入 opencodex 設定並重新同步 Codex 目錄。模式與旗標轉換會在有效的 v1/v2 Codex key 之間移動目前的數值執行緒上限;失敗的轉換會還原原始的 `config.toml`。目錄介面變更套用於新的 Codex session,執行中的 session 則保留其 pin;啟用明文編譯也會影響既有 V2 session 的後續合格請求。Subagents 的 Ultra 開啟開關需要已啟用原生功能且模式為 `v2`,或選擇 `v2-plaintext`(其全域強制保持關閉);僅執行 `ocx v2 on` 不符合條件。 ## 組合路由 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md index 88e5f4208ba..24fef47218a 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md @@ -9,7 +9,7 @@ Agent 設定控制要廣告哪個 Codex 協作介面,以及 opencodex 如何 | 欄位 | 型別 | 預設值 | 意義 | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` 將每個目錄模型標記為 v1;`v2` 將每個模型標記為 v2。`default` 還原上游 pin(Sol/Terra v2、Luna v1),否則遵循原生的 `multi_agent_v2` 旗標。套用於新 session。 | +| `multiAgentMode?` | `"v1" \| "default" \| "v2" \| "v2-plaintext"` | `"default"` | `v1` 將每個目錄模型標記為 v1;`v2` 將每個模型標記為 v2。`default` 還原上游 pin(Sol/Terra v2、Luna v1),否則遵循原生的 `multi_agent_v2` 旗標。目錄 pin 適用於新 session。`v2-plaintext` 選擇 V2、關閉全域強制並啟用實驗性明文任務傳遞;編譯器也影響既有 V2 工作階段的後續合格請求。 | | `subagentModels?` | `string[]` | `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-luna` | 最多五個原生 id、帳號限定的 `/` id 或路由 `provider/model` id,優先顯示在子代理選擇器中。新增選項只提供原生和路由 id;儲存時會保留已設定的精確帳號限定 id,包括目前目錄中無法使用的 id。如需新增精確帳號限定 id,請使用 `ocx agent subagents set` 或編輯設定。[Astra 一次性升級](/reference/configuration/agents/#astra-roster-upgrade)後,明確的空清單會被保留。 | | `injectionModel?` | `string` | — | 在代理撰寫的 v2 委派指引中使用的偏好原生或路由子代理模型。 | | `injectionEffort?` | `string` | — | 偏好 effort(`low` 到 `ultra`),僅在搭配 `injectionModel` 時有意義。 | @@ -19,9 +19,18 @@ Agent 設定控制要廣告哪個 Codex 協作介面,以及 opencodex 如何 | `subagentModelFallback?` | `string[]` | `[]` | 為生成的子任務回合排序的全域 fallback 模型。 | | `subagentModelFallbackPollMs?` | `number` | `60000` | 可用性探測快取間隔。低於 1000 ms 的值會回退到預設值。 | | `effortCap?` | `string` | — | 合格 v2 主回合與標記的生成子回合的硬性上限。接受 `low` 到 `ultra`。 | +| `plaintextV2AgentMessages?` | `boolean` | —(未設定) | 實驗性選項。明確設為 `true` 或選擇 `multiAgentMode: "v2-plaintext"` 會要求合格的原生 ChatGPT v2 父代理以明文產生 `spawn_agent`、`send_message`、`followup_task` 的訊息引數。詳見[明文 v2 代理訊息](#plaintext-v2-agent-messages)。 | | `subagentEffortCap?` | `string` | — | 僅針對生成子回合的額外上限。當兩個上限都適用時,取較低者。 | -使用儀表板或 `ocx v2 status|on|off|mode |threads ` 管理介面。模式變更套用於新 session。`maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 欄位,不是 `config.json` key;`ocx v2 threads ` 在啟用 v2 後,將 `max_concurrent_threads_per_session` 寫入 Codex 的 `$CODEX_HOME/config.toml` 中 `[features.multi_agent_v2]` 之下。 +使用儀表板或 `ocx v2 status|on|off|mode |threads ` 管理介面。目錄 pin 變更適用於新工作階段;明文編譯器依目前設定處理合格請求,包括既有 V2 工作階段的後續請求。`maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 欄位,不是 `config.json` key;`ocx v2 threads ` 在啟用 v2 後,將 `max_concurrent_threads_per_session` 寫入 Codex 的 `$CODEX_HOME/config.toml` 中 `[features.multi_agent_v2]` 之下。 + +## Plaintext v2 agent messages + +`plaintextV2AgentMessages` 在新設定中未設定;明確設為 `true` 或選擇 `multiAgentMode: "v2-plaintext"` 均會啟用此實驗路徑。模式不會覆寫獨立布林值,因此離開模式後仍遵循其原值。透過儀表板確認,或執行 `ocx v2 mode v2-plaintext --live --acknowledge-plaintext-agent-messages` 進入。管理 API 的 `PUT /api/v2` 首次進入時須提供 `plaintextAgentMessagesAcknowledged: true`。 + +呼叫方必須使用 Responses,最終目的地必須是標準 ChatGPT Codex forward 傳輸:`adapter: "openai-responses"`、`authMode: "forward"`,以及確切網址 `https://chatgpt.com/backend-api/codex`。API 金鑰 provider、自訂相容閘道、其他最終 provider 及非 Responses 呼叫不會被改寫。啟用編譯也影響既有 V2 工作階段後續的合格請求;既有密文不會被解密,已儲存歷史不會改寫,停用也不會刪除歷史、provider 請求或本機回應/除錯狀態中已保留的副本。HTTPS 與驗證仍啟用。 + +啟用任一設定時,本機重放需要相同獲准的 opencodex 呼叫方身分及請求自身非空白的 `thread-id`;否則須省略 `previous_response_id` 並重送完整對話。啟用期間保留的狀態停用後仍維持綁定。預設 `v1` 對未綁定狀態保留原有父執行緒範圍續接行為。 管理 API 暴露 `GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models` 與 `/api/subagent-model-fallback`。注入模型更新為部分更新;自訂 prompt 是該 API 的 `prompt` 欄位。 diff --git a/gui/src/components/PlaintextV2ConsentModal.tsx b/gui/src/components/PlaintextV2ConsentModal.tsx new file mode 100644 index 00000000000..9a2a0feab74 --- /dev/null +++ b/gui/src/components/PlaintextV2ConsentModal.tsx @@ -0,0 +1,90 @@ +/** + * Consent dialog for the 'v2-plaintext' sub-agent surface. + * + * The mode sends native v2 collaboration task text to ChatGPT without the encryption + * marker, so entering it requires an explicit acknowledgement on the same PUT. The + * checkbox is that acknowledgement: nothing is written until it is checked and the + * operator confirms. It deliberately replaces the base/v2 warning dialog for this + * mode, so one confirmation covers the experimental/plaintext notice and the usual + * "v1 is the reliable default" guidance instead of asking twice in a row. + */ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { IconAlert } from "../icons"; + +export default function PlaintextV2ConsentModal({ + busy = false, + onConfirm, + onDismiss, +}: { + busy?: boolean; + /** Checkbox checked plus confirm clicked: send the mode with its acknowledgement. */ + onConfirm: () => void; + /** Escape or backdrop: abandon the selection, keep the stored mode. */ + onDismiss: () => void; +}) { + const t = useT(); + const titleId = useId(); + const bodyId = useId(); + const dialogRef = useRef(null); + const [acknowledged, setAcknowledged] = useState(false); + + // Open as a native modal dialog — focus trapping and the backdrop come from the platform. + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + // Escape could otherwise close the dialog over a request that then lands anyway. + if (busy) return; + onDismiss(); + }, [busy, onDismiss]); + + return ( + + + + + + + ); +} diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index 480ef9d3346..66ef95dde87 100644 --- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx @@ -11,6 +11,7 @@ import { Select } from "../../ui"; import { IconArrowDown, IconArrowUp, IconChevron, IconX } from "../../icons"; import { useT, type TKey } from "../../i18n/shared"; import { formatNamespacedModelId } from "../../provider-icons"; +import { subagentSurfaceModes } from "../../subagent-surface"; import { clampNumberDraft } from "../../clamp-draft"; import { cn } from "@/lib/utils"; import { Button } from "../ui-v2/button"; @@ -311,11 +312,14 @@ export default function SubagentDelegationSection({ {t("models.v2DocsLink")} + {ultraMode.plaintextAgentMessagesEnabled === true && ( + {t("models.v2PlaintextOn")} + )} } help={{t("models.v2Help")}} control={
- {(["v1", "default", "v2"] as const).map(mode => ( + {subagentSurfaceModes(ultraMode.supportsPlaintextV2Mode === true).map(mode => (
{v2 && ( @@ -2068,6 +2069,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c {t("models.v2Conflict")} )} {v2Note && {v2Note}} + {v2.plaintextAgentMessagesEnabled === true && {t("models.v2PlaintextOn")}} )} diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index 010be87e962..030b0bea751 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -9,7 +9,8 @@ import { readSessionListCache, writeSessionListCache } from "../session-list-cac import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import SubagentSurfaceWarningModal from "../components/SubagentSurfaceWarningModal"; -import { SUBAGENT_SURFACE_GUIDE_URL } from "../subagent-surface"; +import PlaintextV2ConsentModal from "../components/PlaintextV2ConsentModal"; +import { SUBAGENT_SURFACE_GUIDE_URL, confirmsPlaintextV2Mode, type SubagentSurfaceMode } from "../subagent-surface"; import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; import { InEffectSummary } from "../components/v2-subagents/InEffectSummary"; import { formatNamespacedModelId } from "../provider-icons"; @@ -76,7 +77,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { * for. Switching endpoints with the dialog open must not apply one proxy's answer to another, * and tagging beats clearing it from an effect, which would be a cascading render. */ - const [pendingSurface, setPendingSurface] = useState<{ mode: "default" | "v2"; apiBase: string } | null>(null); + const [pendingSurface, setPendingSurface] = useState<{ mode: "default" | "v2" | "v2-plaintext"; apiBase: string } | null>(null); const [ultraLoadFailed, setUltraLoadFailed] = useState(false); const ultraLoadGeneration = useRef(0); const currentUltraApiBase = useRef(apiBase); @@ -94,10 +95,12 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const res = await fetch(`${apiBase}/api/v2`, { signal }); const data = await readJsonOrThrow<{ enabled?: boolean; - multiAgentMode?: "v1" | "default" | "v2"; + multiAgentMode?: SubagentSurfaceMode; multiAgentModeHintText?: string | null; keepNativeChatGptOnV1?: boolean; multiAgentModeHintRecommendation?: { text?: unknown; revision?: unknown }; + supportsPlaintextV2Mode?: boolean; + plaintextAgentMessagesEnabled?: boolean; }>(res, t("sub.ultraModeLoadFail")); if (!data) return false; if (signal?.aborted || generation !== ultraLoadGeneration.current || currentUltraApiBase.current !== apiBase) return false; @@ -119,8 +122,15 @@ export default function Subagents({ apiBase }: { apiBase: string }) { // 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", + // 'v2-plaintext' is also a forced V2 surface: every catalog row is pinned v2 + // while the global feature stays off. + multiAgentV2Enabled: (data.enabled === true && data.multiAgentMode === "v2") + || data.multiAgentMode === "v2-plaintext", + multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" || data.multiAgentMode === "v2-plaintext" + ? data.multiAgentMode + : "default", + supportsPlaintextV2Mode: data.supportsPlaintextV2Mode === true, + plaintextAgentMessagesEnabled: data.plaintextAgentMessagesEnabled === true, } }); return true; }, [apiBase, t]); @@ -150,12 +160,21 @@ export default function Subagents({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); - await readJsonOrThrow(res, t("sub.ultraModeSaveFail")); + const receipt = await readJsonOrThrow(res, t("sub.ultraModeSaveFail")); + if (patch.multiAgentMode === "v2-plaintext" && !confirmsPlaintextV2Mode(receipt)) { + throw new Error(t("sub.ultraModeSaveFail")); + } if (currentUltraApiBase.current !== requestApiBase || !await loadUltraMode()) return; setOk(true); setStatus(t("sub.ultraModeSaved")); } catch (error) { if (currentUltraApiBase.current !== requestApiBase) return; + if (patch.multiAgentMode === "v2-plaintext") { + // A failed receipt cannot prove the write failed. Reconcile this server's state + // without turning the original save failure into a success notification. + try { await loadUltraMode(); } catch { /* retain the save error */ } + if (currentUltraApiBase.current !== requestApiBase) return; + } setOk(false); setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); } finally { @@ -421,8 +440,10 @@ export default function Subagents({ apiBase }: { apiBase: string }) { ultraSaving: ultraSaving || !ultraModeCurrent, // This page carries the same v1/base/v2 switch as Models and the Dashboard, so it // needs the same gate: base and v2 wait for an answer, everything else writes. + // 'v2-plaintext' also waits — for its own consent dialog, which stages the + // plaintext acknowledgement onto the same request as the mode. onUltraModeSave: patch => { - if (patch.multiAgentMode === "default" || patch.multiAgentMode === "v2") { + if (patch.multiAgentMode === "default" || patch.multiAgentMode === "v2" || patch.multiAgentMode === "v2-plaintext") { setPendingSurface({ mode: patch.multiAgentMode, apiBase }); return; } @@ -432,7 +453,20 @@ export default function Subagents({ apiBase }: { apiBase: string }) { onUltraModeRetry: () => { void retryUltraMode(); }, }} /> - {pendingSurface && pendingSurface.apiBase === apiBase && ( + {pendingSurface && pendingSurface.apiBase === apiBase && pendingSurface.mode === "v2-plaintext" && ( + { + setPendingSurface(null); + void saveUltraMode({ + multiAgentMode: "v2-plaintext", + plaintextAgentMessagesAcknowledged: true, + }); + }} + onDismiss={() => setPendingSurface(null)} + /> + )} + {pendingSurface && pendingSurface.apiBase === apiBase && pendingSurface.mode !== "v2-plaintext" && ( false. */ + supportsPlaintextV2: boolean; }; export type DashboardEpochRefs = { @@ -243,16 +245,17 @@ export async function fetchDashboardMaMode( ): Promise { try { const v2Res = await fetch(`${apiBase}/api/v2`, { signal }); - if (!v2Res.ok) return { maMode: "default" }; - const v2Data = await v2Res.json() as { multiAgentMode?: unknown; multiAgentSurfaceAdvisory?: unknown }; + if (!v2Res.ok) return { maMode: "default", supportsPlaintextV2: false }; + const v2Data = await v2Res.json() as { multiAgentMode?: unknown; multiAgentSurfaceAdvisory?: unknown; supportsPlaintextV2Mode?: unknown }; const advisory = readSubagentSurfaceAdvisory(v2Data.multiAgentSurfaceAdvisory); - if (v2Data.multiAgentMode === "v1" || v2Data.multiAgentMode === "v2") { - return { maMode: v2Data.multiAgentMode, advisory }; + const supportsPlaintextV2 = v2Data.supportsPlaintextV2Mode === true; + if (v2Data.multiAgentMode === "v1" || v2Data.multiAgentMode === "v2" || v2Data.multiAgentMode === "v2-plaintext") { + return { maMode: v2Data.multiAgentMode, advisory, supportsPlaintextV2 }; } - return { maMode: "default", advisory }; + return { maMode: "default", advisory, supportsPlaintextV2 }; } catch (error) { if (isAbortError(error, signal)) throw error; - return { maMode: "default" }; + return { maMode: "default", supportsPlaintextV2: false }; } } diff --git a/gui/src/pages/dashboard-dialogs.tsx b/gui/src/pages/dashboard-dialogs.tsx index f0f10050680..a87624880e8 100644 --- a/gui/src/pages/dashboard-dialogs.tsx +++ b/gui/src/pages/dashboard-dialogs.tsx @@ -7,6 +7,7 @@ import { import type { useDashboardData } from "./use-dashboard-data"; import { shadowSourceModelLabel } from "./shadow-call-source"; import SubagentSurfaceWarningModal from "../components/SubagentSurfaceWarningModal"; +import PlaintextV2ConsentModal from "../components/PlaintextV2ConsentModal"; import { SUBAGENT_SURFACE_GUIDE_URL } from "../subagent-surface"; type Dash = ReturnType; @@ -213,7 +214,15 @@ export function DashboardDialogs(d: Dash) { - {(pendingMaMode || maAdvisoryOpen) && ( + {pendingMaMode === "v2-plaintext" ? ( + // Dedicated consent: one checkbox-gated PUT covers the experimental plaintext notice + // and the mode write; the generic advisory warning stays for every other reason. + { void keepMaMode(); }} + onDismiss={dismissMaSurfaceDialog} + /> + ) : (pendingMaMode || maAdvisoryOpen) && ( ) { +export function DashboardMultiAgentControl({ maMode, maSupportsPlaintextV2, maBusy, switchMaMode }: Pick) { const t = useT(); return (
@@ -129,7 +130,7 @@ export function DashboardMultiAgentControl({ maMode, maBusy, switchMaMode }: Pic aria-label={t("dash.multiAgent")} value={maMode} onChange={mode => { if (mode !== maMode) void switchMaMode(mode); }} - items={(["v1", "default", "v2"] as const).map(mode => ({ value: mode, label: t(`models.v2Mode_${mode}` as TKey) }))} + items={subagentSurfaceModes(maSupportsPlaintextV2 || maMode === "v2-plaintext").map(mode => ({ value: mode, label: t(`models.v2Mode_${mode}` as TKey) }))} />
); diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 6a4ba198dd5..c09c748e6d3 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -50,7 +50,7 @@ export function DashboardSubagentSection({ d }: { d: Dash }) { label={t("dash.multiAgent")} hint={t("proxyV2.maModeHint")} help={} - control={} + control={} > {maError &&
{maError}
} diff --git a/gui/src/pages/models-settings-summary.ts b/gui/src/pages/models-settings-summary.ts index e417896913c..e7093895479 100644 --- a/gui/src/pages/models-settings-summary.ts +++ b/gui/src/pages/models-settings-summary.ts @@ -5,6 +5,7 @@ */ import type { TFn, TKey } from "../i18n/shared"; import type { StorageLike } from "./collapse-store"; +import type { SubagentSurfaceMode } from "../subagent-surface"; import { fmtK } from "./models-shared"; export const SETTINGS_OPEN_KEY = "ocx.models.settingsOpen.v1"; @@ -37,7 +38,7 @@ export function writeSettingsOpen(open: boolean, storage?: StorageLike): void { } export interface ModelsSettingsState { - multiAgentMode?: "v1" | "default" | "v2"; + multiAgentMode?: SubagentSurfaceMode; /** Configured sub-agent limit as a logical child count; null/undefined means the runtime default. */ v2Threads?: number | null; keepNativeOnV1?: boolean; diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 3bd143f26f1..ac54c6c73b7 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -2,6 +2,7 @@ import type { TFn, TKey } from "../i18n/shared"; import type { ProviderDiscoverySummary } from "../models-groups"; import { modelVisible, type ProviderModelMap } from "../model-visibility"; import { formatNamespacedModelId } from "../provider-icons"; +import type { SubagentSurfaceMode } from "../subagent-surface"; import type { ShadowCallSourceOption } from "./shadow-call-source"; export type StorageLike = Pick; @@ -156,10 +157,38 @@ export interface V2Status { enabled: boolean; agentsMaxThreadsConflict: boolean; maxConcurrentThreadsPerSession?: number | null; - multiAgentMode?: "v1" | "default" | "v2"; + multiAgentMode?: SubagentSurfaceMode; keepNativeChatGptOnV1?: boolean; /** Response-only; absent on a runtime older than the v1-default advisory. */ multiAgentSurfaceAdvisory?: unknown; + /** Response-only; absent on a runtime older than the 'v2-plaintext' mode. */ + supportsPlaintextV2Mode?: boolean; + /** + * Response-only: the plaintext-delivery SETTING is on (the mode, or the legacy + * experimental flag). It does not prove any particular message left in plaintext — + * a request the compiler cannot safely rewrite is still sent unchanged. + */ + plaintextAgentMessagesEnabled?: boolean; +} + +/** + * Narrow a /api/v2 response into renderable state. Absent or unrecognised fields fall + * back to the pre-'v2-plaintext' shape, so an older runtime cannot produce a phantom + * option and a malformed mode cannot stick. + */ +export function normalizeV2Status(data: V2Status): V2Status { + return { + enabled: data.enabled, + agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, + maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, + multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" || data.multiAgentMode === "v2-plaintext" + ? data.multiAgentMode + : "default", + keepNativeChatGptOnV1: data.keepNativeChatGptOnV1 === true, + multiAgentSurfaceAdvisory: data.multiAgentSurfaceAdvisory, + supportsPlaintextV2Mode: data.supportsPlaintextV2Mode === true, + plaintextAgentMessagesEnabled: data.plaintextAgentMessagesEnabled === true, + }; } export interface ShadowCallData { diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index acf9bb5ace4..883569061cf 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -28,7 +28,8 @@ import { type DashboardEpochRefs, } from "./dashboard-core-poll"; import { usageSummary30dResourceKey } from "../usage-summary-resource"; -import type { SubagentSurfaceAdvisory } from "../subagent-surface"; +import { confirmsPlaintextV2Mode } from "../subagent-surface"; +import type { SubagentSurfaceAdvisory, SubagentSurfaceMode } from "../subagent-surface"; import { type DashboardSection, type HealthData, @@ -77,7 +78,7 @@ type CachedOverview = { providers: ProviderInfo[]; }; -type MaMode = "v1" | "default" | "v2"; +type MaMode = SubagentSurfaceMode; type CodexPreference = "codexAutoStart" | "codexDesktopAuthless" | "codexClientCompaction"; type DashboardSettingsState = { @@ -200,10 +201,12 @@ const [maBusy, setMaBusy] = useState(false); */ const [maAdvisoryState, setMaAdvisoryState] = useState<{ advisory: SubagentSurfaceAdvisory | null; apiBase: string } | null>(null); const [maAdvisoryAnsweredFor, setMaAdvisoryAnsweredFor] = useState(null); - const [pendingMaModeState, setPendingMaModeState] = useState<{ mode: "default" | "v2"; apiBase: string } | null>(null); + const [pendingMaModeState, setPendingMaModeState] = useState<{ mode: Exclude; apiBase: string } | null>(null); const maAdvisory = maAdvisoryState?.apiBase === apiBase ? maAdvisoryState.advisory : null; const maAdvisoryAnswered = maAdvisoryAnsweredFor === apiBase; const pendingMaMode = pendingMaModeState?.apiBase === apiBase ? pendingMaModeState.mode : null; + // Same endpoint tag: a capability read on one server must not offer the fourth option on another. + const [maPlaintextSupportState, setMaPlaintextSupportState] = useState<{ supports: boolean; apiBase: string } | null>(null); const [maHelpOpen, setMaHelpOpen] = useState(false); const [effortCapHelpOpen, setEffortCapHelpOpen] = useState(false); const [shadowCallHelpOpen, setShadowCallHelpOpen] = useState(false); @@ -417,9 +420,17 @@ const [maBusy, setMaBusy] = useState(false); if (maModePoll.data === undefined) return; setMaMode(maModePoll.data.maMode); setMaAdvisoryState({ advisory: maModePoll.data.advisory ?? null, apiBase }); + setMaPlaintextSupportState({ supports: maModePoll.data.supportsPlaintextV2, apiBase }); writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, maModePoll.data.maMode); }, [maModePoll.data, apiBase]); + // The runtime advertises the fourth mode with supportsPlaintextV2Mode, proven per + // apiBase by the live poll. A cached 'v2-plaintext' still renders as the stored + // selection, but it cannot prove this server accepts the write — the same apiBase + // may have been downgraded or replaced — so the option follows support only. + const maSupportsPlaintextV2 = + maPlaintextSupportState?.apiBase === apiBase && maPlaintextSupportState.supports === true; + // Derived — avoids setState-on-prop-change for the resolved flag. Cache / poll / optimistic // save (which writes the same cache key) all count as resolved for MA UI. const maModeResolved = maModePoll.data !== undefined || cachedMaMode !== null; @@ -664,19 +675,40 @@ const [maBusy, setMaBusy] = useState(false); } } - const writeMaMode = async (mode: "v1" | "default" | "v2", acknowledgeAdvisory = false) => { + const writeMaMode = async ( + mode: MaMode, + acknowledgeAdvisory = false, + acknowledgePlaintext = false, + ) => { setMaBusy(true); setMaError(null); try { - const payload: { multiAgentMode: "v1" | "default" | "v2"; multiAgentSurfaceAdvisoryAcknowledged?: true } = { multiAgentMode: mode }; + const payload: { + multiAgentMode: MaMode; + multiAgentSurfaceAdvisoryAcknowledged?: true; + plaintextAgentMessagesAcknowledged?: true; + } = { multiAgentMode: mode }; // One request, so the recommended answer cannot leave the notice raised on a mode it applied. if (acknowledgeAdvisory) payload.multiAgentSurfaceAdvisoryAcknowledged = true; + // 'v2-plaintext' is rejected without the dedicated consent; the checkbox in + // PlaintextV2ConsentModal is what proved it before this write ran. + if (acknowledgePlaintext) payload.plaintextAgentMessagesAcknowledged = true; const r = await fetch(`${apiBase}/api/v2`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (r.ok) { + if (mode === "v2-plaintext") { + // Claim the mode only when the receipt proves it landed: the catalog pin is + // V2 while the global override must stay OFF. A 200 without those fields + // means an older or replaced proxy answered — keep the stored selection. + let confirmed = false; + try { confirmed = confirmsPlaintextV2Mode(await r.json()); } catch { /* unreadable receipt */ } + if (!confirmed) { setMaError(t("models.saveFailed")); return; } + // A confirmed write proves this apiBase supports the mode until the next poll. + setMaPlaintextSupportState({ supports: true, apiBase }); + } setMaMode(mode); if (acknowledgeAdvisory) setMaAdvisoryAnsweredFor(apiBase); writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, mode); @@ -694,11 +726,11 @@ const [maBusy, setMaBusy] = useState(false); finally { setMaBusy(false); } }; - const switchMaMode = async (mode: "v1" | "default" | "v2") => { + const switchMaMode = async (mode: MaMode) => { if (maBusy || maMode === mode) return; // v1 applies immediately: confirming a move toward the safe default would be noise. base // and v2 both put ChatGPT-native parents on the surface whose task a routed child cannot - // read, so they wait for an answer. + // read, so they wait for an answer; 'v2-plaintext' waits for its dedicated consent modal. if (mode !== "v1") { setPendingMaModeState({ mode, apiBase }); return; } await writeMaMode("v1"); }; @@ -725,6 +757,9 @@ const [maBusy, setMaBusy] = useState(false); const keepMaMode = async () => { const pending = pendingMaMode; setPendingMaModeState(null); + // 'v2-plaintext' writes with its dedicated acknowledgement in a single PUT — the mode + // itself records the consent, and the runtime rejects the write without the field. + if (pending === "v2-plaintext") { await writeMaMode(pending, false, true); return; } // A selection answers the advisory too. Without that, continuing to base or v2 leaves the // notice raised and the next poll asks the same question the operator just answered. // Unconditionally, not gated on the poll's current `required`: that projection goes false @@ -967,6 +1002,7 @@ const [maBusy, setMaBusy] = useState(false); sidecarSaving, shadowCallSaving, shadowCallError, modelsLoading, settingsSaving, syncing, maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen, maError, + maSupportsPlaintextV2, maAdvisory, maAdvisoryOpen, pendingMaMode, keepMaMode, chooseMaV1, dismissMaSurfaceDialog, effortCapHelpOpen, setEffortCapHelpOpen, shadowCallHelpOpen, setShadowCallHelpOpen, injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving, diff --git a/gui/src/pages/use-subagent-delegation.ts b/gui/src/pages/use-subagent-delegation.ts index cea33a8508d..6dc81f338fb 100644 --- a/gui/src/pages/use-subagent-delegation.ts +++ b/gui/src/pages/use-subagent-delegation.ts @@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from "react"; import { requireJson } from "./dashboard-shared"; import { normalizeInjectionSelection } from "./dashboard-core-poll"; +import type { SubagentSurfaceMode } from "../subagent-surface"; export type DelegationModelOption = { provider: string; model: string; namespaced: string }; @@ -32,16 +33,29 @@ export type UltraModeState = { 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"; + /** The raw multi-agent mode; Subagents renders the surface switch from it. */ + multiAgentMode: SubagentSurfaceMode; + /** True only on runtimes new enough to accept 'v2-plaintext'. */ + supportsPlaintextV2Mode?: boolean; + /** + * The plaintext-delivery SETTING is on (the mode, or the legacy experimental flag). + * Not a claim that any particular message left in plaintext — an unrewritable + * request still goes out with its original encryption. + */ + plaintextAgentMessagesEnabled?: boolean; }; export type UltraModePatch = { multiAgentModeHintText?: string | null; - /** The v1/base/v2 switch. Models owns the catalog-side copy; this is the delegation-side one. */ - multiAgentMode?: "v1" | "default" | "v2"; + /** The surface switch. Models owns the catalog-side copy; this is the delegation-side one. */ + multiAgentMode?: SubagentSurfaceMode; /** Answers the one-time surface advisory in the same request as a mode change. */ multiAgentSurfaceAdvisoryAcknowledged?: true; + /** + * Required by the server when entering 'v2-plaintext'; nothing is persisted for it — + * the stored mode is itself the operator's opt-in record. + */ + plaintextAgentMessagesAcknowledged?: true; }; type DelegationResponse = { diff --git a/gui/src/subagent-surface.ts b/gui/src/subagent-surface.ts index b6b405c693f..563bbb607f1 100644 --- a/gui/src/subagent-surface.ts +++ b/gui/src/subagent-surface.ts @@ -7,7 +7,7 @@ */ export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent-v1-default/"; -export type SubagentSurfaceMode = "v1" | "default" | "v2"; +export type SubagentSurfaceMode = "v1" | "default" | "v2" | "v2-plaintext"; export type SubagentSurfaceAdvisory = { required: boolean; @@ -22,7 +22,7 @@ export function readSubagentSurfaceAdvisory(value: unknown): SubagentSurfaceAdvi if (!value || typeof value !== "object") return null; const raw = value as Record; const mode = raw.mode; - if (mode !== "v1" && mode !== "default" && mode !== "v2") return null; + if (mode !== "v1" && mode !== "default" && mode !== "v2" && mode !== "v2-plaintext") return null; if (typeof raw.required !== "boolean") return null; return { required: raw.required, @@ -35,5 +35,25 @@ export function readSubagentSurfaceAdvisory(value: unknown): SubagentSurfaceAdvi /** What the operator sees for a mode. The stored `"default"` is called base in the UI. */ export function subagentSurfaceLabel(mode: SubagentSurfaceMode): string { + if (mode === "v2-plaintext") return "v2 \u00b7 plaintext"; return mode === "default" ? "base" : mode; } + +/** + * The segmented mode options, in display order. `"v2-plaintext"` exists only on runtimes + * that advertise `supportsPlaintextV2Mode`; an older server must not be offered a value + * it would reject, and a hidden option cannot be clicked into a bad request. + */ +export function subagentSurfaceModes(supportsPlaintextV2: boolean): readonly SubagentSurfaceMode[] { + return supportsPlaintextV2 + ? ["v1", "default", "v2", "v2-plaintext"] + : ["v1", "default", "v2"]; +} + +/** A successful HTTP status alone does not confirm the requested plaintext mode. */ +export function confirmsPlaintextV2Mode(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const state = value as Record; + return state.ok === true && state.multiAgentMode === "v2-plaintext" && state.supportsPlaintextV2Mode === true + && state.plaintextAgentMessagesEnabled === true && state.enabled === false; +} diff --git a/gui/tests/dashboard-plaintext-v2.test.tsx b/gui/tests/dashboard-plaintext-v2.test.tsx new file mode 100644 index 00000000000..63abaf96e45 --- /dev/null +++ b/gui/tests/dashboard-plaintext-v2.test.tsx @@ -0,0 +1,280 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { fetchDashboardMaMode } from "../src/pages/dashboard-core-poll"; +import { useDashboardData } from "../src/pages/use-dashboard-data"; +import { subagentSurfaceModes } from "../src/subagent-surface"; +import { DashboardMultiAgentControl } from "../src/pages/dashboard-overview-head"; + +const signal = () => new AbortController().signal; + +test("the redesigned dashboard switch offers plaintext only with capability and preserves saving state", async () => { + const { win, restore } = setupDashboardDom(globalThis.fetch); + const { createRoot } = await import("react-dom/client"); + const host = win.document.createElement("div"); + win.document.body.append(host); + const root = createRoot(host); + const selections: string[] = []; + const render = async (supported: boolean, busy = false, mode: "default" | "v2-plaintext" = "default") => { + await act(async () => root.render( + { selections.push(mode); }} /> + )); + }; + try { + await render(false); + expect(host.querySelectorAll('[role="radio"]').length).toBe(3); + await render(false, false, "v2-plaintext"); + const cached = host.querySelectorAll('[role="radio"]'); + expect(cached.length).toBe(4); + expect(cached[3]!.getAttribute("aria-checked")).toBe("true"); + await act(async () => { (cached[3] as unknown as HTMLButtonElement).click(); }); + expect(selections).toEqual([]); + await render(true); + const radios = host.querySelectorAll('[role="radio"]'); + expect(radios.length).toBe(4); + await act(async () => { (radios[3] as unknown as HTMLButtonElement).click(); }); + expect(selections).toEqual(["v2-plaintext"]); + await render(true, true); + expect(host.querySelector("fieldset")?.disabled).toBe(true); + await render(false); + expect(host.querySelectorAll('[role="radio"]').length).toBe(3); + } finally { + await act(async () => root.unmount()); + restore(); + } +}); + +test("the ma-mode poll retains 'v2-plaintext' and the capability field", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => Response.json({ + multiAgentMode: "v2-plaintext", + supportsPlaintextV2Mode: true, + plaintextAgentMessagesEnabled: true, + })) as typeof fetch; + expect(await fetchDashboardMaMode("", signal())).toEqual({ + maMode: "v2-plaintext", + advisory: null, + supportsPlaintextV2: true, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an older runtime narrows to the three legacy options", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => Response.json({ multiAgentMode: "v2" })) as typeof fetch; + const poll = await fetchDashboardMaMode("", signal()); + expect(poll).toMatchObject({ maMode: "v2", supportsPlaintextV2: false }); + expect(subagentSurfaceModes(poll.supportsPlaintextV2)).toEqual(["v1", "default", "v2"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a stored 'v2-plaintext' keeps the fourth option offered on a supporting runtime", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => Response.json({ + multiAgentMode: "v2-plaintext", + supportsPlaintextV2Mode: true, + })) as typeof fetch; + const poll = await fetchDashboardMaMode("", signal()); + expect(subagentSurfaceModes(poll.supportsPlaintextV2)).toContain("v2-plaintext"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +type Json = Record; + +const DASH_GLOBAL_KEYS = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + +/** Happy-dom dashboard harness; same shape as dashboard-resource-deadline.test.tsx. */ +function setupDashboardDom(fetchImpl: typeof fetch) { + const previous = new Map(DASH_GLOBAL_KEYS.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: "http://localhost/" }); + for (const [key, value] of Object.entries({ + window: win, document: win.document, navigator: win.navigator, + sessionStorage: win.sessionStorage, localStorage: win.localStorage, + fetch: fetchImpl, IS_REACT_ACT_ENVIRONMENT: true, + })) { + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + clearClientResourceStoresForTests(); + const restore = () => { + clearClientResourceStoresForTests(); + win.close(); + for (const key of DASH_GLOBAL_KEYS) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }; + return { win, restore }; +} + +async function waitFor(predicate: () => boolean) { + const until = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= until) throw new Error("dashboard fixture did not settle"); + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } +} + +async function mountDashboard(win: Window, apiBase: string) { + let data: ReturnType | undefined; + function Probe() { + data = useDashboardData(apiBase); + return null; + } + const { createRoot } = await import("react-dom/client"); + const host = win.document.createElement("div"); + win.document.body.append(host); + const root = createRoot(host); + await act(async () => { root.render(); }); + const unmount = async () => { + await act(async () => { root.unmount(); }); + // Let the store eviction macrotask run so a remount refetches instead of + // reading a retained snapshot. + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + }; + return { data: () => data!, unmount }; +} + +/** Fetch stub: v2 GET from a fixture fn, PUT bodies recorded and answered by onPut. */ +function dashboardFetch(opts: { v2Get: () => Promise; onPut?: (body: Json) => Response }) { + const puts: Json[] = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Json; + puts.push(body); + return opts.onPut ? opts.onPut(body) : Response.json({ ok: true }); + } + if (path.endsWith("/api/v2")) return opts.v2Get(); + if (path.endsWith("/api/system/health")) return Response.json({ status: "ok", version: "fixture", uptime: 1 }); + if (path.endsWith("/api/providers") || path.endsWith("/api/models")) return Response.json([]); + return new Response(null, { status: 404 }); + }) as typeof fetch; + return { fetchImpl, puts }; +} + +const SUPPORTING_V2 = { multiAgentMode: "default", supportsPlaintextV2Mode: true }; +const VALID_RECEIPT = { + ok: true, + multiAgentMode: "v2-plaintext", + supportsPlaintextV2Mode: true, + plaintextAgentMessagesEnabled: true, + enabled: false, +}; + +test("selecting 'v2-plaintext' stages the consent and dismissing writes nothing", async () => { + const { fetchImpl, puts } = dashboardFetch({ v2Get: async () => Response.json(SUPPORTING_V2) }); + const { win, restore } = setupDashboardDom(fetchImpl); + try { + const dash = await mountDashboard(win, "/d"); + await waitFor(() => dash.data().maMode === "default"); + await act(async () => { await dash.data().switchMaMode("v2-plaintext"); }); + expect(dash.data().pendingMaMode).toBe("v2-plaintext"); + expect(dash.data().maMode).toBe("default"); + expect(puts).toEqual([]); + await act(async () => { dash.data().dismissMaSurfaceDialog(); }); + expect(dash.data().pendingMaMode).toBeNull(); + expect(puts).toEqual([]); + expect(dash.data().maMode).toBe("default"); + await dash.unmount(); + } finally { + restore(); + } +}); + +test("confirming 'v2-plaintext' sends one PUT with the acknowledgement and applies a valid receipt", async () => { + const { fetchImpl, puts } = dashboardFetch({ + v2Get: async () => Response.json(SUPPORTING_V2), + onPut: () => Response.json(VALID_RECEIPT), + }); + const { win, restore } = setupDashboardDom(fetchImpl); + try { + const dash = await mountDashboard(win, "/d"); + await waitFor(() => dash.data().maMode === "default"); + await act(async () => { await dash.data().switchMaMode("v2-plaintext"); }); + await act(async () => { await dash.data().keepMaMode(); }); + expect(puts).toEqual([{ multiAgentMode: "v2-plaintext", plaintextAgentMessagesAcknowledged: true }]); + expect(dash.data().maMode).toBe("v2-plaintext"); + expect(dash.data().maError).toBeNull(); + // The confirmed receipt proves this apiBase supports the mode until the next poll. + expect(dash.data().maSupportsPlaintextV2).toBe(true); + expect(win.sessionStorage.getItem("ocx.dash.maMode.v1:/d")).toBe('"v2-plaintext"'); + await dash.unmount(); + } finally { + restore(); + } +}); + +test("a 200 receipt that does not confirm the mode keeps the old selection and surfaces an error", async () => { + const receipts = [ + // Server applied a different mode than requested. + { ok: true, multiAgentMode: "v2", supportsPlaintextV2Mode: true, plaintextAgentMessagesEnabled: true, enabled: false }, + // The four state fields without ok:true — matches the CLI receipt invariant. + { multiAgentMode: "v2-plaintext", supportsPlaintextV2Mode: true, plaintextAgentMessagesEnabled: true, enabled: false }, + ]; + let receiptIndex = 0; + const { fetchImpl, puts } = dashboardFetch({ + v2Get: async () => Response.json(SUPPORTING_V2), + onPut: () => Response.json(receipts[Math.min(receiptIndex++, receipts.length - 1)]), + }); + const { win, restore } = setupDashboardDom(fetchImpl); + try { + const dash = await mountDashboard(win, "/d"); + await waitFor(() => dash.data().maMode === "default"); + for (let attempt = 0; attempt < receipts.length; attempt++) { + await act(async () => { await dash.data().switchMaMode("v2-plaintext"); }); + await act(async () => { await dash.data().keepMaMode(); }); + expect(dash.data().maMode).toBe("default"); + expect(dash.data().maError).toBeTruthy(); + // An unconfirmed write must not seed the cache or reopen the fourth option by itself. + expect(win.sessionStorage.getItem("ocx.dash.maMode.v1:/d")).toBe('"default"'); + } + expect(puts).toHaveLength(2); + await dash.unmount(); + } finally { + restore(); + } +}); + +test("a cached 'v2-plaintext' cannot offer the write on a runtime that stopped supporting it", async () => { + let v2Fixture: Json = { multiAgentMode: "v2-plaintext", supportsPlaintextV2Mode: true, plaintextAgentMessagesEnabled: true }; + const { fetchImpl } = dashboardFetch({ v2Get: async () => Response.json(v2Fixture) }); + const { win, restore } = setupDashboardDom(fetchImpl); + try { + const first = await mountDashboard(win, "/d"); + await waitFor(() => first.data().maMode === "v2-plaintext" && first.data().maSupportsPlaintextV2); + await first.unmount(); + // The session cache now holds 'v2-plaintext'; the endpoint answers like an older + // runtime (no supportsPlaintextV2Mode). Hold its GET until the cached-only phase + // is asserted so the poll cannot land early. + let releaseV2: (() => void) | undefined; + const held = new Promise(resolve => { releaseV2 = resolve; }); + v2Fixture = { multiAgentMode: "v2" }; + const gated = dashboardFetch({ v2Get: () => held.then(() => Response.json(v2Fixture)) }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: gated.fetchImpl }); + clearClientResourceStoresForTests(); + const second = await mountDashboard(win, "/d"); + // Cached mode still renders, but it is not a support proof for this apiBase. + expect(second.data().maMode).toBe("v2-plaintext"); + expect(second.data().maSupportsPlaintextV2).toBe(false); + expect(subagentSurfaceModes(second.data().maSupportsPlaintextV2)).not.toContain("v2-plaintext"); + await act(async () => { releaseV2!(); }); + await waitFor(() => second.data().maMode === "v2"); + expect(second.data().maSupportsPlaintextV2).toBe(false); + await second.unmount(); + } finally { + restore(); + } +}); diff --git a/gui/tests/models-keep-native-v1-placement.test.ts b/gui/tests/models-keep-native-v1-placement.test.ts index 7f9cfa60bb4..e5bdb1bb564 100644 --- a/gui/tests/models-keep-native-v1-placement.test.ts +++ b/gui/tests/models-keep-native-v1-placement.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { normalizeV2Status, type V2Status } from "../src/pages/models-shared"; const modelsSource = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); const workspaceCss = await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text(); @@ -63,11 +64,27 @@ test("v2 setting writes adopt the response instead of a no-op refetch", () => { expect(helperAt).toBeGreaterThan(-1); const helper = modelsSource.slice(helperAt, modelsSource.indexOf("const setMultiAgentMode")); - expect(helper).toContain("setV2({"); - expect(helper).toContain("keepNativeChatGptOnV1: data.keepNativeChatGptOnV1 === true"); + // The PUT response reaches state through the same normalizer loadV2 uses; an inline + // copy here is exactly the kind of drift the shared helper exists to prevent. + expect(helper).toContain("setV2(normalizeV2Status(data))"); expect(helper).not.toContain("void loadV2()"); }); +/** + * The switch reads `v2.keepNativeChatGptOnV1` out of state, so the value the server + * persisted in the write response has to survive normalization into that state. + */ +test("response normalization persists keep-native-v1 exactly as the server returned it", () => { + const base = { enabled: false, agentsMaxThreadsConflict: false, multiAgentMode: "v2" as const }; + + expect(normalizeV2Status({ ...base, keepNativeChatGptOnV1: true }).keepNativeChatGptOnV1).toBe(true); + expect(normalizeV2Status({ ...base, keepNativeChatGptOnV1: false }).keepNativeChatGptOnV1).toBe(false); + // An older runtime omits the field; state must come out off, not stale-truthy. + expect(normalizeV2Status(base).keepNativeChatGptOnV1).toBe(false); + // A malformed value cannot mint the setting. + expect(normalizeV2Status({ ...base, keepNativeChatGptOnV1: "yes" } as unknown as V2Status).keepNativeChatGptOnV1).toBe(false); +}); + test("both v2 surface setters route through the shared writer", () => { const setters = modelsSource.slice( modelsSource.indexOf("const setMultiAgentMode"), diff --git a/gui/tests/plaintext-v2-consent.test.tsx b/gui/tests/plaintext-v2-consent.test.tsx new file mode 100644 index 00000000000..5af5d784615 --- /dev/null +++ b/gui/tests/plaintext-v2-consent.test.tsx @@ -0,0 +1,211 @@ +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 { LanguageProvider } from "../src/i18n/provider"; +import PlaintextV2ConsentModal from "../src/components/PlaintextV2ConsentModal"; +import { confirmsPlaintextV2Mode, subagentSurfaceModes } from "../src/subagent-surface"; +import { normalizeV2Status, type V2Status } from "../src/pages/models-shared"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + // happy-dom has no native modal dialog; the component only needs the call to be safe. + const dialogProto = (testWindow as unknown as { HTMLDialogElement?: { prototype: Record } }).HTMLDialogElement?.prototype; + if (dialogProto && typeof dialogProto.showModal !== "function") dialogProto.showModal = function showModal() { (this as { open?: boolean }).open = true; }; + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); + root = null; +}); + +afterEach(() => { + try { + if (root) act(() => root!.unmount()); + } finally { + try { + testWindow.close(); + } finally { + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + } + } +}); + +function render(node: React.ReactElement): void { + root = createRoot(container); + act(() => root!.render({node})); +} + +function modalParts() { + const dialog = container.querySelector("dialog[data-plaintext-v2-consent]"); + const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement | null; + const actions = Array.from(container.querySelectorAll(".modal-actions button")) as HTMLButtonElement[]; + const backdrop = container.querySelector(".modal-backdrop-dismiss") as HTMLButtonElement | null; + return { dialog, checkbox, cancel: actions[0] ?? null, confirm: actions[1] ?? null, backdrop }; +} + +function pressEscape(): void { + const { dialog } = modalParts(); + act(() => { + dialog!.dispatchEvent(new testWindow.Event("cancel", { bubbles: true, cancelable: true }) as unknown as Event); + }); +} + +test("the acknowledgement starts unchecked and only it enables confirm", () => { + const calls: string[] = []; + render( calls.push("confirm")} onDismiss={() => calls.push("dismiss")} />); + + const { dialog, checkbox, cancel, confirm, backdrop } = modalParts(); + expect(dialog).not.toBeNull(); + expect(checkbox?.checked).toBe(false); + expect(confirm?.disabled).toBe(true); + expect(cancel?.disabled).toBe(false); + expect(backdrop?.disabled).toBe(false); + // The dialog renders real copy, not a raw key id. + expect(dialog!.querySelector("h3")?.textContent?.trim().length).toBeGreaterThan(0); + + act(() => confirm!.click()); + expect(calls).toEqual([]); + + act(() => checkbox!.click()); + expect(checkbox?.checked).toBe(true); + expect(confirm?.disabled).toBe(false); +}); + +test("an acknowledged confirm calls onConfirm exactly once and nothing else", () => { + const calls: string[] = []; + render( calls.push("confirm")} onDismiss={() => calls.push("dismiss")} />); + const { checkbox, confirm } = modalParts(); + + act(() => checkbox!.click()); + act(() => confirm!.click()); + + expect(calls).toEqual(["confirm"]); +}); + +test("the cancel button and Escape each dismiss without confirming", () => { + const calls: string[] = []; + render( calls.push("confirm")} onDismiss={() => calls.push("dismiss")} />); + const { cancel } = modalParts(); + + act(() => cancel!.click()); + expect(calls).toEqual(["dismiss"]); + + pressEscape(); + expect(calls).toEqual(["dismiss", "dismiss"]); +}); + +test("a busy dialog cannot be checked, confirmed, or dismissed out from under the write", () => { + const calls: string[] = []; + render( calls.push("confirm")} onDismiss={() => calls.push("dismiss")} />); + const { checkbox, cancel, confirm, backdrop } = modalParts(); + + expect(checkbox?.disabled).toBe(true); + expect(cancel?.disabled).toBe(true); + expect(confirm?.disabled).toBe(true); + expect(backdrop?.disabled).toBe(true); + + act(() => checkbox!.click()); + act(() => confirm!.click()); + act(() => cancel!.click()); + act(() => backdrop!.click()); + pressEscape(); + + expect(calls).toEqual([]); + expect(checkbox?.checked).toBe(false); +}); + +const VALID_RECEIPT = { + ok: true, + enabled: false, + multiAgentMode: "v2-plaintext", + supportsPlaintextV2Mode: true, + plaintextAgentMessagesEnabled: true, +} as const; + +test("confirmsPlaintextV2Mode accepts a receipt that proves mode, capability, and flag", () => { + expect(confirmsPlaintextV2Mode(VALID_RECEIPT)).toBe(true); + // Extra response fields do not dilute the proof. + expect(confirmsPlaintextV2Mode({ ...VALID_RECEIPT, agentsMaxThreadsConflict: false, maxConcurrentThreadsPerSession: 8 })).toBe(true); +}); + +test("confirmsPlaintextV2Mode rejects non-objects and every weakened field", () => { + for (const bad of [undefined, null, "v2-plaintext", 200, true, []]) { + expect(confirmsPlaintextV2Mode(bad)).toBe(false); + } + + for (const field of Object.keys(VALID_RECEIPT)) { + const missing = Object.fromEntries(Object.entries(VALID_RECEIPT).filter(([key]) => key !== field)); + expect(confirmsPlaintextV2Mode(missing)).toBe(false); + } + + const weakened: ReadonlyArray = [ + ["ok", false], + ["ok", "true"], + ["enabled", true], + ["enabled", "false"], + ["multiAgentMode", "v2"], + ["multiAgentMode", "default"], + ["multiAgentMode", true], + ["supportsPlaintextV2Mode", false], + ["supportsPlaintextV2Mode", "true"], + ["plaintextAgentMessagesEnabled", false], + ["plaintextAgentMessagesEnabled", 1], + ]; + for (const [field, value] of weakened) { + expect(confirmsPlaintextV2Mode({ ...VALID_RECEIPT, [field]: value })).toBe(false); + } +}); + +test("normalizeV2Status preserves the plaintext mode and its capability fields", () => { + const normalized = normalizeV2Status({ + enabled: false, + agentsMaxThreadsConflict: false, + multiAgentMode: "v2-plaintext", + supportsPlaintextV2Mode: true, + plaintextAgentMessagesEnabled: true, + }); + + expect(normalized.multiAgentMode).toBe("v2-plaintext"); + expect(normalized.supportsPlaintextV2Mode).toBe(true); + expect(normalized.plaintextAgentMessagesEnabled).toBe(true); +}); + +test("normalizeV2Status cannot mint the capability from an older or malformed response", () => { + const legacy = normalizeV2Status({ enabled: false, agentsMaxThreadsConflict: false, multiAgentMode: "v2" }); + expect(legacy.supportsPlaintextV2Mode).toBe(false); + expect(legacy.plaintextAgentMessagesEnabled).toBe(false); + + const malformed = normalizeV2Status({ + enabled: false, + agentsMaxThreadsConflict: false, + multiAgentMode: "v2-plaintext-x", + supportsPlaintextV2Mode: "yes", + plaintextAgentMessagesEnabled: 1, + } as unknown as V2Status); + expect(malformed.multiAgentMode).toBe("default"); + expect(malformed.supportsPlaintextV2Mode).toBe(false); + expect(malformed.plaintextAgentMessagesEnabled).toBe(false); +}); + +test("the fourth mode option exists only where the server advertises it", () => { + expect(subagentSurfaceModes(false)).toEqual(["v1", "default", "v2"]); + expect(subagentSurfaceModes(true)).toEqual(["v1", "default", "v2", "v2-plaintext"]); +}); diff --git a/gui/tests/subagents-ultra-mode.test.tsx b/gui/tests/subagents-ultra-mode.test.tsx index 22cfc459be2..5448ea684c4 100644 --- a/gui/tests/subagents-ultra-mode.test.tsx +++ b/gui/tests/subagents-ultra-mode.test.tsx @@ -102,6 +102,30 @@ test("does not enable Ultra mode for the default surface even when V2 is enabled expect(ultraSwitch().getAttribute("aria-pressed")).toBe("false"); }); +test("an unconfirmed plaintext receipt refreshes applied state while keeping the save error", async () => { + const dialogProto = (testWindow as unknown as { HTMLDialogElement?: { prototype: Record } }).HTMLDialogElement?.prototype; + if (dialogProto && typeof dialogProto.showModal !== "function") dialogProto.showModal = function () { (this as { open?: boolean }).open = true; }; + v2Responses = [ + { ok: true, body: { enabled: false, multiAgentMode: "default", supportsPlaintextV2Mode: true } }, + { ok: true, body: { enabled: true, multiAgentMode: "v2-plaintext", supportsPlaintextV2Mode: true, plaintextAgentMessagesEnabled: true } }, + ]; + await mount(); + container.querySelector(".swi-advanced")?.setAttribute("open", ""); + const plaintext = Array.from(container.querySelectorAll('[role="radio"]')) + .find(button => button.textContent?.includes("plaintext")); + expect(plaintext).toBeTruthy(); + await act(async () => { plaintext!.click(); }); + const dialog = container.querySelector("dialog[data-plaintext-v2-consent]"); + expect(dialog).toBeTruthy(); + await act(async () => { dialog!.querySelector('input[type="checkbox"]')!.click(); }); + await act(async () => { dialog!.querySelector(".btn-primary")!.click(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(v2Call).toBe(2); + expect(plaintext!.getAttribute("aria-checked")).toBe("true"); + expect(container.textContent).toContain("Failed to save proactive delegation settings"); + expect(requests.filter(item => item.init?.method === "PUT")).toHaveLength(1); +}); + test("clears the page load error after a successful Ultra mode retry", async () => { v2Responses = [ { ok: false, body: { error: "temporary failure" }, status: 503 }, diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 0d73d3041ea..1d13a407dd7 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -39,6 +39,7 @@ "codex-cli-update-worker.test.ts": "codex-integration", "native-source-priority.test.ts": "codex-integration", "server-native-catalog-priority.test.ts": "server", + "codex-model-list-plaintext.test.ts": "server", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "codex-catalog-readonly-windows.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", diff --git a/scripts/test-layout/layout-responses.json b/scripts/test-layout/layout-responses.json index f6057862d3b..4fc93b1e2bb 100644 --- a/scripts/test-layout/layout-responses.json +++ b/scripts/test-layout/layout-responses.json @@ -11,6 +11,9 @@ "model-custom-tool-capability.test.ts": "responses", "passthrough-media-type.test.ts": "responses", "passthrough-override.test.ts": "responses", + "response-replay-ownership.test.ts": "server", + "response-replay-combo-identity.test.ts": "server", + "responses-replay-owner-storage.test.ts": "responses", "passthrough-headers.test.ts": "responses", "passthrough-abort.test.ts": "responses", "codex-eager-tool-catalog.test.ts": "responses", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8d3dcbf23e8..ff3f8f9af0a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -37,7 +37,7 @@ "cli-integration-preview.test.ts": "cli", "cli-integration-droid.test.ts": "cli", "cli-integration-journal.test.ts": "cli", "cli-aside-sync.test.ts": "cli", "cli-claude-desktop-profile.test.ts": "cli", "cli-integration-cursor.test.ts": "cli", "cli-remote-workspace-hub.test.ts": "cli", "cli-storage-policy-fields.test.ts": "cli", "cli-link-force.test.ts": "cli", "cli-link-cleanup.test.ts": "cli", "cli-account-policy.test.ts": "cli", "cli-account-login-options.test.ts": "cli", "cli-logout-runtime.test.ts": "cli", "cli-agent-settings.test.ts": "cli", - "cli-v2-runtime.test.ts": "cli", "cli-agent-runtime-settings.test.ts": "cli", "cli-system-settings-parity.test.ts": "cli", + "cli-v2-runtime.test.ts": "cli", "cli-v2-plaintext.test.ts": "cli", "multi-agent-v2-plaintext.test.ts": "codex-integration", "cli-agent-runtime-settings.test.ts": "cli", "cli-system-settings-parity.test.ts": "cli", "cli-catalog-command-result.test.ts": "cli", "cli-models-local-result.test.ts": "cli", "cli-models-custom-runtime.test.ts": "cli", "cli-models-order.test.ts": "cli", "cli-models-display-name.test.ts": "cli", "cli-route-policy-write.test.ts": "cli", "cli-combo-parity.test.ts": "cli", "cli-combo-stats.test.ts": "cli", "cli-json-input.test.ts": "cli", "cli-provider-lifecycle-runtime.test.ts": "cli", "cli-provider-settings.test.ts": "cli", "cli-provider-batch.test.ts": "cli", diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md index 873ca9a7be6..1a489005037 100644 --- a/skills/ocx/references/01_surface_agents-routing.md +++ b/skills/ocx/references/01_surface_agents-routing.md @@ -863,7 +863,7 @@ State-changing: yes. JSON mode: `envelope`. ### `ocx v2 mode` -Usage: `ocx v2 mode [--live] [--json] [--acknowledge-surface-advisory]` +Usage: `ocx v2 mode [--live] [--json] [--acknowledge-surface-advisory] [--acknowledge-plaintext-agent-messages]` Set local model-catalog multi-agent mode. @@ -878,6 +878,7 @@ State-changing: yes. JSON mode: `envelope`. | `--live` | boolean | Use the selected runtime; otherwise preserve the local native/config operation. | | `--json` | boolean | Emit the validated task result as one JSON document. | | `--acknowledge-surface-advisory` | boolean | Explicit live mode only; never implicitly acknowledged. | +| `--acknowledge-plaintext-agent-messages` | boolean | Required when entering v2-plaintext; task text may remain in history, provider requests and debug state. | - Local and live targets never fall back into each other. Parse failures occur before native effects; JSON is one safe state/change/sync result, not collected log text. - Local mode/keep-native and changed on/off preserve their existing sync calls even without a discovered port; threads/hints and unchanged toggles do not gain sync. Unknown or failed sync evidence is nonzero with landed state visible. diff --git a/src/cli/capabilities-agents-routing.ts b/src/cli/capabilities-agents-routing.ts index 28e2553f7a8..5061b013eae 100644 --- a/src/cli/capabilities-agents-routing.ts +++ b/src/cli/capabilities-agents-routing.ts @@ -342,10 +342,10 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [ }, { command: ["v2","mode"], - usage: "ocx v2 mode [--live] [--json] [--acknowledge-surface-advisory]", + usage: "ocx v2 mode [--live] [--json] [--acknowledge-surface-advisory] [--acknowledge-plaintext-agent-messages]", summary: "Set local model-catalog multi-agent mode.", routes: [{"method":"PUT","path":"/api/v2"}], - flags: [{"name":"--live","value":"boolean","summary":"Use the selected runtime; otherwise preserve the local native/config operation."},{"name":"--json","value":"boolean","summary":"Emit the validated task result as one JSON document."},{"name":"--acknowledge-surface-advisory","value":"boolean","summary":"Explicit live mode only; never implicitly acknowledged."}], + flags: [{"name":"--live","value":"boolean","summary":"Use the selected runtime; otherwise preserve the local native/config operation."},{"name":"--json","value":"boolean","summary":"Emit the validated task result as one JSON document."},{"name":"--acknowledge-surface-advisory","value":"boolean","summary":"Explicit live mode only; never implicitly acknowledged."},{"name":"--acknowledge-plaintext-agent-messages","value":"boolean","summary":"Required when entering v2-plaintext; task text may remain in history, provider requests and debug state."}], mutates: true, json: "envelope", details: ["Local and live targets never fall back into each other. Parse failures occur before native effects; JSON is one safe state/change/sync result, not collected log text.","Local mode/keep-native and changed on/off preserve their existing sync calls even without a discovered port; threads/hints and unchanged toggles do not gain sync. Unknown or failed sync evidence is nonzero with landed state visible.","Live writes use actual post-write state and full catalog outcomes; a502 can mean partial native changes. Read back before retrying; no rollback is implied."], diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 4100a7ac690..c455e04feab 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -18,7 +18,8 @@ const USAGE = `Usage: ocx config unset [--json] ocx config validate [path|-] [--json] ocx config export [--json] - ocx config import --yes [--json]`; + ocx config import --yes [--json] +Entering v2-plaintext requires: ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages`; /** * Keys whose VALUE is a credential and must never be printed by display commands. @@ -230,6 +231,12 @@ function validate(value: unknown): OcxConfig { return normalizeVisionConfig(result.config); } +function requireDedicatedPlaintextTransition(current: OcxConfig, candidate: OcxConfig): void { + if (candidate.multiAgentMode === "v2-plaintext" && current.multiAgentMode !== "v2-plaintext") { + throw new CliUsageError("Entering v2-plaintext requires ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages. Task text may remain in history, provider requests and debug state. Complete that transition before restoring a config with this mode."); + } +} + export async function handleConfigCommand(argv: string[]): Promise { let outcome = 0; const status = await runCliAction(async () => { @@ -293,6 +300,7 @@ export async function handleConfigCommand(argv: string[]): Promise { const candidate = structuredClone(fresh) as unknown as Record; setPath(candidate, path, raw === undefined ? undefined : parseValue(raw), action === "unset"); const config = validate(candidate); + requireDedicatedPlaintextTransition(fresh, config); savedValue = action === "unset" ? null : getPath(config, path); // Setting the order here is the operator restating it, exactly as through // `ocx account priority` or the management route, so it releases the manual pin @@ -357,6 +365,7 @@ export async function handleConfigCommand(argv: string[]): Promise { if (!yes) throw new CliUsageError("import requires --yes", USAGE); rejectArgs(args, USAGE); await withConfigDirHardeningSettled(getConfigDir(), () => { + // Reject invalid authority before lock preparation creates coordination files. const current = readCliConfigForMutation(); const rawInput = loadInput(path); if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput) && Object.hasOwn(rawInput, "telemetry")) { @@ -364,6 +373,9 @@ export async function handleConfigCommand(argv: string[]): Promise { console.warn("Note: ignored telemetry block during config import; use `ocx telemetry` to manage telemetry consent."); } const imported = validate(rawInput); + // Do not hold the shared lock while reading stdin. Recheck the live mode and + // the active client connection, then save in the same synchronous transaction; + // saveConfig reuses this transaction. withConfigMutationLockSync(() => { const client = readClientConnectionState(); if (client.kind === "connected" && (imported.runtimeRole !== "client" @@ -376,6 +388,7 @@ export async function handleConfigCommand(argv: string[]): Promise { } else { delete imported.telemetry; } + requireDedicatedPlaintextTransition(readCliConfigForMutation(), imported); saveConfig(imported); }); }); diff --git a/src/cli/v2-input.ts b/src/cli/v2-input.ts index 9b506916358..6c9637e6541 100644 --- a/src/cli/v2-input.ts +++ b/src/cli/v2-input.ts @@ -1,15 +1,15 @@ import { CliUsageError } from "./runtime-api"; -type V2Controls = { json: boolean; live: boolean; acknowledge: boolean }; +type V2Controls = { json: boolean; live: boolean; acknowledge: boolean; plaintextAcknowledged?: true }; export type V2ParsedCommand = V2Controls & ( | { verb: "status" | "on" | "off" } - | { verb: "mode"; value: "v1" | "default" | "v2" } + | { verb: "mode"; value: "v1" | "default" | "v2" | "v2-plaintext" } | { verb: "keep-native-v1"; value: boolean } | { verb: "threads"; value: number } | { verb: "mode-hint"; value: string | null } ); -export const V2_USAGE = "Usage: ocx v2 [status|on|off|mode |keep-native-v1 |threads |mode-hint ] [--live] [--json]\nFor literal reserved hint text: ocx v2 mode-hint [--live] [--json] -- \nOnly live mode accepts --acknowledge-surface-advisory."; +export const V2_USAGE = "Usage: ocx v2 [status|on|off|mode |keep-native-v1 |threads |mode-hint ] [--live] [--json]\nEntering v2-plaintext requires --acknowledge-plaintext-agent-messages: task text may remain in history, provider requests and local response/debug state. Catalog pins affect new sessions; enabled compilation also affects subsequent eligible canonical ChatGPT requests from existing V2 sessions. Existing ciphertext is not decrypted, stored history is not rewritten, and disabling does not delete retained copies.\nFor literal reserved hint text: ocx v2 mode-hint [--live] [--json] -- \nOnly live mode accepts --acknowledge-surface-advisory."; /** Parse all controls before touching native/config state. A terminator protects literal hints. */ export function parseV2Command(argv: readonly string[]): V2ParsedCommand { @@ -20,8 +20,9 @@ export function parseV2Command(argv: readonly string[]): V2ParsedCommand { const operands: string[] = []; for (const arg of prefix) { const flag = arg === "--json" ? "json" : arg === "--live" ? "live" - : arg === "--acknowledge-surface-advisory" ? "acknowledge" : undefined; - if (["--json=", "--live=", "--acknowledge-surface-advisory="].some(prefix => arg.startsWith(prefix))) { + : arg === "--acknowledge-surface-advisory" ? "acknowledge" + : arg === "--acknowledge-plaintext-agent-messages" ? "plaintextAcknowledged" : undefined; + if (["--json=", "--live=", "--acknowledge-surface-advisory=", "--acknowledge-plaintext-agent-messages="].some(prefix => arg.startsWith(prefix))) { throw new CliUsageError("v2: control flags do not take values; use -- for literal hint text", V2_USAGE); } if (flag) { @@ -34,6 +35,9 @@ export function parseV2Command(argv: readonly string[]): V2ParsedCommand { if (controls.acknowledge && (!controls.live || verb !== "mode")) { invalid("v2: advisory acknowledgment requires explicit live mode"); } + if (controls.plaintextAcknowledged && (verb !== "mode" || operands[0]?.trim().toLowerCase() !== "v2-plaintext")) { + invalid("v2: plaintext acknowledgment applies only to mode v2-plaintext"); + } if (boundary >= 0 && (verb !== "mode-hint" || operands.length !== 0 || suffix.length !== 1)) { invalid("v2: the terminator requires exactly one literal mode-hint operand"); } @@ -50,7 +54,7 @@ export function parseV2Command(argv: readonly string[]): V2ParsedCommand { return { ...controls, verb, value: boundary < 0 && raw === "--clear" ? null : raw }; } const value = raw.trim().toLowerCase(); - if (verb === "mode" && (value === "v1" || value === "default" || value === "v2")) { + if (verb === "mode" && (value === "v1" || value === "default" || value === "v2" || value === "v2-plaintext")) { return { ...controls, verb, value }; } if (verb === "keep-native-v1" && (value === "on" || value === "off")) { diff --git a/src/cli/v2-local-output.ts b/src/cli/v2-local-output.ts index d340abcdb7a..57e8ff96d7e 100644 --- a/src/cli/v2-local-output.ts +++ b/src/cli/v2-local-output.ts @@ -1,5 +1,5 @@ import { loadConfig } from "../config"; -import { multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../config/multi-agent-surface"; +import { isPlaintextV2AgentMessagesEnabled, multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../config/multi-agent-surface"; import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled } from "../codex/features"; import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../codex/multi-agent-mode-policy"; @@ -11,10 +11,11 @@ export function localV2State(isEnabled = isMultiAgentV2Enabled, hasMaxThreads = const config = loadConfig(); const enabled = isEnabled(); return { enabled, agentsMaxThreadsConflict: enabled && hasMaxThreads(), + supportsPlaintextV2Mode: true, plaintextAgentMessagesEnabled: isPlaintextV2AgentMessagesEnabled(config), multiAgentMode: resolveMultiAgentMode(config), keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, multiAgentSurfaceAdvisory: multiAgentSurfaceAdvisory(config), maxConcurrentThreadsPerSession: getLogicalMaxThreads(), agentsEnabled: getAgentsEnabled(), agentsMaxDepth: getAgentsMaxDepth(), - agentsMaxDepthAppliesWhenV2Disabled: !enabled, subagentDeveloperInstructions: getSubagentDeveloperInstructions(), + agentsMaxDepthAppliesWhenV2Disabled: !enabled && config.multiAgentMode !== "v2-plaintext", subagentDeveloperInstructions: getSubagentDeveloperInstructions(), multiAgentModeHintText: getMultiAgentModeHintText(), multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION }; } diff --git a/src/cli/v2-runtime.ts b/src/cli/v2-runtime.ts index e38a627ac7c..853ba92672d 100644 --- a/src/cli/v2-runtime.ts +++ b/src/cli/v2-runtime.ts @@ -7,8 +7,8 @@ import type { V2ParsedCommand } from "./v2-input"; function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function mode(value: unknown): value is "v1" | "default" | "v2" { - return value === "v1" || value === "default" || value === "v2"; +function mode(value: unknown): value is "v1" | "default" | "v2" | "v2-plaintext" { + return value === "v1" || value === "default" || value === "v2" || value === "v2-plaintext"; } function nullableString(value: unknown): value is string | null { return value === null || typeof value === "string"; } @@ -16,6 +16,11 @@ function nullableString(value: unknown): value is string | null { return value = function stateDto(value: unknown): Record { if (!record(value) || !mode(value.multiAgentMode)) throw new Error("Invalid v2 state"); const result: Record = { multiAgentMode: value.multiAgentMode }; + for (const key of ["supportsPlaintextV2Mode", "plaintextAgentMessagesEnabled"] as const) { + if (value[key] !== undefined && typeof value[key] !== "boolean") throw new Error("Invalid plaintext capability"); + if (value[key] !== undefined) result[key] = value[key]; + } + if (value.multiAgentMode === "v2-plaintext" && (value.supportsPlaintextV2Mode !== true || value.plaintextAgentMessagesEnabled !== true || value.enabled !== false)) throw new Error("Unverified plaintext V2 state"); for (const key of ["enabled", "agentsMaxThreadsConflict", "keepNativeChatGptOnV1", "agentsMaxDepthAppliesWhenV2Disabled"] as const) { if (typeof value[key] !== "boolean") throw new Error("Invalid v2 boolean"); result[key] = value[key]; @@ -46,6 +51,7 @@ function bodyFor(parsed: V2ParsedCommand): Record { switch (parsed.verb) { case "on": case "off": return { enabled: parsed.verb === "on" }; case "mode": return { multiAgentMode: parsed.value, + ...(parsed.plaintextAcknowledged ? { plaintextAgentMessagesAcknowledged: true } : {}), ...(parsed.acknowledge ? { multiAgentSurfaceAdvisoryAcknowledged: true } : {}) }; case "keep-native-v1": return { keepNativeChatGptOnV1: parsed.value }; case "threads": return { maxConcurrentThreadsPerSession: parsed.value }; @@ -70,11 +76,12 @@ export async function handleV2RuntimeCommand(parsed: V2ParsedCommand, deps: Runt throw error; } const state = stateDto(response); + if (parsed.verb === "mode" && parsed.value === "v2-plaintext" && state.multiAgentMode !== "v2-plaintext") throw new Error("Plaintext V2 mode was not confirmed by the selected runtime"); const lines = [`multi_agent_v2: ${state.enabled ? "ON" : "OFF"}`, `multi_agent_mode: ${state.multiAgentMode}`, `keep_native_chatgpt_on_v1: ${state.keepNativeChatGptOnV1 ? "ON" : "OFF"}`, `max_threads: ${state.maxConcurrentThreadsPerSession ?? "unset"}`, `agents.enabled: ${state.agentsEnabled ?? "unset (upstream default)"}`, - `agents.max_depth: ${state.agentsMaxDepth ?? "unset"}${state.enabled ? " (V1-only — ignored while V2 is enabled)" : ""}`, + `agents.max_depth: ${state.agentsMaxDepth ?? "unset"}${state.enabled || state.multiAgentMode === "v2-plaintext" ? " (V1-only — ignored by V2 sessions)" : ""}`, `subagent_developer_instructions: ${JSON.stringify(state.subagentDeveloperInstructions)}`, `multi_agent_mode_hint_text: ${JSON.stringify(state.multiAgentModeHintText)}`]; if (state.agentsMaxThreadsConflict) lines.push("Native thread-limit conflict: inspect the selected target's Codex configuration before starting Codex."); diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 734cccd3023..254d66415de 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -128,6 +128,7 @@ export function v2StatusLine(enabled: boolean): string { export function multiAgentModeLine(mode: string, keepNativeChatGptOnV1 = false): string { switch (mode) { case "v1": return "multi_agent_mode: v1 — ALL models forced to v1 surface (upstream pins overridden)"; + case "v2-plaintext": return "multi_agent_mode: v2-plaintext — new sessions select V2; subsequent eligible canonical ChatGPT requests, including existing V2 sessions, use experimental plaintext over HTTPS; global override stays OFF"; case "v2": return keepNativeChatGptOnV1 ? "multi_agent_mode: v2 hybrid — ChatGPT-native models use v1; routed models use v2" : "multi_agent_mode: v2 — ALL models forced to v2 surface (upstream pins overridden)"; @@ -136,7 +137,7 @@ export function multiAgentModeLine(mode: string, keepNativeChatGptOnV1 = false): } function requiresGlobalV2Disabled(multiAgentMode: string | undefined, keepNativeChatGptOnV1: boolean): boolean { - return multiAgentMode === "v2" && keepNativeChatGptOnV1; + return multiAgentMode === "v2-plaintext" || (multiAgentMode === "v2" && keepNativeChatGptOnV1); } function printLocalStatus(deps: V2CliDeps): number { @@ -148,20 +149,22 @@ function printLocalStatus(deps: V2CliDeps): number { const mode = cfg.multiAgentMode ?? "default"; const keepNativeV1 = cfg.keepNativeChatGptOnV1 === true; log.log(multiAgentModeLine(mode, keepNativeV1)); - log.log(cfg.keepNativeChatGptOnV1 === true + log.log(mode === "v2-plaintext" + ? "keep_native_chatgpt_on_v1: inactive in v2-plaintext mode; stored preference is preserved" + : cfg.keepNativeChatGptOnV1 === true ? requiresGlobalV2Disabled(mode, keepNativeV1) && isEnabled() ? "keep_native_chatgpt_on_v1: CONFLICT — global multi_agent_v2 overrides the native v1 catalog pin; run 'ocx v2 keep-native-v1 on' to reconcile" : "keep_native_chatgpt_on_v1: ON — global V2 override is off; ChatGPT-native rows use v1 and routed rows use v2 when mode is v2" : "keep_native_chatgpt_on_v1: OFF"); const threads = getLogicalMaxThreads(); log.log(`max_threads: ${threads ?? "(unset — codex default)"}`); - const v2Active = isEnabled(); + const v2Active = isEnabled() || mode === "v2-plaintext"; const agentsEnabled = getAgentsEnabled(); log.log(`agents.enabled: ${agentsEnabled === null ? "(unset — upstream default true)" : agentsEnabled}`); const maxDepth = getAgentsMaxDepth(); // max_depth is V1-only upstream; say so whenever V2 is active so the number // cannot be misread as an effective V2 limit. - log.log(`agents.max_depth: ${maxDepth ?? "(unset — upstream default 1)"}${v2Active ? " (V1-only — ignored while multi_agent_v2 is enabled)" : ""}`); + log.log(`agents.max_depth: ${maxDepth ?? "(unset — upstream default 1)"}${v2Active ? " (V1-only — ignored by V2 sessions)" : ""}`); const instructions = getSubagentDeveloperInstructions(); log.log(`subagent_developer_instructions: ${instructions === null ? "(unset — children inherit)" : instructions === "" ? '"" (clears inherited instructions)' : JSON.stringify(instructions)}`); const modeHint = getMultiAgentModeHintText(); @@ -226,6 +229,10 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () let successLines: string[]; let unchangedKeep: boolean | undefined; if (parsed.verb === "mode") { + const needsPlaintextAcknowledgment = () => parsed.value === "v2-plaintext" + && config.multiAgentMode !== "v2-plaintext" && !parsed.plaintextAcknowledged; + const refuseUnacknowledgedPlaintext = () => emit(false, ["Entering v2-plaintext requires --acknowledge-plaintext-agent-messages. Enabled compilation also affects subsequent eligible canonical ChatGPT requests from existing V2 sessions. Task text can remain in Codex history, provider requests and local response/debug state; TLS and authentication stay enabled. Existing ciphertext is not decrypted, and disabling does not delete retained copies."]); + if (needsPlaintextAcknowledgment()) return refuseUnacknowledgedPlaintext(); const persistMode = (): boolean => { const before = config.multiAgentMode ?? "default"; if (parsed.value === "default") deleteConfigTopLevelKey(config, "multiAgentMode"); @@ -235,20 +242,26 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () }; if (parsed.value !== "default") { let modeChanged = false; + let acknowledgmentRequired = false; const transition = await withCodexCliSelectionParentWriteAsync(() => { config = loadConfig(); + if (needsPlaintextAcknowledgment()) { + acknowledgmentRequired = true; + return { ok: true as const, changed: false }; + } const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true; const result = transitionMultiAgentV2(target, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); if (result.ok) modeChanged = persistMode(); return result; }); + if (acknowledgmentRequired) return refuseUnacknowledgedPlaintext(); if (!transition.ok) { changed = false; return emit(false, ["Native mode transition failed. Read ocx v2 status before retrying."]); } changed = modeChanged || transition.changed; } else { changed = persistMode(); } successLines = [multiAgentModeLine(parsed.value, config.keepNativeChatGptOnV1 === true), - "Applies to NEW sessions; running sessions keep their pinned multi-agent version."]; + "Catalog pins apply to NEW sessions; running sessions keep their pinned multi-agent version. Plaintext compilation follows the current setting for subsequent eligible canonical ChatGPT requests, including existing V2 sessions. Existing ciphertext is not decrypted, stored history is not rewritten, and disabling does not delete retained copies."]; } else if (parsed.verb === "keep-native-v1") { const persistKeep = (): void => { unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value; @@ -278,19 +291,21 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () } else { const want = parsed.verb === "on"; if (want && requiresGlobalV2Disabled(config.multiAgentMode, config.keepNativeChatGptOnV1 === true)) { + if (config.multiAgentMode === "v2-plaintext") return emit(false, ["v2 on: incompatible with v2-plaintext. Its catalog selects V2 for new sessions while the global override stays OFF. Select another mode explicitly first."]); return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]); } let incompatible = false; const transition = await withCodexCliSelectionParentWriteAsync(() => { // Revalidated once admitted: keep-native-v1 or mode v2 may have been set meanwhile. - const current = loadConfig(); - if (want && requiresGlobalV2Disabled(current.multiAgentMode, current.keepNativeChatGptOnV1 === true)) { + config = loadConfig(); + if (want && requiresGlobalV2Disabled(config.multiAgentMode, config.keepNativeChatGptOnV1 === true)) { incompatible = true; return { ok: true as const, changed: false }; } return transitionMultiAgentV2(want, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); }); if (incompatible) { + if (config.multiAgentMode === "v2-plaintext") return emit(false, ["v2 on: incompatible with v2-plaintext. Its catalog selects V2 for new sessions while the global override stays OFF. Select another mode explicitly first."]); return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]); } if (!transition.ok) return emit(false, ["Native feature transition failed. Read ocx v2 status before retrying."]); diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index dad7bd99c64..92ef9426d71 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -1,4 +1,6 @@ import type { VendorContextInference } from "./vendor-context"; +import type { MultiAgentMode } from "../../types/multi-agent"; +export type { MultiAgentMode } from "../../types/multi-agent"; import { retainedNativeInheritanceTemplate } from "./native-template"; export { findSupportedNativeTemplate } from "./native-template"; import { isCodexControlPlaneModel } from "../control-plane-models"; @@ -594,8 +596,6 @@ export function ensureStrictCatalogFields( return ensureAutoCompactTokenLimit(entry); } -export type MultiAgentMode = "v1" | "default" | "v2"; - export interface MultiAgentModeOptions { /** Caller-owned source metadata already defines the default for these projected rows. */ preserveDefaultMultiAgentVersion?: (entry: RawEntry) => boolean; @@ -665,6 +665,9 @@ export function applyRoutedCodexToolMode( * `keepNativeChatGptOnV1` only applies when `mode === "v2"`. It leaves Sol/Terra * (and other ChatGPT-native rows) on v1 so a native parent can still spawn a * routed child. See issue #92. + * + * `"v2-plaintext"` stamps every row v2, ChatGPT-native rows included -- the mode is + * the plaintext-delivery opt-in, so no native/routed split applies. */ export function applyMultiAgentMode( entries: RawEntry[], @@ -743,9 +746,10 @@ export function applyMultiAgentMode( } return entries; } + const forcedVersion = mode === "v2-plaintext" ? "v2" : mode; for (const entry of entries) { recordMultiAgentOrigin(entry); - entry.multi_agent_version = mode; + entry.multi_agent_version = forcedVersion; } return entries; } diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 527f94e1624..56813452366 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { initializeConfigOwnership } from "../../lib/config-ownership"; import { getConfigDir, loadConfig, ultraFastTierEnabled, websocketsEnabled, withConfigMutationLockSync } from "../../config"; +import { resolveMultiAgentMode } from "../../config/multi-agent-surface"; import { shouldSyncCodexOnStart } from "../desired-state"; import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration"; import { getCodexHome } from "../paths"; @@ -334,7 +335,7 @@ function writeRetainedCatalogSync({ catalogModelsForMerge, ); const modelPickerOrder = config.modelPickerOrder ?? []; - const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; + const multiAgentMode: MultiAgentMode = resolveMultiAgentMode(config); const exactComboSlugs = exactComboCatalogSlugs(config); const bareEligibleAccountIds = providerCodexAccountMode( OPENAI_CODEX_PROVIDER_ID, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 926ff835ecb..df0c59a0711 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { compareCatalogHealPaths } from "./catalog/heal-observation"; import { getConfigDir, saveConfigPreservingClaudeCode, ultraFastTierEnabled, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; +import { resolveMultiAgentMode } from "../config/multi-agent-surface"; import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy"; import { pendingModelSelectionProviders } from "../providers/initial-model-selection"; import { COMBO_NAMESPACE } from "../combos"; @@ -276,8 +277,7 @@ function prepareCatalog( const featured = config.subagentModels ?? []; const ordered = orderForSubagents(enabled, featured); const modelPickerOrder = config.modelPickerOrder ?? []; - const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" - ? config.multiAgentMode : "default"; + const multiAgentMode = resolveMultiAgentMode(config); const exactComboSlugs = exactComboCatalogSlugs(config); const bareEligibleAccountIds = providerCodexAccountMode( OPENAI_CODEX_PROVIDER_ID, diff --git a/src/codex/inject/multi-agent-v2.ts b/src/codex/inject/multi-agent-v2.ts index f3168972b2d..f70aafd923b 100644 --- a/src/codex/inject/multi-agent-v2.ts +++ b/src/codex/inject/multi-agent-v2.ts @@ -6,7 +6,7 @@ import type { CodexCliSelectionAsyncParentWrite } from "../cli-update-selection- /** * Reconcile the native `features.multi_agent_v2` override when an injection carries an - * explicit v1 surface pin. + * explicit pin-driven surface ("v1" or "v2-plaintext"). * * Codex resolves the global v2 feature before catalog-level `multi_agent_version` pins, so a * config.toml that still enables `multi_agent_v2` would run v2 sessions under a catalog the @@ -15,6 +15,11 @@ import type { CodexCliSelectionAsyncParentWrite } from "../cli-update-selection- * injection on a previously-v2 Codex home the common trigger. The explicit mode selectors * (`ocx v2 mode`, `PUT /api/v2`) already run the same format-preserving transition; this is * the injection-side half of that contract. + * + * "v2-plaintext" joins the reconcile for a different reason: its whole surface already + * runs v2, but through catalog pins rather than the global feature. Leaving the flag on + * would outrank those pins and change the native agent-enable and thread-limit semantics, + * so the flag is disabled the same way. */ export type InjectedV1SurfaceReconcile = | { ok: true; content: string; changed: boolean } @@ -67,7 +72,8 @@ export async function prepareInjectedV1SurfaceReconcile( config: Pick | undefined, options: { validateOnly?: boolean }, ): Promise { - if (options.validateOnly || config?.multiAgentMode !== "v1") { + const mode = config?.multiAgentMode; + if (options.validateOnly || (mode !== "v1" && mode !== "v2-plaintext")) { return null; } const { isMultiAgentV2Enabled, transitionMultiAgentV2 } = await import("../features"); @@ -115,7 +121,7 @@ export async function prepareInjectedV1SurfaceReconcile( if (transition.retryable === false) throw new ConfigWriteDestinationChanged(transition.error); return { ok: false, - message: `Codex config injection refused: could not reconcile the v1 surface with the global multi_agent_v2 feature: ${transition.error}.`, + message: `Codex config injection refused: could not reconcile the ${mode} surface with the global multi_agent_v2 feature: ${transition.error}.`, }; } return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: transition.changed }; diff --git a/src/config/multi-agent-surface.ts b/src/config/multi-agent-surface.ts index eefd7f1a077..d7f2b5b0829 100644 --- a/src/config/multi-agent-surface.ts +++ b/src/config/multi-agent-surface.ts @@ -18,7 +18,22 @@ export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent- * What changed in this release is the written default, not this resolution. */ export function resolveMultiAgentMode(config: Pick): MultiAgentMode { - return config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; + return config.multiAgentMode === "v1" || config.multiAgentMode === "v2" || config.multiAgentMode === "v2-plaintext" + ? config.multiAgentMode + : "default"; +} + +/** + * Whether v2 collaboration messages are compiled to plaintext on the canonical + * ChatGPT forward route. Two independent opt-ins activate it: the standalone + * experimental `plaintextV2AgentMessages` flag, and mode `"v2-plaintext"`, which is + * itself the operator's explicit consent. Mode transitions never rewrite the flag, + * and the flag alone never changes the resolved mode. + */ +export function isPlaintextV2AgentMessagesEnabled( + config: Pick, +): boolean { + return config.plaintextV2AgentMessages === true || resolveMultiAgentMode(config) === "v2-plaintext"; } type AdvisoryConfig = Pick; @@ -27,12 +42,20 @@ type AdvisoryConfig = Pick; const keys = Object.keys(payload); - if (keys.some(key => !["version", "responseId", "createdAt", "clientThreadId", "items", "providerOutputStart", "providers", "unforcedStoreFalse"].includes(key))) return false; + if (keys.some(key => !["version", "responseId", "createdAt", "clientThreadId", "replayOwnerKey", "items", "providerOutputStart", "providers", "unforcedStoreFalse"].includes(key))) return false; if (payload.version !== 1 || payload.responseId !== responseId) return false; if (typeof payload.createdAt !== "number" || !Number.isFinite(payload.createdAt)) return false; if (payload.clientThreadId !== undefined && (typeof payload.clientThreadId !== "string" || payload.clientThreadId.trim().length === 0)) return false; + if (payload.replayOwnerKey !== undefined && !validReplayOwnerKey(payload.replayOwnerKey)) return false; if (payload.unforcedStoreFalse !== undefined && typeof payload.unforcedStoreFalse !== "boolean") return false; if (!Array.isArray(payload.items)) return false; if (payload.unforcedStoreFalse === true && payload.providerOutputStart === undefined) return false; diff --git a/src/responses/state.ts b/src/responses/state.ts index 7faf782bda1..7df4605d30c 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -20,8 +20,9 @@ import { } from "./spill-store"; import { collectReferencedSpillFileNames, snapshotReferencedSpillFileNames } from "./state/spill-inspect"; import { selectSnapshotEntries, snapshotPayload } from "./state/snapshot-select"; -import { clientCarriedPrefixLength, providerIssuedIdentity } from "./state/replay-fingerprint"; -import { hasPendingClientToolCall, allowsUnforcedStoreFalseReplay } from "./state/unforced-store-false"; +import { expandStoredResponseInput } from "./state/replay-input"; +import { normalizedClientThreadId, replayScopeMatches, validReplayOwnerKey } from "./state/replay-owner"; +import { hasPendingClientToolCall } from "./state/unforced-store-false"; import { computeResponseStateMetrics, type ResponseStateMetrics } from "./state/metrics"; export type { ResponseStateMetrics } from "./state/metrics"; export type { ResponseStateTempRecoveryResult, ResponseStateTempRecoveryOptions } from "./state/temp-recovery"; @@ -117,6 +118,7 @@ export interface ResidentResponseState { kind: "resident"; createdAt: number; clientThreadId?: string; + replayOwnerKey?: string; items: unknown[]; /** Index in `items` where provider output begins; see clientCarriedPrefixLength. */ providerOutputStart?: number; @@ -129,6 +131,7 @@ export interface SpilledResponseState { kind: "spill"; createdAt: number; clientThreadId?: string; + replayOwnerKey?: string; /** Mirrors the spilled payload boundary so a spilled entry keeps its anchor. */ providerOutputStart?: number; providers?: OcxProviderContinuationState; @@ -317,6 +320,7 @@ function measureResidentEntry(id: string, entry: ResidentInput): ResidentRespons responseId: id, createdAt: entry.createdAt, ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}), + ...(entry.replayOwnerKey ? { replayOwnerKey: entry.replayOwnerKey } : {}), items: entry.items, ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}), ...(entry.providers ? { providers: entry.providers } : {}), @@ -423,6 +427,7 @@ function swapResidentForSpill(id: string, expected: ResidentResponseState, ref: kind: "spill", createdAt: expected.createdAt, ...(expected.clientThreadId ? { clientThreadId: expected.clientThreadId } : {}), + ...(expected.replayOwnerKey ? { replayOwnerKey: expected.replayOwnerKey } : {}), ...(expected.providerOutputStart !== undefined ? { providerOutputStart: expected.providerOutputStart } : {}), ...(expected.providers ? { providers: expected.providers } : {}), ...(expected.unforcedStoreFalse ? { unforcedStoreFalse: expected.unforcedStoreFalse } : {}), @@ -446,6 +451,7 @@ function replaceSpillEntryAtomically( const ref = writeResponseSpillDurably(id, { createdAt: candidate.createdAt, ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}), + ...(candidate.replayOwnerKey ? { replayOwnerKey: candidate.replayOwnerKey } : {}), items: candidate.items, ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), @@ -455,6 +461,7 @@ function replaceSpillEntryAtomically( kind: "spill", createdAt: candidate.createdAt, ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}), + ...(candidate.replayOwnerKey ? { replayOwnerKey: candidate.replayOwnerKey } : {}), ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), ...(candidate.unforcedStoreFalse ? { unforcedStoreFalse: candidate.unforcedStoreFalse } : {}), @@ -540,6 +547,7 @@ function admitOversizedCandidate( const ref = writeResponseSpillDurably(id, { createdAt: candidate.createdAt, ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}), + ...(candidate.replayOwnerKey ? { replayOwnerKey: candidate.replayOwnerKey } : {}), items: candidate.items, ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), @@ -558,6 +566,7 @@ function admitOversizedCandidate( kind: "spill", createdAt: candidate.createdAt, ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}), + ...(candidate.replayOwnerKey ? { replayOwnerKey: candidate.replayOwnerKey } : {}), ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), ...(candidate.unforcedStoreFalse ? { unforcedStoreFalse: candidate.unforcedStoreFalse } : {}), @@ -960,6 +969,7 @@ function pruneResponses(at = now()): void { const ref = writeResponseSpillDurably(oldestId, { createdAt: entry.createdAt, ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}), + ...(entry.replayOwnerKey ? { replayOwnerKey: entry.replayOwnerKey } : {}), items: entry.items, ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}), ...(entry.providers ? { providers: entry.providers } : {}), @@ -1020,6 +1030,7 @@ export function evictOldestResponseContinuationForBudget(): number { const ref = writeResponseSpillDurably(id, { createdAt: entry.createdAt, ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}), + ...(entry.replayOwnerKey ? { replayOwnerKey: entry.replayOwnerKey } : {}), items: entry.items, ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}), ...(entry.providers ? { providers: entry.providers } : {}), @@ -1060,6 +1071,9 @@ function materializeEntry( schedulePersist(); return { ok: false, failure }; } + if (entry.replayOwnerKey !== result.payload.replayOwnerKey || entry.clientThreadId !== result.payload.clientThreadId) { + return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_corrupt" } }; + } // A restricted snapshot stub must agree with the validated spill's provenance. if (entry.unforcedStoreFalse && (result.payload.unforcedStoreFalse !== true || entry.providerOutputStart === undefined || entry.providerOutputStart > result.payload.items.length @@ -1072,6 +1086,7 @@ function materializeEntry( const state = measureResidentEntry(id, { createdAt: result.payload.createdAt, ...(result.payload.clientThreadId ? { clientThreadId: result.payload.clientThreadId } : {}), + ...(result.payload.replayOwnerKey ? { replayOwnerKey: result.payload.replayOwnerKey } : {}), items: result.payload.items, ...(result.payload.providerOutputStart !== undefined ? { providerOutputStart: result.payload.providerOutputStart } @@ -1088,11 +1103,7 @@ function materializeEntry( return { ok: true, state }; } -function normalizedClientThreadId(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -export function expandPreviousResponseInput(body: unknown, clientThreadId?: string): unknown { +export function expandPreviousResponseInput(body: unknown, clientThreadId?: string, replayOwnerKey?: string | null): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const request = body as Record; const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined; @@ -1101,66 +1112,24 @@ export function expandPreviousResponseInput(body: unknown, clientThreadId?: stri pruneResponses(); const previous = states.get(previousId); if (!previous) return body; - const materialized = materializeEntry(previousId, previous); - if (!materialized.ok) { - replayFailures.set(request, materialized.failure); - return body; - } - const requestThreadId = normalizedClientThreadId(clientThreadId); - const storedThreadId = normalizedClientThreadId(materialized.state.clientThreadId); - // A Codex task must never inherit another task's continuation, nor a legacy unscoped entry. - // Unscoped callers retain backward-compatible replay only with other unscoped entries. - if (requestThreadId !== storedThreadId) { + // Authorize metadata BEFORE opening a spill or exposing provider continuation state. + if (previous.kind !== "spill-failed" && !replayScopeMatches(previous, clientThreadId, replayOwnerKey)) { replayFailures.set(request, { code: "previous_response_not_found", reason: "scope_mismatch" }); replayScopeMismatchDrops += 1; return body; } - const clientInput = inputItems(request.input); - const stored = materialized.state.items; - const carried = clientCarriedPrefixLength(stored, clientInput); - if ( - materialized.state.unforcedStoreFalse - && !allowsUnforcedStoreFalseReplay(stored, materialized.state.providerOutputStart, clientInput, carried) - ) { + const materialized = materializeEntry(previousId, previous); + if (!materialized.ok) { + replayFailures.set(request, materialized.failure); return body; } - // The client already replayed this history verbatim. Prepending the stored copy would - // double it, and the doubled turn is stored again, so the next turn triples (#1412 saw - // 127k of real context reach 1.3M tokens this way). - // - // Three conditions, all required. The run must cover the whole stored entry; it must reach - // the provider-output region; and some matched item in that region must carry a - // provider-issued id. The last one is the load-bearing part: content equality alone proves - // two items look alike, not that they are the same occurrence, so a client that merely - // repeats its own message would otherwise authorize a skip that deletes real history. - // There is no invariant that provider output always carries ids, so an entry whose output - // has none simply never skips. - { - const anchor = materialized.state.providerOutputStart; - if ( - carried === stored.length - && anchor !== undefined - && carried > anchor - && stored.slice(anchor, carried).some(item => providerIssuedIdentity(item) !== null) - ) { - replayOverlapSkips += 1; - // Keep previous_response_id: Kiro and Cursor recover their conversation ids from it - // (kiro-wire.ts, cursor/request-builder.ts). Only the concatenation is skipped. - const unchanged = { ...request }; - // Same provenance boundary a real expansion would record, so the replayed prefix does - // not re-acknowledge historical compaction markers (parser.ts) and stays visible to - // guidance de-duplication (collaboration.ts). - replayedInputPrefixLengths.set(unchanged, carried); - clientCarriedReplays.add(unchanged); - return unchanged; - } + const expanded = expandStoredResponseInput(request, inputItems(request.input), materialized.state); + if (expanded.overlap) { + replayOverlapSkips += 1; + clientCarriedReplays.add(expanded.body); } - const expanded = { - ...request, - input: [...stored, ...clientInput], - }; - replayedInputPrefixLengths.set(expanded, stored.length); - return expanded; + if (expanded.body !== request) replayedInputPrefixLengths.set(expanded.body, expanded.prefixLength); + return expanded.body; } export function previousResponseReplayFailure(body: unknown): PreviousResponseReplayFailure | undefined { @@ -1260,7 +1229,7 @@ export function rememberResponseState( requestBody: unknown, response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown }, providerState?: OcxProviderContinuationState | string, - opts?: { force?: boolean; clientThreadId?: string; retainForToolContinuation?: boolean }, + opts?: { force?: boolean; clientThreadId?: string; retainForToolContinuation?: boolean; replayOwnerKey?: string | null; compiledPlaintext?: boolean }, ): void { if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return; const request = requestBody as Record; @@ -1279,7 +1248,11 @@ export function rememberResponseState( if (!details || typeof details !== "object" || Array.isArray(details) || (details as { reason?: unknown }).reason !== "max_output_tokens") return; } else if (response.status !== undefined && response.status !== "completed") return; + if ((opts?.compiledPlaintext && !validReplayOwnerKey(opts.replayOwnerKey)) || opts?.replayOwnerKey === null || (opts?.replayOwnerKey !== undefined && !validReplayOwnerKey(opts.replayOwnerKey))) return; ensureLoaded(); + const existing = states.get(response.id); + // Never let a colliding upstream id replace a different caller's retained response. + if (existing && existing.kind !== "spill-failed" && !replayScopeMatches(existing, opts?.clientThreadId, opts?.replayOwnerKey)) return; const normalizedProviderState: OcxProviderContinuationState = typeof providerState === "string" ? { cursor: { conversationId: providerState } } : structuredClone(providerState ?? {}); @@ -1295,6 +1268,7 @@ export function rememberResponseState( setResidentEntry(response.id, { createdAt: now(), ...(clientThreadId ? { clientThreadId } : {}), + ...(opts?.replayOwnerKey ? { replayOwnerKey: opts.replayOwnerKey } : {}), items: [...requestItems, ...response.output], // Where response.output begins. A replay skip requires a matched item at or past this // index that also carries a provider-issued id — position alone proves only that an item diff --git a/src/responses/state/replay-input.ts b/src/responses/state/replay-input.ts new file mode 100644 index 00000000000..30fc0e63bc9 --- /dev/null +++ b/src/responses/state/replay-input.ts @@ -0,0 +1,50 @@ +import type { ResidentResponseState } from "../state"; +import { clientCarriedPrefixLength, providerIssuedIdentity } from "./replay-fingerprint"; +import { allowsUnforcedStoreFalseReplay } from "./unforced-store-false"; + +export function expandStoredResponseInput(request: Record, input: unknown[], state: ResidentResponseState): { + body: Record; prefixLength: number; overlap: boolean; +} { + const clientInput = input; + const stored = state.items; + const carried = clientCarriedPrefixLength(stored, clientInput); + if ( + state.unforcedStoreFalse + && !allowsUnforcedStoreFalseReplay(stored, state.providerOutputStart, clientInput, carried) + ) { + return { body: request, prefixLength: 0, overlap: false }; + } + // The client already replayed this history verbatim. Prepending the stored copy would + // double it, and the doubled turn is stored again, so the next turn triples (#1412 saw + // 127k of real context reach 1.3M tokens this way). + // + // Three conditions, all required. The run must cover the whole stored entry; it must reach + // the provider-output region; and some matched item in that region must carry a + // provider-issued id. The last one is the load-bearing part: content equality alone proves + // two items look alike, not that they are the same occurrence, so a client that merely + // repeats its own message would otherwise authorize a skip that deletes real history. + // There is no invariant that provider output always carries ids, so an entry whose output + // has none simply never skips. + { + const anchor = state.providerOutputStart; + if ( + carried === stored.length + && anchor !== undefined + && carried > anchor + && stored.slice(anchor, carried).some(item => providerIssuedIdentity(item) !== null) + ) { + // Keep previous_response_id: Kiro and Cursor recover their conversation ids from it + // (kiro-wire.ts, cursor/request-builder.ts). Only the concatenation is skipped. + const unchanged = { ...request }; + // Same provenance boundary a real expansion would record, so the replayed prefix does + // not re-acknowledge historical compaction markers (parser.ts) and stays visible to + // guidance de-duplication (collaboration.ts). + return { body: unchanged, prefixLength: carried, overlap: true }; + } + } + const expanded = { + ...request, + input: [...stored, ...clientInput], + }; + return { body: expanded, prefixLength: stored.length, overlap: false }; +} diff --git a/src/responses/state/replay-owner.ts b/src/responses/state/replay-owner.ts new file mode 100644 index 00000000000..73478dfcff7 --- /dev/null +++ b/src/responses/state/replay-owner.ts @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; + +/** A content-free binding, derived only from trusted admission and the request's OWN thread. */ +export function responseReplayOwnerKey(principal: string | undefined, ownThread: string | undefined): string | null { + if (!principal || !ownThread?.trim()) return null; + return createHash("sha256").update(JSON.stringify(["ocx-response-owner-v1", principal, ownThread.trim()])).digest("hex"); +} + +export function validReplayOwnerKey(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); +} + +export function normalizedClientThreadId(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** Undefined is the legacy internal API; null explicitly means a request without an owner. */ +export function replayScopeMatches( + entry: { clientThreadId?: string; replayOwnerKey?: string }, thread: string | undefined, key: string | null | undefined, +): boolean { + if (key === null || (key !== undefined && !validReplayOwnerKey(key))) return false; + return key === entry.replayOwnerKey + && normalizedClientThreadId(thread) === normalizedClientThreadId(entry.clientThreadId); +} diff --git a/src/responses/state/snapshot-codec.ts b/src/responses/state/snapshot-codec.ts index d4575e9ed14..901ea4a57c4 100644 --- a/src/responses/state/snapshot-codec.ts +++ b/src/responses/state/snapshot-codec.ts @@ -1,3 +1,4 @@ +import { validReplayOwnerKey } from "./replay-owner"; import type { ResidentInput, ResidentResponseState, @@ -20,6 +21,7 @@ export interface SnapshotLoadStore { interface LegacySnapshotState { createdAt?: unknown; clientThreadId?: unknown; + replayOwnerKey?: unknown; items?: unknown; providers?: OcxProviderContinuationState; conversationId?: unknown; @@ -41,6 +43,11 @@ export function loadSnapshotEntry(id: string, value: unknown, store: SnapshotLoa if (!value || typeof value !== "object" || Array.isArray(value)) return; const rec = value as LegacySnapshotState & { kind?: unknown; spill?: unknown }; if (typeof rec.createdAt !== "number" || !Number.isFinite(rec.createdAt)) return; + if (rec.replayOwnerKey !== undefined && !validReplayOwnerKey(rec.replayOwnerKey)) { + store.replaceMapEntry(id, store.tombstone(id, rec.createdAt)); + return; + } + const replayOwnerKey = rec.replayOwnerKey; const clientThreadId = typeof rec.clientThreadId === "string" && rec.clientThreadId.trim().length > 0 ? rec.clientThreadId.trim() : undefined; @@ -61,6 +68,7 @@ export function loadSnapshotEntry(id: string, value: unknown, store: SnapshotLoa kind: "spill", createdAt: rec.createdAt, ...(clientThreadId ? { clientThreadId } : {}), + ...(replayOwnerKey ? { replayOwnerKey } : {}), // Item count is unknown until materialization, so accept any non-negative integer // here; the spill payload validator re-checks it against the real array. ...(anchorFor(Number.MAX_SAFE_INTEGER) !== undefined ? { providerOutputStart: anchorFor(Number.MAX_SAFE_INTEGER) } : {}), @@ -90,6 +98,7 @@ export function loadSnapshotEntry(id: string, value: unknown, store: SnapshotLoa const resident = store.measureResidentEntry(id, { createdAt: rec.createdAt, ...(clientThreadId ? { clientThreadId } : {}), + ...(replayOwnerKey ? { replayOwnerKey } : {}), items: rec.items, ...(anchorFor(rec.items.length) !== undefined ? { providerOutputStart: anchorFor(rec.items.length) } : {}), ...(providers ? { providers } : {}), diff --git a/src/responses/state/spill-queue.ts b/src/responses/state/spill-queue.ts index f5616fc1824..d9681e63dd0 100644 --- a/src/responses/state/spill-queue.ts +++ b/src/responses/state/spill-queue.ts @@ -193,6 +193,7 @@ function spillPayloadForResident(candidate: ResidentResponseState): Parameters): boolean { + if (!principal) return false; + const environment = configuredApiAuthToken(config); + if (environment && mintContextPrincipal("environment", "", environment) === principal) return true; + const matches = (keyId: string, secret: string): boolean => { + if (!secret || mintContextPrincipal("configured", keyId, secret) !== principal) return false; + // Preserve the resolver's precedence if an environment token or another key + // now shadows this entry. The same key id with a replacement secret is not this owner. + return contextPrincipalIdOf(resolveDataPlaneAdmissionSecret(secret, config) ?? undefined) === principal; + }; + for (const key of config.apiKeys ?? []) { + if (matches(key.id, key.key)) return true; + const pending = key.pendingRotation; + if (pending && Date.parse(pending.expiresAt) > Date.now() && matches(key.id, pending.key)) return true; + } + return false; +} + /** * The caller principal for a context-relay request, which is a stricter question than admission. * @@ -576,6 +598,22 @@ export function resolveContextPrincipal(req: Request, config: OcxConfig, admissi return undefined; } +/** Preserve only loopback replay identity removed by the forwarding-header filter. */ +export function resolveFilteredReplayPrincipal( + req: Request, + config: OcxConfig, + admission: DataPlaneAdmission, + forwardHeaders: Headers, +): Readonly<{ clientPrincipalId: string }> | undefined { + if (admission.kind !== "loopback" + || !(req.headers.has("x-opencodex-api-key") || req.headers.has("x-api-key"))) return undefined; + const principal = resolveContextPrincipal(req, config, admission); + const forwardedPrincipal = resolveContextPrincipal(new Request(req.url, { headers: forwardHeaders }), config, admission); + // A surviving bearer keeps its existing per-frame resolution and key-rotation semantics. + if (principal && principal !== forwardedPrincipal) return Object.freeze({ clientPrincipalId: principal }); + return undefined; +} + /** Stable structural replay identity. This does not replace process-local opaque-state ownership. */ export function resolveDurableReplayPrincipal( req: Request, diff --git a/src/server/codex-model-list.ts b/src/server/codex-model-list.ts index 4059cc6e404..387821a25ef 100644 --- a/src/server/codex-model-list.ts +++ b/src/server/codex-model-list.ts @@ -6,6 +6,7 @@ import { loadCatalogTemplate } from "../codex/catalog/bundled"; import { applyNativeVisibility, nativeContextLimits } from "../codex/catalog/metadata"; import { readCatalog, readCodexCatalogPath, type CatalogModel } from "../codex/catalog/parsing"; import { readNativeBaseline, retainedNativeSourcePriorities } from "../codex/catalog/native-priority"; +import { resolveMultiAgentMode } from "../config/multi-agent-surface"; interface CodexModelListInput { config: OcxConfig; @@ -31,7 +32,7 @@ export function buildCodexModelList({ baseline: readNativeBaseline(catalogPath), retainedBySlug: retainedNativeSourcePriorities(readCatalog(catalogPath)?.models ?? []), }; - const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; + const maMode = resolveMultiAgentMode(config); // Account rows include hidden supported ids so newly re-enabled natives reappear before sync. const catalogNativeSlugs = accountSelectors.length > 0 ? [...new Set([...availableAccountNativeSlugs, ...accountNativeSlugs])] diff --git a/src/server/index/serve-contract.ts b/src/server/index/serve-contract.ts index fda2afa5640..fa819409462 100644 --- a/src/server/index/serve-contract.ts +++ b/src/server/index/serve-contract.ts @@ -45,6 +45,7 @@ export interface ServeOptionsContext { loopbackRouteAllowed: (url: URL, req: Request) => boolean; managementIngressRouteAllowed: (url: URL, req: Request) => boolean; linkRouteAllowed: (url: URL, req: Request) => boolean; + /** A key-admitted catalog request; routeProofId is a multi-route readiness proof it carried (src/link/route-proof.ts). */ onAuthenticatedCatalog: (apiKeyId: string, routeProofId?: string) => void; linkPolicy: () => RequestPolicyView; packageTreeChangedResponse: ( diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 2ab34ab14e0..9c4683be8eb 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -109,6 +109,7 @@ import { admissionFields, resolveApiAuth, resolveResponsesApiAuth, + resolveFilteredReplayPrincipal, type RequestPolicyView, withCors, withManagementCors, @@ -567,17 +568,19 @@ export function createServeOptions(ctx: ServeOptionsContext) { } const websocketLease = tryReserveCodexWebSocket(); if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets", policy); + const forwardHeaders = selectForwardHeaders(req.headers); + const replayPrincipal = resolveFilteredReplayPrincipal(req, config, admission, forwardHeaders); // Upgrade on the server that RECEIVED this request, not the captured `server` // binding. They are the same object for the public listener, but the // unauthenticated loopback listener (#1102) is a second Bun.serve, and handing its // request to the public server's upgrade would fail or cross sockets. if (requestServer.upgrade(req, { data: buildResponsesWsData( - selectForwardHeaders(req.headers), + forwardHeaders, admission, websocketLease, sessionLaneIdFromRequest(req.headers), - classifyUsageClient(req.headers), + classifyUsageClient(req.headers), replayPrincipal, ), })) return undefined as unknown as Response; websocketLease.release(); diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 3ac98d8390e..7d2f61b03ba 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -1,4 +1,6 @@ import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../../service"; +import type { OcxConfig } from "../../types"; +import { isPlaintextV2AgentMessagesEnabled } from "../../config/multi-agent-surface"; import { createWindowsTaskListingCache, inspectNativeCodexOwnership, @@ -268,8 +270,10 @@ export function resolveInboundBodyLimitWithWarning(config: { maxInboundBodyBytes return limit; } -export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void { - if (config.plaintextV2AgentMessages !== true) return; +export function warnPlaintextV2AgentMessagesStartup( + config: Pick, +): void { + if (!isPlaintextV2AgentMessagesEnabled(config)) return; console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); console.warn(" Eligible ChatGPT collaboration calls may carry plaintext message arguments. HTTPS remains encrypted, but task text may be retained in Codex history, selected providers, and local response/debug state."); console.warn(" This depends on undocumented ChatGPT and Codex behavior; it does not decrypt existing tasks."); diff --git a/src/server/index/websocket-handler.ts b/src/server/index/websocket-handler.ts index cf07600322f..177a880b6fe 100644 --- a/src/server/index/websocket-handler.ts +++ b/src/server/index/websocket-handler.ts @@ -75,6 +75,7 @@ import { admissionFields, resolveApiAuth, resolveResponsesApiAuth, + isContextPrincipalCurrent, type RequestPolicyView, withCors, withManagementCors, @@ -363,9 +364,15 @@ export function createWebsocketHandler( if (workflow?.admitted) turnAdmissionLease.attach(workflow.lease); ws.data.nativeControl = nativeControl; let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; + const retainedPrincipal = ws.data.replayPrincipal; + const replayPrincipal = retainedPrincipal && (isContextPrincipalCurrent(retainedPrincipal.clientPrincipalId, config) + ? retainedPrincipal : { clientPrincipalId: undefined }); const response = await handleResponses(req, config, logCtx, { advisorAdmissionPolicy, ...(wsAdmission ? { admission: wsAdmission } : {}), + // Keep an empty holder after revocation: a different forwarded bearer + // must not become this connection's retained replay owner. + ...(replayPrincipal ? { replayPrincipal } : {}), forceEmptyResponseId: true, inboundTransport: "websocket", nativeControl, diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d9d7f29ba06..db88f3e4c9c 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -18,6 +18,7 @@ import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError } from "../../config/provider-validation"; import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../../config/multi-agent-surface"; +import { isPlaintextV2AgentMessagesEnabled } from "../../config/multi-agent-surface"; import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance"; import { anthropicSidecarPatchError } from "../../config/schema/anthropic-account-pool"; import { isAnthropicInstanceId } from "../../providers/anthropic-instance-id"; @@ -294,6 +295,8 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise { const effectiveMode = mode ?? config.multiAgentMode ?? "default"; const effectiveKeepNative = wantsKeepNative ? body.keepNativeChatGptOnV1 === true : config.keepNativeChatGptOnV1 === true; const hybridPinActive = effectiveMode === "v2" && effectiveKeepNative; - const modeFlag = mode === "v2" ? !hybridPinActive : mode === "v1" ? false : undefined; + const pinDrivenV2 = hybridPinActive || effectiveMode === "v2-plaintext"; + const pinConflict = effectiveMode === "v2-plaintext" + ? "body.enabled=true conflicts with multiAgentMode 'v2-plaintext': Codex's global multi_agent_v2 override outranks catalog pins" + : "body.enabled=true conflicts with keepNativeChatGptOnV1: Codex's global multi_agent_v2 override outranks catalog pins"; + const modeFlag = mode === "v2" ? !hybridPinActive : mode === "v1" || mode === "v2-plaintext" ? false : undefined; if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) { return jsonResponse({ - error: hybridPinActive - ? "body.enabled=true conflicts with keepNativeChatGptOnV1: Codex's global multi_agent_v2 override outranks catalog pins" + error: pinDrivenV2 + ? pinConflict : `body.enabled conflicts with multiAgentMode '${mode}'`, }, 400); } - if (wantsFlag && body.enabled === true && hybridPinActive) { + if (wantsFlag && body.enabled === true && pinDrivenV2) { return jsonResponse({ - error: "body.enabled=true conflicts with keepNativeChatGptOnV1: Codex's global multi_agent_v2 override outranks catalog pins", + error: pinConflict, }, 400); } + // Recheck consent against the admitted state as well as the initial snapshot. + if (mode === "v2-plaintext" && resolveMultiAgentMode(config) !== "v2-plaintext" + && body.plaintextAgentMessagesAcknowledged !== true) { + return jsonResponse({ error: "body.multiAgentMode 'v2-plaintext' requires plaintextAgentMessagesAcknowledged === true: native v2 collaboration messages are delivered as plaintext on the canonical ChatGPT route" }, 400); + } const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag ?? (wantsKeepNative && hybridPinActive ? false : undefined); @@ -457,6 +476,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise 0 && body === rawBody; + const unresolvedPrevious = requestedPreviousId.trim().length > 0 && body === rawBody; // Remote Link (design T4 5 and 9.1): the combo's own expansion is the request's one capture point; its // children replay a snapshot and skip theirs. observeLinkTurnExpansion(options.linkTurn, req, logCtx, body, - requestedPreviousId.length > 0 ? (unresolvedPrevious ? "unexpanded" : "chained") : "none"); + requestedPreviousId.trim().length > 0 ? (unresolvedPrevious ? "unexpanded" : "chained") : "none"); if (combo.imageInput === "disabled" && unresolvedPrevious) { markLinkTurnMissing(logCtx); return formatErrorResponse( @@ -1246,6 +1251,7 @@ export async function executeComboResponses( }, }) : await requestDispatchers.handleResponses(childRequest, config, childLog, { ...options, + plaintextV2Decision: strictReplay, replayPrincipal, // A bridge child is a concrete route; the native source belongs to this loop only. ...(options.protocolSource ? { protocolSource: undefined } : {}), diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index cb94a33f819..e7c7860c914 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -19,6 +19,7 @@ import { prepareEffortNormalization, stripEmptyLadderEffort, supportedLadderFor import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport"; import { getOrAllocateRequestSessionLane } from "../request-log-conversation"; import { shouldPreparePlaintextV2AgentMessages } from "../../responses/plaintext-v2-agent-messages"; +import { isPlaintextV2AgentMessagesEnabled } from "../../config/multi-agent-surface"; import { hasValidatedActiveReasoningEffort } from "../../responses/parser"; import { responseTierAuthorityForProvider } from "../../providers/openai-tiers-destination"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; @@ -195,11 +196,12 @@ export async function applyFinalRouteRequestNormalization(args: { args.claudeGoAffinity ? args.claudeGoAffinity.sessionLane : getOrAllocateRequestSessionLane(req), routedProvider); parsed._plaintextV2AgentMessages = shouldPreparePlaintextV2AgentMessages({ - enabled: config.plaintextV2AgentMessages === true, + enabled: parsed._plaintextV2Decision === true && isPlaintextV2AgentMessagesEnabled(config), inboundWire, canonicalChatGpt: isCanonicalOpenAiForwardProvider(route.provider), requestBody: parsed._rawBody, }); + if (parsed._plaintextV2AgentMessages) parsed._plaintextV2EverPrepared = true; // Recompute from the original wire preference on every route, including fallback. // A provider default never converts raw reasoning into a summary. if (inboundWire === "responses" && parsed._rawBody) { diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 8b163813566..9bbee9b5756 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -161,6 +161,8 @@ export interface HandleResponsesOptions { * holder with no principal preserves keyless replay; never populate it from client data. */ replayPrincipal?: Readonly<{ clientPrincipalId: string | undefined }>; + /** Captured for the logical request and inherited by retries and combo children. */ + readonly plaintextV2Decision?: boolean; /** Called at most once after the complete client body is read and accepted for dispatch. */ onRequestBodyRead?: () => void; /** Internal handoff for retry wrappers that must reuse the already-accounted request body. */ diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index bc5cda6d911..ace61778b86 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -15,6 +15,7 @@ import { executeComboResponses } from "./core-combo"; import { registerTurn, unregisterTurn } from "../lifecycle"; import { runWithCompactionRecovery } from "./compaction-recovery"; import { captureAdmissionPolicy } from "../capture-admission-policy"; +import { isPlaintextV2AgentMessagesEnabled } from "../../config/multi-agent-surface"; /** * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough * wire, image/web-search bridges, and the terminal-guard continuation. @@ -36,6 +37,7 @@ export async function handleResponses( try { const response = await runWithCompactionRecovery(req, config, logCtx, { ...options, abortSignal, + plaintextV2Decision: options.plaintextV2Decision ?? isPlaintextV2AgentMessagesEnabled(config), advisorAdmissionPolicy: options.advisorAdmissionPolicy ?? captureAdmissionPolicy(config, options.admission, options.admissionScope), openAiSidecarAuth: options.openAiSidecarAuth === undefined ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index f59a7bdb63c..4bb0e376acd 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -1,3 +1,5 @@ +import { responseReplayOwnerKey } from "../../responses/state/replay-owner"; +import { isPlaintextV2AgentMessagesEnabled } from "../../config/multi-agent-surface"; import { projectLocalModelRefusalLog } from "../request-log-failure-detail"; import { failLinkTurnExpansion, markLinkTurnMissing, observeLinkTurnExpansion } from "../link-turn"; import { inputAdmissionRefusalMessage } from "./input-admission"; @@ -436,12 +438,17 @@ export async function prepareResponsesRequest( // parent all present the same parent id. `codexConversationIdentity` already reads this header // for the same reason, and a surface that must tell siblings apart needs it too (#5033). const inboundOwnThreadId = req.headers.get("thread-id")?.trim() || undefined; + const replayPrincipal = options.replayPrincipal + ? options.replayPrincipal.clientPrincipalId : resolveContextPrincipal(req, config, options.admission); + const strictReplay = options.plaintextV2Decision ?? isPlaintextV2AgentMessagesEnabled(config); + const replayOwnerKey = strictReplay ? responseReplayOwnerKey(replayPrincipal, inboundOwnThreadId) : undefined; + const replayThreadId = strictReplay ? inboundOwnThreadId : inboundClientThreadId; const cursorClientThreadId = codexPoolAffinityKey(req.headers); const originalBody = body; if (options.comboReplaySnapshot) { copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body); } else { - body = expandPreviousResponseInput(body, inboundClientThreadId); + body = expandPreviousResponseInput(body, replayThreadId, replayOwnerKey); const replayFailure = previousResponseReplayFailure(body); if (replayFailure?.reason === "scope_mismatch") { // Bounded and content-free: no task scope and nothing about the retained entry. @@ -619,10 +626,13 @@ export async function prepareResponsesRequest( } return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err)); } + parsed._plaintextV2Decision = strictReplay; options.onRequestBodyRead?.(); - const responseStateOptions = (force = false): { force?: boolean; clientThreadId?: string } => ({ + const responseStateOptions = (force = false) => ({ + replayOwnerKey, + compiledPlaintext: parsed._plaintextV2EverPrepared === true, ...(force ? { force: true } : {}), - ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}), + ...((strictReplay ? inboundOwnThreadId : parsed._clientThreadId) ? { clientThreadId: strictReplay ? inboundOwnThreadId : parsed._clientThreadId } : {}), }); const resolvedConversationId = conversationIdFromResponsesRequest({ clientThreadId: parsed._clientThreadId, @@ -1173,6 +1183,8 @@ export async function prepareResponsesRequest( try { const reparsed = parseRequest(body); const kept: Array = [ + "_plaintextV2Decision", + "_plaintextV2EverPrepared", "_previousResponseInputExpanded", "_providerContinuation", "_providerContinuationCandidate", diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index d608cfde228..e717da78748 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -1,5 +1,6 @@ import { isolateCodexPoolAccountHeaders } from "../codex/forward-transport-headers"; import type { NativeResponseControl } from "./responses/native-response-control"; +import type { HandleResponsesOptions } from "./responses/core-options"; import type { ServerWebSocket } from "bun"; import { responsesJsonEventSequence } from "./responses-json-events"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; @@ -41,6 +42,8 @@ export interface WsData { * attribution. */ admission?: DataPlaneAdmission; + /** Local-only loopback replay identity; revalidated each turn without retaining its secret. */ + replayPrincipal?: HandleResponsesOptions["replayPrincipal"]; authContext?: CodexAuthContext; // last resolved account decision for observability/registry cleanup cancel?: () => void; // cancels the in-flight stream reader/fetch turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns @@ -134,12 +137,14 @@ export function buildResponsesWsData( admissionLease?: AdmissionReservation>, sessionLaneId?: string, client?: UsageClient, + replayPrincipal?: HandleResponsesOptions["replayPrincipal"], ): WsData { // Auth is handshake-time only on this path: the per-frame contexts have no // request headers left to re-resolve from, so the decision rides along here. return { headers, admission, + ...(replayPrincipal ? { replayPrincipal } : {}), ...(client ? { client } : {}), ...(admissionLease ? { admissionLease } : {}), ...(sessionLaneId ? { sessionLaneId } : {}), diff --git a/src/types/config.ts b/src/types/config.ts index 48d0a871b93..41b710967fc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,7 @@ import type { CodexAccount } from "./accounts"; import type { AnthropicAccountPoolConfig, OcxAccountPoolRotationStrategy } from "./anthropic-account-pool"; import type { AnthropicInstanceId } from "../providers/anthropic-instance-id"; import type { OcxFallbackConfig } from "./fallback"; +import type { MultiAgentMode } from "./multi-agent"; import type { OcxTelemetryConfig } from "./telemetry"; import type { OcxClaudeCodeToolLoadingConfig } from "./claude-tool-loading"; import type { OcxLinkHomeConfig, OcxLinkTransportConfig } from "./link"; @@ -940,13 +941,8 @@ export interface OcxConfig extends OcxLinkHomeConfig { * Unset or omitted by default. */ blockedModelRedirects?: Record; - /** - * 3-state multi-agent surface override: - * - "v1": force ALL models to v1 surface (override upstream pins) - * - "default" | undefined: respect upstream model pins (sol/terra=v2, luna=v1, rest=codex flag) - * - "v2": force ALL models to v2 surface (override upstream pins) - */ - multiAgentMode?: "v1" | "default" | "v2"; + /** Multi-agent surface override; mode selection and consent follow MultiAgentMode. */ + multiAgentMode?: MultiAgentMode; /** * Which revision of the sub-agent surface advisory this install has answered. * Absent means it has answered none. Written by the dashboard, never by a mode change. @@ -955,6 +951,7 @@ export interface OcxConfig extends OcxLinkHomeConfig { /** * When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1. * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92). + * Stored but inactive under `"v2-plaintext"`, which pins ChatGPT-native rows on v2. */ keepNativeChatGptOnV1?: boolean; /** Experimental plaintext delivery for native v2 collaboration messages; disabled unless true. */ diff --git a/src/types/multi-agent.ts b/src/types/multi-agent.ts new file mode 100644 index 00000000000..70ebd7b792e --- /dev/null +++ b/src/types/multi-agent.ts @@ -0,0 +1,13 @@ +/** + * Multi-agent surface override: + * - "v1": force ALL models to v1 surface (override upstream pins) + * - "default" | undefined: respect upstream model pins (sol/terra=v2, luna=v1, rest=codex flag) + * - "v2": force ALL models to v2 surface (override upstream pins) + * - "v2-plaintext": force ALL models to v2 surface via catalog pins while the global + * `multi_agent_v2` feature stays disabled. Selecting the mode is itself the explicit + * opt-in to plaintext delivery of native v2 collaboration messages; the separate + * `plaintextV2AgentMessages` flag is not required and is never rewritten by a mode change. + * The "v2-plaintext" literal must never be written into `multi_agent_version`, + * whose values Codex validates. + */ +export type MultiAgentMode = "v1" | "default" | "v2" | "v2-plaintext"; diff --git a/src/types/request.ts b/src/types/request.ts index 38f30c6c3eb..edb35b43c58 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -148,6 +148,8 @@ export interface OcxParsedRequest { _dropForeignReasoningItemIds?: boolean; /** Final-route opt-in: emit v2 collaboration message arguments as plaintext on ChatGPT. */ _plaintextV2AgentMessages?: boolean; + _plaintextV2Decision?: boolean; + _plaintextV2EverPrepared?: boolean; /** * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation. * When absent (single-operator local proxy), derivation stays local-scoped. diff --git a/structure/cli-management.md b/structure/cli-management.md index 267daaac12f..9df172b3550 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -2,6 +2,8 @@ `src/cli/` contains the terminal management interface. Process ownership, startup, stop and service contracts remain in [Runtime](runtime.md); this document owns command discovery, help, management-client presentation and the generated operating reference. +`ocx v2 mode v2-plaintext` requires `--acknowledge-plaintext-agent-messages` when entering the mode, locally or with `--live`. Local selection admission re-reads the mode and rechecks entry consent before either native or proxy writes, so a mode changed during the wait cannot reuse earlier idempotent permission. Generic config set/import refuse a new plaintext-mode entry and direct the operator to that coordinated transition; already-active restoration and exits remain allowed. The live client sends a scoped acknowledgment and validates the returned mode, capability and plaintext state. Its global feature remains disabled; the [surface contract](subagent-capacity.md#multi-agent-surface-selection) defines task-text retention and unchanged transport authentication. Existing independent plaintext preferences are preserved when leaving the mode. + `src/cli/account-affinity-reset.ts` implements `ocx account reset-affinity [--json]` through the declared `PUT /api/oauth/accounts/affinity-reset` management route. It sends once, requires a matching provider and explicit durable confirmation, and reports unconfirmed or failed responses as nonzero exits. This provider-wide cleanup remains available without a configured pool or account roster; it never selects or resumes an account. See the [affinity persistence contract](providers/anthropic-account-pool.md#anthropic-account-pause). ## CLI readiness diagnostics @@ -87,6 +89,8 @@ Management route declarations describe actual HTTP calls only; local Lab automat Local config set/unset/import, offline effort, custom-model add/remove and provider add/remove/default wait for config-root ACL settlement before their synchronous mutation phase. Candidate reads and validation run inside admission; invalid or unreadable authority is refused without replacing its bytes. Models/provider runners in `src/cli/dispatch.ts` use `runCliAction` for the standard usage-error message and exit 2, retaining handler `process.exitCode` on normal completion. Post-save sync keeps its existing result contract. Custom-model removal rechecks the confirmed row after any prompt and admission. The [config admission contract](config.md) owns the deadline and writer ordering. +Plaintext entry in `src/cli/config-command.ts` is checked against the fresh set candidate. Import rejects invalid current authority before lock preparation creates coordination files, reads and validates its input before taking the synchronous config lock, then rechecks current mode and saves within that transaction. Existing plaintext-mode restoration, other settings, exits and the independent legacy flag retain their behavior. `tests/cli/cli-config-command.test.ts` covers canonical/normalized paths and import refusal without overwriting config bytes; `tests/windows/windows-config-root-async-writers.test.ts` retains the no-coordination-file refusal contract. + Offline removal in `src/cli/provider.ts` snapshots GCP marker references before deletion and runs the [post-commit credential cleanup](providers/google.md#vertex-credential-markers) only after saving; live and latest durable rows are captured under the same mutation lock as deletion persistence. diff --git a/structure/codex-home.md b/structure/codex-home.md index e07c05c4c05..41f010a9634 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -418,7 +418,7 @@ this ceiling for up to `RESPONSE_SPILL_ORPHAN_GRACE_MS` past each crash. Without directory was limited only per file (256 MiB) and per entry (1000) — a 250 GiB product — which left `RESPONSE_TTL_MS` as the only effective limit and made disk use a function of client request rate rather than of anything the process controls. The durable snapshot that -re-establishes these references after a restart is budgeted too, and its bounded stub and +re-establishes file references after a restart (not caller replay authority; see the [Responses retention contract](transports/responses.md)) is budgeted too, and its bounded stub and tombstone rows are selected before resident payloads under that budget, so a full resident cohort cannot crowd a spill reference out of `responses-state.json` and strand a file the store still owns. diff --git a/structure/config.md b/structure/config.md index a3d71246620..041945e8830 100644 --- a/structure/config.md +++ b/structure/config.md @@ -117,7 +117,7 @@ Malformed optional data-loopback and nested hub-management listener blocks are d The `ocx config [show]` reader in `src/cli/config-command.ts` uses those diagnostics directly. `--json` and `--source` are consumed before selecting the action, so flags alone default to `show`; repeated `--json` or `--source` flags are rejected before positional operands, and `--source` is rejected for every other action. When diagnostics report a fallback or error, `show`/`get` warn on stderr and exit nonzero (explicit `--source` still exits 0); `validate` returns exit 1 for an invalid candidate, file `export --json` emits only `{ok:true,path}`, and local `ocx provider add` saves only a candidate that passes `validateConfigCandidate`. Its client annotation compares only the bounded service-token fingerprint with the validated client record; it does not call `loadConfig`, mutate permissions, or import the write-capable connect flow. -All config publication continues through the existing required ACL-hardened writers above. +Generic config writers follow the [CLI plaintext-entry contract](cli-management.md); all config publication continues through the existing required ACL-hardened writers above. `claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config/salvage.ts` already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. `claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. `claudeCode.contextAccounting` in `src/types/config.ts` stores only `"200k"`; absence is the 1m default and `"1m"` deletes the key. `ocx claude config set --context-accounting` writes it. The surface contract is [Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index fa07691b219..ad1497dd8f7 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -49,12 +49,12 @@ rejects malformed targets without dropping unrelated config. An unknown saved mo visible for correction, and disabling a phase removes its effort setting. The route contract is defined by [memory phase routing](transports/responses-failover.md#memory-phase-routing). -The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses-wire-shapes.md#passthrough-sse-stream-shapes-314). Management provider-validation calls use the [initialization-independent relative send-path validation](config.md#provider-relative-send-paths) before persistence. Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). +The opt-in [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses-wire-shapes.md#passthrough-sse-stream-shapes-314). Management provider-validation calls use the [initialization-independent relative send-path validation](config.md#provider-relative-send-paths) before persistence. Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). The [Orca importer](codex-home.md#orca-source-owned-account-import) is a local CLI operation with -no management route. Imported accounts use existing quota validation; deferred warmups reread -linked sources after the quota await to reject revoked or rotated captures. +no management route. Imported accounts use existing quota validation; deferred warmups reread linked sources after the quota await to reject revoked or rotated captures. + +The `v2-plaintext` surface option is exposed only when `GET /api/v2` advertises `supportsPlaintextV2Mode: true`. Entering it requires the dashboard's explicit task-retention acknowledgment, sent as `plaintextAgentMessagesAcknowledged: true` in the same PUT as the mode. `plaintextAgentMessagesEnabled` reports the derived compiler state; successful configuration and catalog synchronization do not prove a real provider handoff. The mode leaves the global override off and preserves independent legacy settings. Existing authentication and write permissions are unchanged. ## Compact desktop usage @@ -281,7 +281,7 @@ per-request first-party callback reads that live object; a failed write leaves i | 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. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, the read-only `GET /api/link/diagnostics` (this runtime's bounded diagnostics file, re-sanitized on read), `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). 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 [`openai-tiers.md`](providers/openai-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 `maxConcurrentThreadsPerSession`. That API value counts children with `enabled: false` and includes the primary thread with `enabled: true`. The Models page's Model settings panel always displays children and exposes the limit even when the global flag is disabled; see [concurrent child limit](subagent-capacity.md#concurrent-child-limit). 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. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | +| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the `multiAgentMode` override (`v1`/`default`/`v2`/`v2-plaintext`), the `keepNativeChatGptOnV1` hybrid pin, and `maxConcurrentThreadsPerSession`. That API value counts children with `enabled: false` and includes the primary thread with `enabled: true`. The Models page's Model settings panel always displays children and exposes the limit even when the global flag is disabled; see [concurrent child limit](subagent-capacity.md#concurrent-child-limit). 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` or `v2-plaintext` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. The native feature transition preserves the logical thread limit and rolls back its own failed writes. Later mode persistence and catalog synchronization are separate steps; a failed combined PUT must be reread before retry. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, required while the resolved mode is base or v2 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | Reached through the Usage & Logs row’s section switcher (`/#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 persistent search field. Each request row shows an icon-only classified-client chip before its other content, with an accessible name; absent or unknown clients use a neutral question mark, and protocol conversion badges remain separate. `gui/src/client-icon.ts` shares dark-mode treatment for monochrome app SVGs with the usage source picker. 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` read-only aggregates of readable rows from `~/.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. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model, provider, and classified app breakdowns. The Usage report defaults to all apps and all devices; the app breakdown is shown for all-app selection. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. Fast observations and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority): the provider editor classifies `responseTierAuthoritative` as an operator-owned boolean under the existing redaction and write boundaries, `src/usage/log.ts` retains it on final outcomes and attempts (old records keep their legacy meaning), and `src/usage/cost.ts` never prices a non-authoritative echo or confirmed label as response-confirmed. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | diff --git a/structure/providers/codex-installation-identity.md b/structure/providers/codex-installation-identity.md index 03931f01dab..d25d7882f1c 100644 --- a/structure/providers/codex-installation-identity.md +++ b/structure/providers/codex-installation-identity.md @@ -47,6 +47,7 @@ metadata-free frames; main and main-pool remain outside this validation. `src/adapters/openai-responses/transport.ts` and `src/adapters/openai-responses/passthrough.ts` apply the same identity to HTTP headers and a cloned client-metadata map. The serialized body feeds `src/server/responses/codex-ws-request.ts`; WebSocket preparation preserves these existence gates. +The inbound `buildResponsesWsData` contract in `src/server/ws-bridge.ts` keeps the observational usage client separate from the admitted replay principal. `src/server/index/serve-options.ts` passes both; client classification never grants replay authority. `src/server/responses/codex-ws-pool.ts` includes immutable outgoing headers and account in its reuse identity. `src/server/ws-bridge.ts` isolates explicit Pool header overrides as well. Its inbound handshake snapshot also retains caller-supplied `user-agent` and diff --git a/structure/remote-link.md b/structure/remote-link.md index 3d3cfde8f8a..fee7cd4939b 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -278,6 +278,8 @@ The hub management relay advertises `relayTargetCheck: "expected-target-v1"` in `src/link/codex-settings.ts` owns the Codex settings document. It carries exactly the OpenCodex config fields the injection plan reads that are policy rather than listener facts: `fastMode`, `multiAgentMode`, `webSearchSidecar.enabled`, and the effective managed sub-agent defaults (`syncCodexSubagentDefaults` with `injectionModel` and `injectionEffort`). Root `model_reasoning_effort` and the `desktop.*` picker keys are Codex app settings OpenCodex writes on no machine, so they are not part of it. Port, hostname, the Desktop authless and client compaction routing forms, and resume history stay the Child's because they describe how the Child's Codex reaches the Child. The document rides on its own read rather than in the catalog because the catalog bytes are written verbatim to `model_catalog_json`. The parse is strict. `syncConnectedClient` and link-mode connect in `src/client/connect.ts` overlay it on the Child's config before injection. A fresh read is recorded in `/link/home-codex-settings.json` (0600, keyed by link id, removed on disconnect) only after the injection that applies it succeeds, because `ocx status` reads the record as applied; connect with `--no-sync` records nothing. A failed read reuses that record, and a Home without the route leaves the Child's local values and says so. `ocx sync` and `ocx status` list the applied values; the record's host name is also how a Home-initiated Child, which has no sidecar alias, names its Home. +The `multiAgentMode` policy also preserves `v2-plaintext` through projection, strict parse and the Child overlay, rather than dropping it as an absent mode. It follows the same authenticated Home-policy boundary; no credential or resume-history field is added. Older Children reject the unknown enum through their existing strict parser, so both endpoints need support before this mode can be applied consistently. + Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listener: admission, refusals, masked projections, the settings document), `tests/clients/client-machine-listener.test.ts` (the Child relay and session bootstrap), `tests/clients/link-home-settings.test.ts` (parse, overlay, injection and record fallback) and `tests/cli/cli-link-child.test.ts` (the CLI reads, refusals and Home wording). `src/link/codex-settings.ts` also owns `appliedCodexSettingsDigest` (OCX-6): lowercase hex SHA-256 of compact JSON written in a fixed key order (`version`, then `settings` with `fastMode`, `multiAgentMode`, `webSearchSidecarEnabled`, `subagentDefaults` as `null` or `{model, reasoningEffort}`). It is a published contract that GOAT recomputes byte for byte, so it is serialized by name, not by a canonicalizer, and `tests/clients/link-codex-settings-digest.test.ts` pins two golden digests computed outside the code. The Child's record carries `appliedAt`, stamped when an apply writes a different digest and kept when the same digest is re-applied; a record from before OCX-6 reads its `fetchedAt`. Before an apply writes different settings, `withdrawChangingHomeCodexSettingsRecord` removes the old record, so a failed injection or record write reports no digest instead of the previous one. A Home reports the digest of the document it serves right now, which is what P6 compares a Child against. A failed record write is an error on `ocx sync` and a warning after an enrollment connect has committed (the committed connection is never rolled back for it). Every apply, `ocx sync` and the link branch of `connect`, holds the apply lock (`withLinkApply`: the lifecycle lock primitive on its own path under `/link/`, outermost, `busy_timeout = 0`) from the Home read through injection to the record write, so a record always names what its own injection wrote. L cannot be held across injection because injection takes N and the order is N -> L -> C. Coverage: `tests/clients/link-apply-record.test.ts`. diff --git a/structure/subagent-capacity.md b/structure/subagent-capacity.md index ed68673a7c4..363ed911984 100644 --- a/structure/subagent-capacity.md +++ b/structure/subagent-capacity.md @@ -2,7 +2,7 @@ Surface and limit settings are independent of [subagent roster ordering](subagents.md). -## Multi-agent surface mode (3-state) +## Multi-agent surface selection `OcxConfig.multiAgentMode` controls the `multi_agent_version` field stamped on catalog entries: @@ -11,12 +11,23 @@ Surface and limit settings are independent of [subagent roster ordering](subagen | `"v1"` | Force ALL entries to `multi_agent_version = "v1"` ??overrides upstream pins (sol/terra included). | | `"default"` | Respect upstream model pins (sol/terra=v2, luna=v1, others=null ??codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | | `"v2"` | Force ALL entries to `multi_agent_version = "v2"` ??overrides upstream pins (luna included). | +| `"v2-plaintext"` | Select V2 catalog pins for every model, enable the experimental plaintext task compiler, and disable the global override. Explicit opt-in; existing history is not rewritten. | The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and `mergeCatalogEntriesForSync` (on-disk sync), AFTER all normalization and visibility processing. This ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does not clobber the forced value. +`src/server/codex-model-list.ts` uses the shared mode resolver before building live rows, including +`v2-plaintext`; retained native priority evidence and read-only catalog discovery remain intact. + +`"v2-plaintext"` is a separate explicit opt-in: all catalog rows receive `multi_agent_version = "v2"`, the global feature is disabled, and the existing [plaintext V2 compiler](subagents.md#plaintext-v2-agent-messages) is enabled. The OpenCodex mode literal is never sent as a Codex model pin. Stored `keepNativeChatGptOnV1` and `plaintextV2AgentMessages` preferences are preserved; the former is inactive in this mode. Existing configurations never migrate into it automatically. Existing chats retain native history/session selection: catalog pins affect new chats, but the compiler follows the current setting on subsequent eligible canonical ChatGPT requests, including existing V2 sessions. No stored-history rewrite, ciphertext decryption or deletion of retained copies occurs when the setting changes. + +Entering this mode through `PUT /api/v2` requires `plaintextAgentMessagesAcknowledged: true`; re-saving the same mode is idempotent. The CLI requires `--acknowledge-plaintext-agent-messages` for the same transition. The dashboard advertises it only when `supportsPlaintextV2Mode` is true and asks for explicit confirmation of task-text retention before one mode write. GET/PUT expose the derived `plaintextAgentMessagesEnabled` state. Enabling the global V2 override while the mode is selected is rejected before writes. Selecting another mode restores its ordinary policy, without clearing an independently enabled legacy plaintext field. No automatic fallback or provider retry is added. + +The redesigned `DashboardMultiAgentControl` in `gui/src/pages/dashboard-overview-head.tsx` receives the capability through `dashboard-overview-sections.tsx`; the Subagents surface uses the same supported-mode list and retains its enabled-plaintext hint inside the setting row. Both keep the existing consent handlers. +The dashboard also displays an already-selected plaintext mode while capability is unconfirmed, without treating a click on that stored value as a new write. A failed plaintext save receipt triggers a same-server readback on Subagents while retaining the original save error. Help and applied-state text distinguish new-session catalog selection from the compiler's subsequent eligible requests in existing V2 sessions. + A forced pass records each row's pre-override value once, in `opencodex_multi_agent_version_origin` (a string pin, or null for none); repeated forced passes never replace it. Returning to `"default"` consumes the record. Pristine baseline and native pins still win; the record only decides a native @@ -32,18 +43,18 @@ document, because spreading the defaults underneath would repair an unrelated mi into a surface change its operator never made. An absent key still means `"default"`, because selecting base deletes the key ??absence cannot be read as "never configured". An install that predates that change is therefore not rewritten; it is -asked once. `multiAgentSurfaceAdvisoryRequired()` is true while the resolved mode is not v1 and +asked once. `multiAgentSurfaceAdvisoryRequired()` is true while the resolved mode is base or v2 and the stored `multiAgentSurfaceAdvisoryVersion` is below `MULTI_AGENT_SURFACE_ADVISORY_VERSION`, and only the operator answering the dashboard notice writes that version. -CLI: `ocx v2 mode v1|default|v2`. GUI: segmented control on the Models page. API: `GET/PUT /api/v2` +CLI: `ocx v2 mode v1|default|v2|v2-plaintext` (plaintext entry requires the acknowledgement above). GUI: segmented control on the Models page. API: `GET/PUT /api/v2` with `multiAgentMode` field. The `multi_agent_v2` feature flag and the logical maximum thread count are separate from `multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while the flag and thread count decide what the native runtime allows. Because the global feature has precedence over catalog pins, Codex config injection reconciles it -to disabled whenever persisted OpenCodex mode explicitly selects v1. This includes a fresh install +to disabled whenever persisted OpenCodex mode explicitly selects v1 or v2-plaintext. This includes a fresh install on a Codex home that had previously enabled v2; external-provider ownership and read-only injection preflight still prohibit that write. The transition runs inside the same write lock and preimage as the rest of injection, so a later refusal rolls the flag back with the files. @@ -55,7 +66,7 @@ v2. An explicit attempt to enable the global flag while the hybrid pin is active ### Concurrent child limit -The Models page's Model settings panel exposes one subagent limit control in v1, base and v2, +The Models page's Model settings panel exposes one subagent limit control in v1, base, v2 and v2-plaintext, including hybrid mode with the global `multi_agent_v2` flag disabled. Its displayed value counts children only. Surface selection and capacity are independent; raising the cap neither spawns children automatically nor guarantees faster execution. diff --git a/structure/subagents.md b/structure/subagents.md index 6ca7ea09b8d..8cdedbf2acc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -21,15 +21,13 @@ CLI installation inspection reason codes, including Windows deferral, follow the ## Plaintext V2 agent messages -`src/responses/plaintext-v2-agent-messages.ts` owns the experimental, configuration-only -`plaintextV2AgentMessages` request compiler and response restoration. The default is unset; -only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. +`src/responses/plaintext-v2-agent-messages.ts` owns the experimental request compiler and response restoration. `src/types/multi-agent.ts` owns the shared surface-mode type used by configuration and re-exported by catalog parsing. The legacy `plaintextV2AgentMessages` field defaults to unset; explicit true or the explicitly selected `v2-plaintext` mode enables it through `isPlaintextV2AgentMessagesEnabled` in `src/config/multi-agent-surface.ts`. Selecting or leaving that mode preserves the independent legacy field. Only Responses ingress to the final canonical ChatGPT forward route activates rewriting. Catalog pins affect new sessions, but the compiler predicate is evaluated for every eligible request, including later requests from existing V2 sessions. Disabling this setting does not delete retained history, provider, response-cache or diagnostic copies. A default collaboration catalog is required: top-level `tools`, or, when that field is absent, the first input item's developer `additional_tools` catalog used by Responses Lite. Explicit top-level catalogs take precedence; user-role and later historical catalogs do not opt in. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. +Declaration/reference collisions refuse the whole rewrite without changing the request. The request-level mode decision is captured once for combo children and internal retries; a disabled setting at normalization prevents compilation while keeping the captured replay scope strict. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after @@ -50,6 +48,7 @@ response/debug state. This is application-level plaintext over HTTPS, depends on upstream behavior, and does not decrypt existing tasks or replace authenticated recovery. Restored calls and selectors carry an explicit collaboration namespace and unqualified child name. +Restoration treats a null namespace as absent, rejects non-string namespace types, and restores the native namespace/name pair before HTTP/WS delivery and continuation publication. Codex treats qualified names literally and defaults absent namespaces to functions. Only child declarations inherit their restored namespace container; the compiler never invents an empty encryption marker when the upstream omitted it or returned a nonempty marker. @@ -60,9 +59,9 @@ Pool credentials used by subagent routes can be [linked to Orca-managed homes](codex-home.md#orca-source-owned-account-import). The account-store resolver enforces source identity and expiry before those credentials reach routing. -## Multi-agent surface mode (3-state) +## Multi-agent surface mode -See [surface selection and capacity](subagent-capacity.md#multi-agent-surface-mode-3-state) for the mode, global flag and hybrid configuration contract. +See [surface selection and capacity](subagent-capacity.md#multi-agent-surface-selection) for the mode, global flag and hybrid configuration contract. ### Concurrent child limit diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 47bf4f563ad..4bcbc18543e 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -178,8 +178,8 @@ except the native Responses passthrough. The passthrough forwards the id and kee upstream-owned state. A task-scope mismatch uses the same generic refusal even when the supplied input appears complete, because the proxy cannot prove that it contains the full conversation. The internal mismatch reason, stored scope and state contents never enter the client response; -the caller retries explicitly with complete history and no `previous_response_id`. Matching -normalized scopes replay, and two absent or blank scopes remain the legacy unscoped cohort. +the caller retries explicitly with complete history and no `previous_response_id`. When `v2-plaintext` or `plaintextV2AgentMessages` is enabled, local replay requires the same admitted caller principal and nonblank own `thread-id`; bound state remains bound after disabling. Default `v1` retains parent-thread-scoped continuation for unbound state, including default loopback callers. See the [retention contract](responses.md#responses-httpsse). +Combo history and provider continuation use the same exact opaque response ID throughout ownership validation and lookup. `PROVIDER_OWNED_CONTINUATION_WIRES` in `src/responses/continuation-ownership.ts` is deliberately empty and records why the three candidates do not qualify: devin re-sends the whole conversation each turn, cursor reads its diff --git a/structure/transports/responses.md b/structure/transports/responses.md index eef2f801d5c..a415b382d56 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -6,10 +6,10 @@ Native result continuations and function-result injection follow [the mode-speci Native steering follows [the shared WebSocket contract](streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. -The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Management provider-validation calls use the [initialization-independent relative send-path validation](../config.md#provider-relative-send-paths) before persistence. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). +Canonical ChatGPT forwarding, alias restoration and bounded content-type probing follow the [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages). The [structural turn replay membership contract](response-turn-membership.md) owns optional authenticated pre-parser ordering; ordinary input remains unchanged. Management provider-validation calls use the [initialization-independent relative send-path validation](../config.md#provider-relative-send-paths) before persistence. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). -Plaintext collaboration restoration treats a null namespace as absent, rejects non-string namespace types, and restores the native namespace/name pair before HTTP/WS delivery and continuation publication. -When a successful streamed native response has a missing or unrecognized non-JSON content type, the plaintext V2 path confirms a bounded Responses SSE prefix, under the server's `stallTimeoutSec` probe budget, before applying that restoration; an `application/json` body takes the bounded JSON path instead, and an unknown, stalled, or unreadable body retains the fail-closed response. The [structural turn replay membership contract](response-turn-membership.md) owns optional authenticated pre-parser ordering; ordinary input remains unchanged. +When `v2-plaintext` mode or `plaintextV2AgentMessages` is enabled, local `previous_response_id` retention uses `src/responses/state/replay-owner.ts` to bind the admitted caller principal and the request's own `thread-id`; parent headers grant no ownership. Both direct and combo ingress authorize the binding before spill reads or dispatch. Missing principals/own threads cannot record or replay local state, and legacy unbound entries cannot authorize strict replay. Default v1 retains the legacy parent-thread scope for unbound entries; bound entries remain inaccessible after disabling plaintext. The binding survives snapshot and spill serialization, whose metadata must agree on materialization. A colliding response id cannot replace another owner's entry. Admission principals are process-local, so a process restart requires full client replay of bound entries. `src/responses/state/replay-input.ts` owns the unchanged overlap/deduplication algorithm. `tests/server/response-replay-ownership.test.ts` and `tests/responses/responses-replay-owner-storage.test.ts` cover caller/thread separation, positive replay, collisions and storage reload. Combo history and provider-state lookup retain the exact opaque response ID checked by admission; `tests/server/response-replay-combo-identity.test.ts` covers distinct whitespace-bearing IDs sharing a provider account. Requests that prepared plaintext require a valid owner before response state is retained. The mode decision is shared across the logical request, including combo attempts and compaction retries. +WebSocket upgrades retain a content-free loopback replay principal only when filtering local credential headers would lose or change it. Each turn revalidates that principal against current environment/configured keys and unexpired pending rotations; a revoked retained principal cannot fall back to another handshake bearer. Bearer identity that survives filtering keeps its existing per-turn resolution. Raw local keys, the forwarding allowlist, data-plane admission and model scope are unchanged. `tests/responses/ws-capacity-reclamation.test.ts` covers real ingress ownership, revocation and key non-disclosure; `tests/server/data-plane-admission-identity.test.ts` covers current-key membership. ## Responses HTTP/SSE diff --git a/tests/adapters/terminal-continuation-owner-rotation.test.ts b/tests/adapters/terminal-continuation-owner-rotation.test.ts index 74673b1891a..8eddc3eb172 100644 --- a/tests/adapters/terminal-continuation-owner-rotation.test.ts +++ b/tests/adapters/terminal-continuation-owner-rotation.test.ts @@ -114,7 +114,7 @@ mock.module("../../src/server/adapter-resolve", () => ({ }, })); -const { handleResponses } = await import("../../src/server/responses"); +const { handleOwnedResponses: handleResponses } = await import("../helpers/response-replay-owner"); describe("terminal continuation provider-owner rotation", () => { let originalFetch: typeof fetch; @@ -188,7 +188,7 @@ describe("terminal continuation provider-owner rotation", () => { const post = (body: Record) => handleResponses( new Request("http://localhost/v1/responses", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", "thread-id": "terminal-key-rotation" }, body: JSON.stringify(body), }), config, @@ -256,7 +256,7 @@ describe("terminal continuation provider-owner rotation", () => { { key: keyA }, { key: keyA, continuation: "private-a" }, { key: keyA, continuation: "private-a" }, - { key: keyB }, + { key: keyB, stripReasoning: true }, { key: keyB, continuation: "private-b" }, ]); expect(phases).toEqual([]); diff --git a/tests/cli/cli-config-command.test.ts b/tests/cli/cli-config-command.test.ts index f52bb58c970..8d255d19a66 100644 --- a/tests/cli/cli-config-command.test.ts +++ b/tests/cli/cli-config-command.test.ts @@ -1,6 +1,6 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -210,3 +210,37 @@ test("config validate stdin exits 1 with a single JSON failure payload", () => { expect(result.stderr).toBe(""); } finally { removeTreeWithRetry(dir); } }); + +test.each(["set", "normalized set", "import"])("config %s cannot enter plaintext mode without the dedicated transition", action => { + const dir = freshConfig(); + const configPath = join(dir, "config.json"); + const importPath = join(dir, "restore.json"); + try { + const before = readFileSync(configPath, "utf8"); + const candidate = { ...JSON.parse(before), multiAgentMode: "v2-plaintext", plaintextV2AgentMessages: true }; + writeFileSync(importPath, JSON.stringify(candidate)); + const args = action === "import" ? ["import", importPath, "--yes"] + : ["set", action === "normalized set" ? " . multiAgentMode . " : "multiAgentMode", "v2-plaintext"]; + const refused = runCli(["config", ...args, "--json"], { OPENCODEX_HOME: dir }); + expect(refused.status).toBe(2); + expect(refused.stdout + refused.stderr).toContain("ocx v2 mode v2-plaintext --acknowledge-plaintext-agent-messages"); + expect(readFileSync(configPath, "utf8")).toBe(before); + + // A previously acknowledged mode remains editable/restorable without a new transition. + writeFileSync(configPath, JSON.stringify(candidate)); + const repeated = runCli(["config", ...args, "--json"], { OPENCODEX_HOME: dir }); + expect(repeated.status).toBe(0); + expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({ + multiAgentMode: "v2-plaintext", plaintextV2AgentMessages: true, + }); + expect(runCli(["config", "set", "port", "10123", "--json"], { OPENCODEX_HOME: dir }).status).toBe(0); + expect(JSON.parse(readFileSync(configPath, "utf8")).port).toBe(10123); + + // Leaving the mode never needs consent and never rewrites the independent legacy flag. + expect(runCli(["config", "unset", "multiAgentMode", "--json"], { OPENCODEX_HOME: dir }).status).toBe(0); + const exited = JSON.parse(readFileSync(configPath, "utf8")); + expect(exited.multiAgentMode).toBeUndefined(); + expect(exited.plaintextV2AgentMessages).toBe(true); + expect(runCli(["config", "set", "multiAgentMode", "v2", "--json"], { OPENCODEX_HOME: dir }).status).toBe(0); + } finally { removeTreeWithRetry(dir); } +}); diff --git a/tests/cli/cli-v2-plaintext.test.ts b/tests/cli/cli-v2-plaintext.test.ts new file mode 100644 index 00000000000..72ea7b2a4c1 --- /dev/null +++ b/tests/cli/cli-v2-plaintext.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { cmdV2, type V2CliDeps } from "../../src/cli/v2"; +import { CliUsageError } from "../../src/cli/runtime-api"; +import { parseV2Command } from "../../src/cli/v2-input"; +import { isMultiAgentV2Enabled } from "../../src/codex/features"; +import * as selectionGate from "../../src/codex/cli-update-selection-gate"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../src/codex/multi-agent-mode-policy"; +import { SUBAGENT_SURFACE_GUIDE_URL } from "../../src/config/multi-agent-surface"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const acknowledge = "--acknowledge-plaintext-agent-messages"; +let home: TempHome, configPath: string, nativePath: string, lines: string[], calls: string[]; +let log: ReturnType, error: ReturnType, network: ReturnType; +const syncResult = { status: "applied", ok: true, catalogExists: true, catalogWritten: false, + cacheSynced: false, added: 0, catalogPath: null, message: "fixture", refreshOutcome: "committed" }; +const readConfig = () => JSON.parse(readFileSync(configPath, "utf8")); +function deps(fail = false): V2CliDeps { + return { featuresInvocation: action => ({ file: "fixture-codex", args: ["features", action, "multi_agent_v2"], options: {} }), + execFile: (_file, args) => { calls.push(args[1]!); if (fail) throw new Error("fixture feature failure"); + writeFileSync(nativePath, readFileSync(nativePath, "utf8").replace(/^enabled = (true|false)$/m, `enabled = ${args[1] === "enable"}`)); }, + sync: async () => syncResult }; +} +beforeEach(() => { + home = createTempHome("ocx-cli-plaintext-v2-"); mkdirSync(home.codexHome); + process.env.OPENCODEX_HOME = home.path(".opencodex"); mkdirSync(process.env.OPENCODEX_HOME); + configPath = join(process.env.OPENCODEX_HOME, "config.json"); nativePath = join(home.codexHome, "config.toml"); + writeFileSync(configPath, JSON.stringify({ providers: {}, defaultProvider: "openai", multiAgentMode: "v2", + keepNativeChatGptOnV1: true, plaintextV2AgentMessages: false })); + writeFileSync(nativePath, "[features.multi_agent_v2]\nenabled = true\nmax_concurrent_threads_per_session = 17\n"); + lines = []; calls = []; + log = spyOn(console, "log").mockImplementation((...a: unknown[]) => { lines.push(a.join(" ")); }); + error = spyOn(console, "error").mockImplementation(() => {}); + network = spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("Network denied"); }); +}); +afterEach(() => { log.mockRestore(); error.mockRestore(); network.mockRestore(); home.remove(); }); + +describe("explicit plaintext V2 CLI", () => { + test("missing acknowledgment changes neither native nor proxy state", async () => { + const native = readFileSync(nativePath, "utf8"), config = readFileSync(configPath, "utf8"); + expect(await cmdV2(["mode", "v2-plaintext", "--json"], deps())).toBe(1); + expect(calls).toEqual([]); expect(readFileSync(nativePath, "utf8")).toBe(native); + expect(readFileSync(configPath, "utf8")).toBe(config); + }); + test("selects catalog V2 without global override and preserves independent preferences", async () => { + expect(await cmdV2(["mode", "v2-plaintext", acknowledge, "--json"], deps())).toBe(0); + expect(isMultiAgentV2Enabled()).toBe(false); + expect(readConfig()).toMatchObject({ multiAgentMode: "v2-plaintext", keepNativeChatGptOnV1: true, plaintextV2AgentMessages: false }); + expect(JSON.parse(lines.at(-1)!)).toMatchObject({ ok: true, state: { enabled: false, + multiAgentMode: "v2-plaintext", plaintextAgentMessagesEnabled: true, supportsPlaintextV2Mode: true, + maxConcurrentThreadsPerSession: 16, agentsMaxDepthAppliesWhenV2Disabled: false } }); + expect(await cmdV2(["mode", "v2-plaintext", "--json"], deps())).toBe(0); + expect(await cmdV2(["on", "--json"], deps())).toBe(1); + expect(isMultiAgentV2Enabled()).toBe(false); + expect(await cmdV2(["mode", "v1", "--json"], deps())).toBe(0); + expect(readConfig()).toMatchObject({ multiAgentMode: "v1", keepNativeChatGptOnV1: true, plaintextV2AgentMessages: false }); + expect(JSON.parse(lines.at(-1)!).state.plaintextAgentMessagesEnabled).toBe(false); + }); + test("failed native transition does not select the new mode", async () => { + const native = readFileSync(nativePath, "utf8"), config = readFileSync(configPath, "utf8"); + expect(await cmdV2(["mode", "v2-plaintext", acknowledge], deps(true))).toBe(1); + expect(readFileSync(nativePath, "utf8")).toBe(native); expect(readFileSync(configPath, "utf8")).toBe(config); + }); + test.each([false, true])("rechecks plaintext entry consent after selection admission (ack=%s)", async acknowledged => { + writeFileSync(configPath, JSON.stringify({ ...readConfig(), multiAgentMode: "v2-plaintext" })); + const realAdmission = selectionGate.withCodexCliSelectionParentWriteAsync; + let entered!: () => void, release!: () => void; + const atAdmission = new Promise(resolve => { entered = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + const admission = spyOn(selectionGate, "withCodexCliSelectionParentWriteAsync").mockImplementation(async (run, options) => { + entered(); await held; return realAdmission(run, options); + }); + const pending = cmdV2(["mode", "v2-plaintext", ...(acknowledged ? [acknowledge] : []), "--json"], deps()); + try { + await atAdmission; + writeFileSync(configPath, JSON.stringify({ ...readConfig(), multiAgentMode: "v1" })); + const saved = readFileSync(configPath, "utf8"), native = readFileSync(nativePath, "utf8"); + release(); + expect(await pending).toBe(acknowledged ? 0 : 1); + if (acknowledged) { + expect(readConfig().multiAgentMode).toBe("v2-plaintext"); + expect(isMultiAgentV2Enabled()).toBe(false); + } else { + expect(calls).toEqual([]); + expect(readFileSync(configPath, "utf8")).toBe(saved); + expect(readFileSync(nativePath, "utf8")).toBe(native); + expect(JSON.parse(lines.at(-1)!)).toMatchObject({ ok: false, changed: false }); + } + } finally { release(); await pending.catch(() => {}); admission.mockRestore(); } + }); + test("reports plaintext mode when v2 on becomes incompatible during admission", async () => { + writeFileSync(configPath, JSON.stringify({ ...readConfig(), multiAgentMode: "v1" })); + const realAdmission = selectionGate.withCodexCliSelectionParentWriteAsync; + const admission = spyOn(selectionGate, "withCodexCliSelectionParentWriteAsync").mockImplementation(async (run, options) => { + writeFileSync(configPath, JSON.stringify({ ...readConfig(), multiAgentMode: "v2-plaintext" })); + return realAdmission(run, options); + }); + try { + const messages: string[] = []; + expect(await cmdV2(["on"], { ...deps(), log: { log: () => {}, error: message => messages.push(String(message)) } })).toBe(1); + expect(calls).toEqual([]); + expect(readConfig().multiAgentMode).toBe("v2-plaintext"); + expect(messages.join(" ")).toContain("incompatible with v2-plaintext"); + } finally { admission.mockRestore(); } + }); + test.each([ + { args: ["on", acknowledge], error: "v2: plaintext acknowledgment applies only to mode v2-plaintext" }, + { args: ["mode", "v1", acknowledge], error: "v2: plaintext acknowledgment applies only to mode v2-plaintext" }, + { args: ["mode", "v2-plaintext", acknowledge, acknowledge], error: "v2: duplicate control flag" }, + { args: ["mode", "v2-plaintext", `${acknowledge}=true`], error: "v2: control flags do not take values; use -- for literal hint text" }, + ])("refuses unrelated or ambiguous consent (%#)", ({ args, error }) => { + expect(() => parseV2Command(args)).toThrow(CliUsageError); + expect(() => parseV2Command(args)).toThrow(error); + }); + test("human status explains pin-driven V2 depth with the global flag off", async () => { + writeFileSync(configPath, JSON.stringify({ ...readConfig(), multiAgentMode: "v2-plaintext" })); + writeFileSync(nativePath, "[features.multi_agent_v2]\nenabled = false\n[agents]\nmax_depth = 3\n"); + expect(await cmdV2(["status"], deps())).toBe(0); + expect(isMultiAgentV2Enabled()).toBe(false); + expect(lines.join("\n")).toContain("agents.max_depth: 3 (V1-only — ignored by V2 sessions)"); + }); + test("live request sends scoped acknowledgment and verifies effective state", async () => { + const sent: unknown[] = [], native = readFileSync(nativePath, "utf8"); + const response = { ok: true, enabled: false, agentsMaxThreadsConflict: false, multiAgentMode: "v2-plaintext", + keepNativeChatGptOnV1: true, supportsPlaintextV2Mode: true, plaintextAgentMessagesEnabled: true, + agentsMaxDepthAppliesWhenV2Disabled: false, agentsEnabled: null, agentsMaxDepth: null, maxConcurrentThreadsPerSession: 16, + subagentDeveloperInstructions: null, multiAgentModeHintText: null, multiAgentModeHintRecommendation: MULTI_AGENT_MODE_HINT_RECOMMENDATION, + multiAgentSurfaceAdvisory: { required: false, mode: "v2-plaintext", recommended: "v1", version: 1, docsUrl: SUBAGENT_SURFACE_GUIDE_URL }, + catalogRefresh: { status: "committed", changed: true, degraded: false, notices: [] } }; + const runtimeApi = { baseUrl: "http://127.0.0.1:19300", + fetchImpl: (async (_url: unknown, init?: RequestInit) => { sent.push(JSON.parse(String(init?.body))); return Response.json(response); }) as typeof fetch }; + expect(await cmdV2(["mode", "v2-plaintext", acknowledge, "--live", "--json"], { runtimeApi })).toBe(0); + expect(sent).toEqual([{ multiAgentMode: "v2-plaintext", plaintextAgentMessagesAcknowledged: true }]); + expect(readFileSync(nativePath, "utf8")).toBe(native); expect(readConfig().multiAgentMode).toBe("v2"); + response.plaintextAgentMessagesEnabled = false; + expect(await cmdV2(["mode", "v2-plaintext", acknowledge, "--live"], { runtimeApi })).toBe(1); + expect(sent).toHaveLength(2); + }); +}); diff --git a/tests/cli/cli-v2-runtime.test.ts b/tests/cli/cli-v2-runtime.test.ts index d77b17625bc..7f8423caf93 100644 --- a/tests/cli/cli-v2-runtime.test.ts +++ b/tests/cli/cli-v2-runtime.test.ts @@ -344,7 +344,7 @@ describe("root dispatch compatibility", () => { native(true); writeFileSync(path, readFileSync(path, "utf8") + '\n[agents]\nenabled = false\nmax_depth = 2\n'); expect(await cmdV2(["status"])).toBe(0); expect(out.join("\n")).toContain("agents.enabled: false"); - expect(out.join("\n")).toContain("agents.max_depth: 2 (V1-only — ignored while multi_agent_v2 is enabled)"); + expect(out.join("\n")).toContain("agents.max_depth: 2 (V1-only — ignored by V2 sessions)"); expect(out.join("\n")).toContain("subagent_developer_instructions: (unset — children inherit)"); }); }); @@ -362,11 +362,18 @@ describe("local V2 action-specific human feedback", () => { expect(out.join("\n")).toContain(`keep_native_chatgpt_on_v1 already ${enabled ? "ON" : "OFF"} — catalog re-synced.`); expect(local.syncPorts).toEqual([undefined]); }); - test("mode output names the effective hybrid surface", async () => { + test.each(["v2", "v2-plaintext"])("mode output distinguishes the effective surface from request scope: %s", async mode => { config({ keepNativeChatGptOnV1: true }); const local = localDeps(); - expect(await cmdV2(["mode", "v2"], local.deps)).toBe(0); - expect(out.join("\n")).toContain("multi_agent_mode: v2 hybrid — ChatGPT-native models use v1; routed models use v2"); - expect(out.join("\n")).toContain("Applies to NEW sessions"); + const args = ["mode", mode, ...(mode === "v2-plaintext" ? ["--acknowledge-plaintext-agent-messages"] : [])]; + expect(await cmdV2(args, local.deps)).toBe(0); + expect(out[0]).toBe(mode === "v2" + ? "multi_agent_mode: v2 hybrid — ChatGPT-native models use v1; routed models use v2" + : "multi_agent_mode: v2-plaintext — new sessions select V2; subsequent eligible canonical ChatGPT requests, including existing V2 sessions, use experimental plaintext over HTTPS; global override stays OFF"); + expect(out.join("\n")).toContain("Catalog pins apply to NEW sessions; running sessions keep their pinned multi-agent version."); + expect(out.join("\n")).toContain("Plaintext compilation follows the current setting for subsequent eligible canonical ChatGPT requests, including existing V2 sessions."); + expect(out.join("\n")).toContain("Existing ciphertext is not decrypted, stored history is not rewritten, and disabling does not delete retained copies."); + expect(JSON.parse(readFileSync(ocxConfigPath(), "utf8")).multiAgentMode).toBe(mode); + expect(isMultiAgentV2Enabled()).toBe(false); }); test.each([true, false])("changed native toggle names the switch and new-session scope %s", async enabled => { native(!enabled); const local = localDeps(); diff --git a/tests/clients/client-link-connect.test.ts b/tests/clients/client-link-connect.test.ts index 271564e0b16..c998d1af381 100644 --- a/tests/clients/client-link-connect.test.ts +++ b/tests/clients/client-link-connect.test.ts @@ -23,6 +23,7 @@ import { buildServiceShellCommand } from "../../src/service/definition"; import { inspectOrphanServiceAdmission } from "../../src/client/orphan-token-recovery"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; @@ -93,7 +94,10 @@ describe("client link connection contracts", () => { expect(chunk.every(byte => byte === 0)).toBe(true); }); - async function withLinkHome(run: (home: string, codexHome: string) => Promise): Promise { + async function withLinkHome( + run: (home: string, codexHome: string) => Promise, + removeTree: typeof removeTreeAfterBackgroundWork = removeTreeAfterBackgroundWork, + ): Promise { const home = mkdtempSync(join(tmpdir(), "ocx-client-link-contract-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-link-contract-codex-")); const previousHome = process.env.OPENCODEX_HOME; @@ -107,12 +111,14 @@ describe("client link connection contracts", () => { setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); setIcaclsRunnerForTests(aclSuccess); setAsyncIcaclsRunnerForTests(async () => aclSuccess()); + let runSucceeded = false; try { const config = getDefaultConfig(); config.port = 10100; saveConfig(config); await run(home, codexHome); if (process.platform === "win32") expect(aclCalls).toBeGreaterThan(0); + runSucceeded = true; } finally { setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); @@ -121,11 +127,42 @@ describe("client link connection contracts", () => { else process.env.OPENCODEX_HOME = previousHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; - rmSync(home, { recursive: true, force: true }); - rmSync(codexHome, { recursive: true, force: true }); + const cleanupErrors: unknown[] = []; + for (const path of [home, codexHome]) { + try { await removeTree(path); } catch (error) { cleanupErrors.push(error); } + } + // Keep the original body/assertion error when cleanup also fails. + if (runSucceeded && cleanupErrors.length > 0) throw cleanupErrors[0]; } } + for (const runFails of [false, true]) { + test(`link fixture cleanup attempts both removals after body ${runFails ? "failure" : "success"}`, async () => { + const previousHome = process.env.OPENCODEX_HOME, previousCodexHome = process.env.CODEX_HOME; + const bodyFailure = new Error("synthetic fixture body failure"); + const cleanupFailure = new Error("synthetic first removal failure"); + let homes: string[] = []; + const attempted: string[] = []; + try { + await expect(withLinkHome(async (home, codexHome) => { + homes = [home, codexHome]; + if (runFails) throw bodyFailure; + }, async path => { + attempted.push(path); + if (path === homes[0]) throw cleanupFailure; + await removeTreeAfterBackgroundWork(path); + })).rejects.toBe(runFails ? bodyFailure : cleanupFailure); + expect(attempted).toEqual(homes); + expect(process.env.OPENCODEX_HOME).toBe(previousHome); + expect(process.env.CODEX_HOME).toBe(previousCodexHome); + expect(existsSync(homes[0]!)).toBe(true); + expect(existsSync(homes[1]!)).toBe(false); + } finally { + for (const path of homes) if (existsSync(path)) await removeTreeAfterBackgroundWork(path); + } + }); + } + function linkOptions() { return { serverUrl: "http://127.0.0.1:34567", @@ -778,16 +815,7 @@ describe("client link connection contracts", () => { }); test("connects through the link credential strategy without issuing a hub key", async () => { - const home = mkdtempSync(join(tmpdir(), "ocx-client-link-connect-")); - const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-link-codex-")); - const previousHome = process.env.OPENCODEX_HOME; - const previousCodexHome = process.env.CODEX_HOME; - process.env.OPENCODEX_HOME = home; - process.env.CODEX_HOME = codexHome; - try { - const config = getDefaultConfig(); - config.port = 10100; - saveConfig(config); + await withLinkHome(async (home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), 'model_provider = "openai"\n'); const calls: Array<{ url: string; key?: string }> = []; const fetchImpl: typeof fetch = async (input, init = {}) => { @@ -829,13 +857,6 @@ describe("client link connection contracts", () => { expect(calls[0]?.key).toBe(key); expect(calls[1]?.key).toBe(key); expect(calls.some(call => call.url.includes("/api/keys"))).toBe(false); - } finally { - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; - rmSync(home, { recursive: true, force: true }); - rmSync(codexHome, { recursive: true, force: true }); - } + }); }); }); diff --git a/tests/clients/link-home-settings.test.ts b/tests/clients/link-home-settings.test.ts index 9f17204c900..807267783f9 100644 --- a/tests/clients/link-home-settings.test.ts +++ b/tests/clients/link-home-settings.test.ts @@ -79,6 +79,15 @@ async function applied(): Promise { } describe("Home Codex settings document", () => { + test("preserves the explicit plaintext V2 policy through projection, parse and overlay", () => { + const projected = projectHomeCodexSettings({ providers: {}, multiAgentMode: "v2-plaintext" } as OcxConfig, "fixture"); + const parsed = parseHomeCodexSettingsDocument(projected); + expect(parsed?.settings.multiAgentMode).toBe("v2-plaintext"); + const local = { providers: {}, multiAgentMode: "v1", plaintextV2AgentMessages: false } as OcxConfig; + expect(overlayHomeCodexSettings(local, parsed!.settings)).toMatchObject({ multiAgentMode: "v2-plaintext", plaintextV2AgentMessages: false }); + expect(local.multiAgentMode).toBe("v1"); + }); + test("projects only what the Child's injector writes, and only an effective sub-agent sync", () => { const projected = projectHomeCodexSettings({ port: 1, providers: {}, injectionModel: "x/y", syncCodexSubagentDefaults: false } as unknown as OcxConfig, "2.84.0"); expect(projected.settings).toEqual({ fastMode: null, multiAgentMode: null, webSearchSidecarEnabled: true, subagentDefaults: null }); diff --git a/tests/codex-integration/codex-inject-v1-reconcile.test.ts b/tests/codex-integration/codex-inject-v1-reconcile.test.ts index 27f5b59f63c..ead02e512a4 100644 --- a/tests/codex-integration/codex-inject-v1-reconcile.test.ts +++ b/tests/codex-integration/codex-inject-v1-reconcile.test.ts @@ -245,6 +245,36 @@ describe("injectCodexConfig v1-surface reconcile", () => { expect(config).toContain("openai_base_url = "); }); + test("a v2-plaintext injection also disables the global v2 override so catalog pins decide the surface", () => { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { injectCodexConfig } = require("./src/codex/inject"); + const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); + const path = join(process.env.CODEX_HOME, "config.toml"); + setCodexMultiAgentV2ToggleForTests(enabled => { + const current = fs.readFileSync(path, "utf8"); + fs.writeFileSync(path, current.replace("multi_agent_v2 = true", "multi_agent_v2 = " + enabled)); + }); + const result = await injectCodexConfig(10100, { multiAgentMode: "v2-plaintext" }); + console.log(JSON.stringify(result)); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + + expect(child.status, child.stderr).toBe(0); + expect(JSON.parse(child.stdout)).toMatchObject({ success: true }); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("multi_agent_v2 = false"); + expect(config).toContain("openai_base_url = "); + }); + test("the published witness identifies the plan committed after v1 reconciliation", () => { const configPath = join(codexHome, "config.toml"); writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 2afe52fe888..1fd2d235a08 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -1633,7 +1633,7 @@ describe("cli surface", () => { } const out = logs.join("\n"); expect(out).toContain("agents.enabled: false"); - expect(out).toContain("agents.max_depth: 2 (V1-only — ignored while multi_agent_v2 is enabled)"); + expect(out).toContain("agents.max_depth: 2 (V1-only — ignored by V2 sessions)"); expect(out).toContain("subagent_developer_instructions: (unset — children inherit)"); expect(out).toContain("multi_agent_mode_hint_text: (unset — effort-derived policy: ultra=proactive, else explicit)"); }); diff --git a/tests/codex-integration/issue-702-expired-replay-state.test.ts b/tests/codex-integration/issue-702-expired-replay-state.test.ts index a682fa6db88..242c93fa83e 100644 --- a/tests/codex-integration/issue-702-expired-replay-state.test.ts +++ b/tests/codex-integration/issue-702-expired-replay-state.test.ts @@ -11,7 +11,6 @@ import { clearResponseStateForTests, clearResponseStateMemoryForTests, flushPendingResponseSpillsForTests, - rememberResponseState, responseStateMetrics, RESPONSE_TTL_MS, setResponseStateByteCapForTests, @@ -24,6 +23,8 @@ import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { rememberOwnedResponseState as rememberResponseState, responseReplayFixtureApiKeys, + responseReplayFixtureHeaders } from "../helpers/response-replay-owner"; const originalFetch = globalThis.fetch; const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -70,6 +71,7 @@ function forwardConfig(): OcxConfig { return { port: 0, hostname: "127.0.0.1", + apiKeys: responseReplayFixtureApiKeys, defaultProvider: "openai", openaiProviderTierVersion: 2, providers: { @@ -209,6 +211,7 @@ async function runForwardScenario( server = startServer(0); const token = fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" }); const requestHeaders = { + ...(mode === "ordinary" ? {} : responseReplayFixtureHeaders()), "content-type": "application/json", authorization: `Bearer ${token}`, "chatgpt-account-id": "acct-issue-702", @@ -353,7 +356,7 @@ describe("routed replay recovery", () => { Date.now = realNow; } saveConfig({ - port: 0, hostname: "127.0.0.1", websockets: true, defaultProvider: "routed-test", + port: 0, hostname: "127.0.0.1", apiKeys: responseReplayFixtureApiKeys, websockets: true, defaultProvider: "routed-test", providers: { "routed-test": { adapter: "adapter" in scenario ? scenario.adapter : "openai-responses", @@ -370,13 +373,13 @@ describe("routed replay recovery", () => { input: [toolResult], tools, store: false, }; const httpRejected = await originalFetch(new URL("/v1/responses", server.url), { - method: "POST", headers: { "content-type": "application/json" }, + method: "POST", headers: { "content-type": "application/json", ...responseReplayFixtureHeaders() }, body: JSON.stringify(deltaRequest), }); expect(httpRejected.status).toBe(400); expect(await httpRejected.json()).toMatchObject({ error: { code: "previous_response_not_found" } }); expect(upstreamRequests).toHaveLength(0); - socket = await openResponseSocket(server.url, {}); + socket = await openResponseSocket(server.url, responseReplayFixtureHeaders()); const rejected = await sendSocketTurn(socket, deltaRequest); expect(rejected).toMatchObject({ type: "error", status: 400, @@ -385,7 +388,7 @@ describe("routed replay recovery", () => { expect(upstreamRequests).toHaveLength(0); socket.close(); - socket = await openResponseSocket(server.url, {}); + socket = await openResponseSocket(server.url, responseReplayFixtureHeaders()); const recovered = await sendSocketTurn(socket, { model: "routed-test/test-model", input: [...history, toolResult], tools, store: false, }); @@ -465,6 +468,7 @@ describe("routed replay recovery", () => { saveConfig({ port: 0, hostname: "127.0.0.1", + apiKeys: responseReplayFixtureApiKeys, defaultProvider: "chat-test", providers: { "chat-test": { @@ -482,7 +486,7 @@ describe("routed replay recovery", () => { const refused = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...responseReplayFixtureHeaders() }, body: JSON.stringify({ model: "chat-test/test-model", previous_response_id: FIRST_RESPONSE_ID, @@ -499,7 +503,7 @@ describe("routed replay recovery", () => { // What the client does next: resend the whole conversation without the id. const recovered = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...responseReplayFixtureHeaders() }, body: JSON.stringify({ model: "chat-test/test-model", input: [inputMessage(HISTORICAL_USER_SENTINEL), inputMessage(CURRENT_USER_SENTINEL)], @@ -545,6 +549,7 @@ describe("routed replay recovery", () => { saveConfig({ port: 0, hostname: "127.0.0.1", + apiKeys: responseReplayFixtureApiKeys, defaultProvider: "wire-test", providers: { "wire-test": { @@ -560,7 +565,7 @@ describe("routed replay recovery", () => { server = startServer(0); const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...responseReplayFixtureHeaders() }, body: JSON.stringify({ model: "wire-test/test-model", previous_response_id: FIRST_RESPONSE_ID, @@ -612,6 +617,7 @@ describe("Issue #702 expired forward replay state", () => { config: { port: 0, hostname: "127.0.0.1", + apiKeys: responseReplayFixtureApiKeys, openaiProviderTierVersion: 2, defaultProvider: "kiro-test", providers: { @@ -630,6 +636,7 @@ describe("Issue #702 expired forward replay state", () => { config: { port: 0, hostname: "127.0.0.1", + apiKeys: responseReplayFixtureApiKeys, openaiProviderTierVersion: 2, defaultProvider: "test-openai", providers: { @@ -653,7 +660,7 @@ describe("Issue #702 expired forward replay state", () => { try { const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...responseReplayFixtureHeaders() }, body: JSON.stringify({ model: routeClass.model, previous_response_id: responseId, @@ -735,6 +742,7 @@ describe("Issue #702 expired forward replay state", () => { saveConfig({ ...forwardConfig(), websockets: true }); server = startServer(0); const headers = { + ...responseReplayFixtureHeaders(), authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" })}`, "chatgpt-account-id": "acct-issue-702", }; @@ -796,19 +804,19 @@ describe("Issue #702 expired forward replay state", () => { expect(scenario.secondStatus).toBe(400); expect(JSON.parse(scenario.secondResponseText)).toEqual({ error: LOCAL_REPLAY_UNAVAILABLE }); expect(scenario.upstreamRequests).toHaveLength(1); - }); + }, SERVER_BUDGET_MS); test("missing, corrupt and mismatched local state answer identically on one route", async () => { // All three mean the same thing to a client: replay in full. Each is produced for real here // rather than inferred from the source strings - one spill deleted, one overwritten with // bytes that do not parse, and one entry retained under a different task. setResponseStateByteCapForTests(1_024); - const stored = async (id: string, threadId?: string): Promise => { + const stored = async (id: string, threadId = "task-a"): Promise => { rememberResponseState( { model: "gpt-5.5", input: "x".repeat(8_000), store: false }, { id, status: "completed", output: [{ role: "assistant", content: "done" }] }, undefined, - { force: true, ...(threadId ? { clientThreadId: threadId } : {}) }, + { force: true, clientThreadId: threadId }, ); await flushPendingResponseSpillsForTests(); const spillDir = responseSpillDirectory(testHome); @@ -838,7 +846,7 @@ describe("Issue #702 expired forward replay state", () => { "content-type": "application/json", authorization: `Bearer ${token}`, "chatgpt-account-id": "acct-issue-702", - "x-codex-parent-thread-id": threadId, + ...responseReplayFixtureHeaders(threadId), }, body: JSON.stringify({ model: "gpt-5.5", @@ -893,7 +901,7 @@ describe("Issue #702 expired forward replay state", () => { { force: true, clientThreadId: "task-a" }, ); saveConfig({ - port: 0, hostname: "127.0.0.1", websockets: true, defaultProvider: "routed-test", + port: 0, hostname: "127.0.0.1", apiKeys: responseReplayFixtureApiKeys, websockets: true, defaultProvider: "routed-test", providers: { "routed-test": { adapter: "openai-responses", baseUrl: upstream.url.toString(), allowPrivateNetwork: true, @@ -902,7 +910,7 @@ describe("Issue #702 expired forward replay state", () => { }, } as OcxConfig); server = startServer(0); - socket = await openResponseSocket(server.url, { "x-codex-parent-thread-id": "task-b" }); + socket = await openResponseSocket(server.url, responseReplayFixtureHeaders("task-b")); const refused = await sendSocketTurn(socket, { model: "routed-test/test-model", previous_response_id: FIRST_RESPONSE_ID, input: [inputMessage(CURRENT_USER_SENTINEL)], store: false, @@ -959,6 +967,7 @@ describe("Issue #702 expired forward replay state", () => { saveConfig({ port: 0, hostname: "127.0.0.1", + apiKeys: responseReplayFixtureApiKeys, defaultProvider: "test-openai", providers: { "test-openai": { @@ -982,7 +991,7 @@ describe("Issue #702 expired forward replay state", () => { new URL("/v1/responses", server!.url), { method: "POST", - headers: { "content-type": "application/json", "x-codex-parent-thread-id": threadId }, + headers: { "content-type": "application/json", ...responseReplayFixtureHeaders(threadId) }, body: JSON.stringify({ model: "test-openai/gpt-5.5", previous_response_id: previousResponseId, @@ -1028,6 +1037,7 @@ describe("Issue #702 expired forward replay state", () => { saveConfig({ port: 0, hostname: "127.0.0.1", + apiKeys: responseReplayFixtureApiKeys, defaultProvider: "test-openai", providers: { "test-openai": { @@ -1044,7 +1054,7 @@ describe("Issue #702 expired forward replay state", () => { const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...responseReplayFixtureHeaders() }, body: JSON.stringify({ model: "test-openai/gpt-5.5", previous_response_id: "resp_upstream_native_state", diff --git a/tests/codex-integration/multi-agent-v2-plaintext.test.ts b/tests/codex-integration/multi-agent-v2-plaintext.test.ts new file mode 100644 index 00000000000..758d8c5253b --- /dev/null +++ b/tests/codex-integration/multi-agent-v2-plaintext.test.ts @@ -0,0 +1,296 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { applyMultiAgentMode, MULTI_AGENT_ORIGIN_FIELD, type RawEntry } from "../../src/codex/catalog/parsing"; +import { isMultiAgentV2Enabled } from "../../src/codex/features"; +import { loadConfig, saveConfig } from "../../src/config"; +import { + isPlaintextV2AgentMessagesEnabled, + resolveMultiAgentMode, +} from "../../src/config/multi-agent-surface"; +import { warnPlaintextV2AgentMessagesStartup } from "../../src/server/index/startup-warnings"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { OcxConfig } from "../../src/types"; + +let home: TempHome | undefined; + +afterEach(() => { + home?.remove(); + home = undefined; +}); + +function isolateHomes(): void { + home = createTempHome("ocx-v2-plaintext-"); + mkdirSync(home.codexHome, { recursive: true }); +} + +function getV2(): Request { + return new Request("http://localhost/api/v2", { headers: { Host: "localhost" } }); +} + +function putV2(body: unknown): Request { + return new Request("http://localhost/api/v2", { + method: "PUT", + headers: { "content-type": "application/json", Host: "localhost" }, + body: JSON.stringify(body), + }); +} + +function liveConfig(extra: Partial = {}): OcxConfig { + return { providers: {}, hostname: "127.0.0.1", port: 10100, defaultProvider: "openai", ...extra } as OcxConfig; +} + +function writeCodexFlag(enabled: boolean): string { + const codexConfig = join(process.env.CODEX_HOME!, "config.toml"); + writeFileSync(codexConfig, `[features.multi_agent_v2]\nenabled = ${enabled}\n`); + return codexConfig; +} + +describe("v2-plaintext catalog pin", () => { + test("stamps every row v2, ChatGPT-native rows included, and records origins", () => { + const entries: RawEntry[] = [ + { slug: "gpt-5.6-sol" }, + { slug: "gpt-5.6-terra" }, + { slug: "xai/grok-4.6" }, + { slug: "anthropic/claude-fable-5" }, + ]; + applyMultiAgentMode(entries, "v2-plaintext"); + for (const entry of entries) { + expect(entry.multi_agent_version).toBe("v2"); + expect(entry.multi_agent_version).not.toBe("v2-plaintext"); + expect(Object.hasOwn(entry, MULTI_AGENT_ORIGIN_FIELD)).toBe(true); + } + }); + + test("a v2-plaintext -> default round trip uses the same recorded-origin contract as other forced modes", () => { + // Native row absent from the baseline restores its recorded origin; a routed row + // keeps default-mode normalization (same contract multi-agent-origin.test.ts asserts). + const baseline = new Map([["gpt-5.4", "v2"]]); + const entries: RawEntry[] = [ + { slug: "gpt-6-luna", multi_agent_version: "v1" }, + { slug: "xai/grok-4.7", multi_agent_version: "v1" }, + ]; + applyMultiAgentMode(entries, "v2-plaintext"); + expect(entries[0]!.multi_agent_version).toBe("v2"); + expect(entries[1]!.multi_agent_version).toBe("v2"); + applyMultiAgentMode(entries, "default", false, { nativeDefaults: baseline }); + expect(entries[0]!.multi_agent_version).toBe("v1"); + expect(entries[0]).not.toHaveProperty(MULTI_AGENT_ORIGIN_FIELD); + expect(entries[1]).not.toHaveProperty("multi_agent_version"); + expect(entries[1]).not.toHaveProperty(MULTI_AGENT_ORIGIN_FIELD); + }); + + test("keepNativeChatGptOnV1 does not split the v2-plaintext pass", () => { + const entries: RawEntry[] = [{ slug: "gpt-5.6-sol" }]; + applyMultiAgentMode(entries, "v2-plaintext", false, { keepNativeChatGptOnV1: true }); + expect(entries[0]!.multi_agent_version).toBe("v2"); + }); +}); + +describe("isPlaintextV2AgentMessagesEnabled", () => { + test("the mode alone is an opt-in; the flag stays an independent opt-in", () => { + expect(resolveMultiAgentMode({ multiAgentMode: "v2-plaintext" })).toBe("v2-plaintext"); + expect(resolveMultiAgentMode({ multiAgentMode: "bogus" as never })).toBe("default"); + + expect(isPlaintextV2AgentMessagesEnabled({ multiAgentMode: "v2-plaintext" })).toBe(true); + expect(isPlaintextV2AgentMessagesEnabled({ multiAgentMode: "v2-plaintext", plaintextV2AgentMessages: false })).toBe(true); + expect(isPlaintextV2AgentMessagesEnabled({ plaintextV2AgentMessages: true })).toBe(true); + expect(isPlaintextV2AgentMessagesEnabled({ multiAgentMode: "v2", plaintextV2AgentMessages: true })).toBe(true); + expect(isPlaintextV2AgentMessagesEnabled({ multiAgentMode: "v2" })).toBe(false); + expect(isPlaintextV2AgentMessagesEnabled({})).toBe(false); + }); +}); + +describe("warnPlaintextV2AgentMessagesStartup", () => { + test("the mode alone activates the same warning as the flag", () => { + const warnings: string[] = []; + const spy = console.warn; + console.warn = (m?: unknown) => { warnings.push(String(m)); }; + try { + warnPlaintextV2AgentMessagesStartup({}); + warnPlaintextV2AgentMessagesStartup({ plaintextV2AgentMessages: false }); + warnPlaintextV2AgentMessagesStartup({ multiAgentMode: "v2" }); + expect(warnings).toHaveLength(0); + warnPlaintextV2AgentMessagesStartup({ multiAgentMode: "v2-plaintext" }); + warnPlaintextV2AgentMessagesStartup({ plaintextV2AgentMessages: true }); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.join("\n")).toContain("plaintext V2 agent messages"); + } finally { + console.warn = spy; + } + }); +}); + +describe("/api/v2 v2-plaintext", () => { + const url = () => new URL("http://localhost/api/v2"); + + test("entering the mode requires the per-request acknowledgement and writes nothing without it", async () => { + isolateHomes(); + const codexConfig = writeCodexFlag(true); + const config = liveConfig({ multiAgentMode: "v2" }); + const d = { + createManagementConvergeCodex: catalogConvergenceFactory(() => { throw new Error("must not converge"); }), + toggleCodexMultiAgentV2: () => { throw new Error("must not toggle"); }, + }; + + const refused = await handleManagementAPI(putV2({ multiAgentMode: "v2-plaintext" }), url(), config, d); + expect(refused?.status).toBe(400); + expect(await refused?.json()).toEqual({ + error: "body.multiAgentMode 'v2-plaintext' requires plaintextAgentMessagesAcknowledged === true: native v2 collaboration messages are delivered as plaintext on the canonical ChatGPT route", + }); + expect(config.multiAgentMode).toBe("v2"); + expect(isMultiAgentV2Enabled(codexConfig)).toBe(true); + expect(loadConfig().multiAgentMode).not.toBe("v2-plaintext"); + }); + + test("ack enters the mode: flag disabled, pin-driven v2 saved, response advertises the plaintext contract", async () => { + isolateHomes(); + const codexConfig = writeCodexFlag(true); + const config = liveConfig(); + let toggled: boolean | undefined; + const d = { + createManagementConvergeCodex: catalogConvergenceFactory(), + toggleCodexMultiAgentV2: (enabled: boolean) => { + toggled = enabled; + writeFileSync(codexConfig, readFileSync(codexConfig, "utf8").replace("enabled = true", "enabled = false")); + }, + }; + + const entered = await handleManagementAPI( + putV2({ multiAgentMode: "v2-plaintext", plaintextAgentMessagesAcknowledged: true }), + url(), config, d, + ); + expect(entered?.status).toBe(200); + const body = await entered?.json() as Record; + expect(toggled).toBe(false); + expect(isMultiAgentV2Enabled(codexConfig)).toBe(false); + expect(loadConfig().multiAgentMode).toBe("v2-plaintext"); + expect(body).toMatchObject({ + enabled: false, + multiAgentMode: "v2-plaintext", + plaintextAgentMessagesEnabled: true, + supportsPlaintextV2Mode: true, + agentsMaxDepthAppliesWhenV2Disabled: false, + }); + + const get = await handleManagementAPI(getV2(), url(), config, d); + expect(await get?.json()).toMatchObject({ + multiAgentMode: "v2-plaintext", + plaintextAgentMessagesEnabled: true, + supportsPlaintextV2Mode: true, + enabled: false, + agentsMaxDepthAppliesWhenV2Disabled: false, + }); + }); + + test("a non-boolean acknowledgement is rejected before anything is written", async () => { + isolateHomes(); + writeCodexFlag(false); + const config = liveConfig(); + const bad = await handleManagementAPI( + putV2({ multiAgentMode: "v2-plaintext", plaintextAgentMessagesAcknowledged: "yes" }), + url(), config, + { createManagementConvergeCodex: catalogConvergenceFactory() }, + ); + expect(bad?.status).toBe(400); + expect(await bad?.json()).toEqual({ error: "body.plaintextAgentMessagesAcknowledged must be a boolean" }); + expect(config.multiAgentMode).toBeUndefined(); + }); + + test("enabled=true conflicts with the pin-driven mode, in the same request and against stored state", async () => { + isolateHomes(); + writeCodexFlag(false); + const config = liveConfig(); + const d = { + createManagementConvergeCodex: catalogConvergenceFactory(), + toggleCodexMultiAgentV2: () => { throw new Error("must not toggle"); }, + }; + + const combo = await handleManagementAPI( + putV2({ enabled: true, multiAgentMode: "v2-plaintext", plaintextAgentMessagesAcknowledged: true }), + url(), config, d, + ); + expect(combo?.status).toBe(400); + expect((await combo?.json() as { error: string }).error) + .toContain("conflicts with multiAgentMode 'v2-plaintext'"); + + config.multiAgentMode = "v2-plaintext"; + const stored = await handleManagementAPI(putV2({ enabled: true }), url(), config, d); + expect(stored?.status).toBe(400); + expect((await stored?.json() as { error: string }).error) + .toContain("conflicts with multiAgentMode 'v2-plaintext'"); + }); + + test("re-saving the same mode is idempotent and needs no repeated consent", async () => { + isolateHomes(); + writeCodexFlag(false); + const config = liveConfig({ multiAgentMode: "v2-plaintext" }); + const d = { + createManagementConvergeCodex: catalogConvergenceFactory(), + toggleCodexMultiAgentV2: () => {}, + }; + const again = await handleManagementAPI(putV2({ multiAgentMode: "v2-plaintext" }), url(), config, d); + expect(again?.status).toBe(200); + expect(await again?.json()).toMatchObject({ multiAgentMode: "v2-plaintext" }); + expect(loadConfig().multiAgentMode).toBe("v2-plaintext"); + }); + + test("a stored keepNativeChatGptOnV1 stays stored but inactive under v2-plaintext", async () => { + isolateHomes(); + writeCodexFlag(false); + const config = liveConfig({ multiAgentMode: "v2", keepNativeChatGptOnV1: true }); + const d = { + createManagementConvergeCodex: catalogConvergenceFactory(), + toggleCodexMultiAgentV2: () => {}, + }; + const entered = await handleManagementAPI( + putV2({ multiAgentMode: "v2-plaintext", plaintextAgentMessagesAcknowledged: true }), + url(), config, d, + ); + expect(entered?.status).toBe(200); + const body = await entered?.json() as { keepNativeChatGptOnV1: boolean; warnings: string[] }; + expect(body.keepNativeChatGptOnV1).toBe(true); + expect(body.warnings.join("\n")).toContain("stored but inactive under 'v2-plaintext'"); + expect(loadConfig().keepNativeChatGptOnV1).toBe(true); + }); + + test("a failed feature transition returns 502 and does not save the mode", async () => { + isolateHomes(); + writeCodexFlag(true); + saveConfig({ ...loadConfig(), multiAgentMode: "v2" }); + const config = loadConfig(); + const before = readFileSync(home!.path("config.json"), "utf8"); + const d = { + createManagementConvergeCodex: catalogConvergenceFactory(() => { throw new Error("must not converge"); }), + toggleCodexMultiAgentV2: () => { throw new Error("toggle failed"); }, + }; + const failed = await handleManagementAPI( + putV2({ multiAgentMode: "v2-plaintext", plaintextAgentMessagesAcknowledged: true }), + url(), config, d, + ); + expect(failed?.status).toBe(502); + expect(config.multiAgentMode).toBe("v2"); + expect(readFileSync(home!.path("config.json"), "utf8")).toBe(before); + }); + + test("leaving the mode preserves the standalone plaintext flag untouched", async () => { + isolateHomes(); + writeCodexFlag(false); + saveConfig({ ...loadConfig(), multiAgentMode: "v2-plaintext", plaintextV2AgentMessages: true }); + const config = liveConfig({ multiAgentMode: "v2-plaintext", plaintextV2AgentMessages: true }); + const d = { + createManagementConvergeCodex: catalogConvergenceFactory(), + toggleCodexMultiAgentV2: () => {}, + }; + const left = await handleManagementAPI(putV2({ multiAgentMode: "v1" }), url(), config, d); + expect(left?.status).toBe(200); + const body = await left?.json() as Record; + expect(loadConfig().multiAgentMode).toBe("v1"); + expect(loadConfig().plaintextV2AgentMessages).toBe(true); + // The flag is an independent opt-in: it still reports enabled after the mode leaves. + expect(body.plaintextAgentMessagesEnabled).toBe(true); + expect(body.agentsMaxDepthAppliesWhenV2Disabled).toBe(true); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46c84e1973e..b41179e02d2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -54,6 +54,7 @@ "management-export-model-names.test.ts": "server", "native-source-priority.test.ts": "codex-integration", "server-native-catalog-priority.test.ts": "server", + "codex-model-list-plaintext.test.ts": "server", "server-combo-quota-code-provenance.test.ts": "server", "claude-intercept-acl-barrier.test.ts": "claude-integration", "claude-config-root-writer-barrier.test.ts": "claude-integration", @@ -95,6 +96,9 @@ "codex-cli-update-worker.test.ts": "codex-integration", "codex-cli-update-parent-async.test.ts": "codex-integration", "responses-replay-metadata-principal.test.ts": "responses", + "response-replay-ownership.test.ts": "server", + "response-replay-combo-identity.test.ts": "server", + "responses-replay-owner-storage.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", "responses-translated-replay-principal.test.ts": "responses", "loop-terminal-refusal.test.ts": "images", @@ -195,7 +199,7 @@ "cli-observe-snapshot.test.ts": "cli", "cli-access-data-plane.test.ts": "cli", "cli-access-key-guard.test.ts": "cli", "cli-access-rename.test.ts": "cli", "cli-access-audio.test.ts": "cli", "cli-access-audio-live.test.ts": "cli", "cli-log-follow.test.ts": "cli", "cli-injection-follow.test.ts": "cli", "cli-usage-scope.test.ts": "cli", "cli-system-health.test.ts": "cli", "cli-companion-timeline.test.ts": "cli", "server-cli-key-probe-admission.test.ts": "server", "cli-integration-preview.test.ts": "cli", "cli-integration-droid.test.ts": "cli", "cli-integration-journal.test.ts": "cli", "cli-aside-sync.test.ts": "cli", "cli-claude-desktop-profile.test.ts": "cli", "cli-integration-cursor.test.ts": "cli", "cli-remote-workspace-hub.test.ts": "cli", "cli-storage-policy-fields.test.ts": "cli", "cli-link-force.test.ts": "cli", "cli-link-cleanup.test.ts": "cli", - "cli-account-policy.test.ts": "cli", "cli-account-login-options.test.ts": "cli", "cli-logout-runtime.test.ts": "cli", "cli-agent-settings.test.ts": "cli", "cli-v2-runtime.test.ts": "cli", "cli-agent-runtime-settings.test.ts": "cli", "cli-system-settings-parity.test.ts": "cli", + "cli-account-policy.test.ts": "cli", "cli-account-login-options.test.ts": "cli", "cli-logout-runtime.test.ts": "cli", "cli-agent-settings.test.ts": "cli", "cli-v2-runtime.test.ts": "cli", "cli-v2-plaintext.test.ts": "cli", "multi-agent-v2-plaintext.test.ts": "codex-integration", "cli-agent-runtime-settings.test.ts": "cli", "cli-system-settings-parity.test.ts": "cli", "cli-catalog-command-result.test.ts": "cli", "cli-models-local-result.test.ts": "cli", "cli-models-custom-runtime.test.ts": "cli", diff --git a/tests/helpers/compaction-routing-fixtures.ts b/tests/helpers/compaction-routing-fixtures.ts index 616b9350dc2..11828e9d931 100644 --- a/tests/helpers/compaction-routing-fixtures.ts +++ b/tests/helpers/compaction-routing-fixtures.ts @@ -6,6 +6,14 @@ import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { removeTreeAfterBackgroundWork } from "./remove-tree"; +/** HTTP compaction fixtures bind the physical send; global fetch alone cannot isolate WS/proxy paths. */ +export function installCompactionHttpMock(config: OcxConfig, mock: typeof fetch): void { + globalThis.fetch = mock; + for (const provider of Object.values(config.providers)) { + Object.assign(provider, { fetch: mock, upstreamWebsocket: false }); + } +} + /** * Config, request and upstream-response fixtures for the compaction-routing suite. * diff --git a/tests/helpers/native-injection-fixture.ts b/tests/helpers/native-injection-fixture.ts index cec970af75e..e329f9050cd 100644 --- a/tests/helpers/native-injection-fixture.ts +++ b/tests/helpers/native-injection-fixture.ts @@ -4,6 +4,7 @@ import type { OcxConfig } from "../../src/types"; import { createWebsocketHandler } from "../../src/server/index/websocket-handler"; import type { ServeOptionsContext } from "../../src/server/index/serve-options"; import type { WsData } from "../../src/server/ws-bridge"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; import { clearRequestLogsForTests } from "../../src/server/request-log"; import { runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; import { acquireOwnedSpendHome } from "./owned-spend-home"; @@ -68,13 +69,15 @@ export const waitForInjection = async (condition: () => boolean) => { export function prepareInjectionSpendHome(): void { releaseSpendHome ??= acquireOwnedSpendHome(); } -export function injectionClient(fields: Frame = {}, settings = injectionConfig(), credential = "test", requestHeaders?: HeadersInit) { +type InjectionClientOptions = { requestHeaders?: HeadersInit; admission?: DataPlaneAdmission }; +export function injectionClient(fields: Frame = {}, settings = injectionConfig(), credential = "test", options: InjectionClientOptions = {}) { prepareInjectionSpendHome(); + const { requestHeaders, admission } = options; const handler = createWebsocketHandler({ config: settings, deps: {} } as ServeOptionsContext); const sent: Frame[] = []; const headers = new Headers({ authorization: `Bearer ${credential}`, "thread-id": `injection-fixture-${++nextId}`, "openai-beta": "fixture_beta=v1" }); new Headers(requestHeaders).forEach((value, name) => headers.set(name, value)); - const ws = { readyState: 1, data: { headers } as WsData, + const ws = { readyState: 1, data: { headers, ...(admission ? { admission } : {}) } as WsData, send: (text: string) => { sent.push(JSON.parse(text)); return 1; }, close() { handler.close(ws, 1000, "fixture close"); }, } as unknown as ServerWebSocket; const send = (frame: Frame) => handler.message(ws, JSON.stringify(frame)); @@ -83,8 +86,8 @@ export function injectionClient(fields: Frame = {}, settings = injectionConfig() tools: [{ type: "function", name: "get_value", parameters: { type: "object", properties: {} } }], ...fields }); return { ws, sent, send, handler }; } -export async function beginInjection(fields: Frame = {}, settings = injectionConfig(), credential = "test", requestHeaders?: HeadersInit) { - const client = injectionClient(fields, settings, credential, requestHeaders); +export async function beginInjection(fields: Frame = {}, settings = injectionConfig(), credential = "test", options: InjectionClientOptions = {}) { + const client = injectionClient(fields, settings, credential, options); await waitForInjection(() => client.sent.some(frame => frame.type === "response.created")); const socket = InjectionSocket.all.at(-1)!; expect(socket).toBeDefined(); diff --git a/tests/helpers/response-replay-owner.ts b/tests/helpers/response-replay-owner.ts new file mode 100644 index 00000000000..d77c3e8c8fe --- /dev/null +++ b/tests/helpers/response-replay-owner.ts @@ -0,0 +1,35 @@ +import { handleResponses } from "../../src/server/responses"; +import { expandPreviousResponseInput, rememberResponseState } from "../../src/responses/state"; +import { responseReplayOwnerKey } from "../../src/responses/state/replay-owner"; +import { contextPrincipalIdOf, resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; + +const key = "synthetic-response-replay-owner"; +const thread = "synthetic-response-replay-thread"; +const apiKeys = [{ id: "replay-fixture", key }]; +export const responseReplayFixtureApiKeys = apiKeys; +export function responseReplayFixtureHeaders(ownThread = thread) { + return { "x-opencodex-api-key": key, "thread-id": ownThread }; +} + +/** Direct-handler fixtures take the same configured admission that ingress would resolve. */ +export function handleOwnedResponses(...[req, config, log, options]: Parameters) { + const admittedConfig = { ...config, apiKeys }; + req.headers.set("x-opencodex-api-key", key); + if (!req.headers.has("thread-id")) req.headers.set("thread-id", thread); + const admission = resolveDataPlaneAdmissionSecret(key, admittedConfig)!; + return handleResponses(req, admittedConfig, log, { ...options, admission }); +} + +export function expandOwnedResponseInput(body: unknown): unknown { + const scope = ownedResponseStateOptions(); + return expandPreviousResponseInput(body, scope.clientThreadId, scope.replayOwnerKey); +} + +export function ownedResponseStateOptions(ownThread = thread) { + const admission = resolveDataPlaneAdmissionSecret(key, { apiKeys }); + return { clientThreadId: ownThread, replayOwnerKey: responseReplayOwnerKey(contextPrincipalIdOf(admission ?? undefined), ownThread) }; +} + +export function rememberOwnedResponseState(...[request, response, provider, options]: Parameters): void { + rememberResponseState(request, response, provider, { ...options, ...ownedResponseStateOptions(options?.clientThreadId) }); +} diff --git a/tests/helpers/responses-state-shutdown-budget-child.ts b/tests/helpers/responses-state-shutdown-budget-child.ts index e295846e6bc..8f5df6ac6f9 100644 --- a/tests/helpers/responses-state-shutdown-budget-child.ts +++ b/tests/helpers/responses-state-shutdown-budget-child.ts @@ -22,6 +22,11 @@ import { import { removeTreeWithRetry } from "./remove-tree"; type Scenario = "exhaustion" | "guard"; +const phaseStarted = performance.now(); +const phase = (name: string): void => { + process.stderr.write(`shutdown-budget-phase: ${name} +${Math.round(performance.now() - phaseStarted)}ms\n`); +}; +phase("module-ready"); function fixedResponse(id: string, output: unknown[]): { id: string; output: unknown[]; status: string } { return { id, output, status: "completed" }; @@ -49,6 +54,7 @@ async function runScenario(scenario: Scenario): Promise> const priorHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = home; clearResponseStateMemoryForTests(); + phase("fixture-ready"); try { setPlatformForTests("win32"); setResponseSpillShutdownBudgetForTests({ totalMs: 60, fallbackReserveMs: 40 }); @@ -61,6 +67,7 @@ async function runScenario(scenario: Scenario): Promise> setAsyncIcaclsRunnerForTests(async () => { if (!announced) { announced = true; + phase("first-acl"); entered(); await gate; } @@ -72,6 +79,7 @@ async function runScenario(scenario: Scenario): Promise> }); setResponseStateByteCapForTests(1_024); rememberLarge("resp_budget_exhausted_first", "a".repeat(2 * 1024 * 1024 + 4_096)); + phase("initial-state-stored"); await started; rememberLarge("resp_budget_exhausted_final", "b".repeat(2 * 1024 * 1024 + 4_096)); setResponseStateByteCapForTests(1_000_000_000); @@ -84,12 +92,14 @@ async function runScenario(scenario: Scenario): Promise> let reported: unknown; try { + phase("flush-start"); const flushing = flushResponseState(); setResponseStateByteCapForTests(scenario === "guard" ? 1 : 1_024); await flushing; } catch (error) { reported = error; } finally { + phase("flush-settled"); release(); } const pending = pendingResponseSpillMetricsForTests(); @@ -111,6 +121,7 @@ async function runScenario(scenario: Scenario): Promise> guardReported: messages.some(message => message.includes("terminalization pass limit")), }; } finally { + phase("cleanup-start"); setAsyncIcaclsRunnerForTests(null); setIcaclsRunnerForTests(null); setPlatformForTests(null); @@ -122,6 +133,7 @@ async function runScenario(scenario: Scenario): Promise> removeTreeWithRetry(home); if (priorHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = priorHome; + phase("cleanup-finished"); } } diff --git a/tests/oauth/generic-oauth-refusal-composition.test.ts b/tests/oauth/generic-oauth-refusal-composition.test.ts index e78f5f5829c..0fcdf5e7290 100644 --- a/tests/oauth/generic-oauth-refusal-composition.test.ts +++ b/tests/oauth/generic-oauth-refusal-composition.test.ts @@ -13,7 +13,7 @@ import { clearAccountQuotaCache } from "../../src/providers/quota"; import { clearReasoningReplayCacheForTests, reasoningReplayOAuthCredentialIdentity } from "../../src/responses/reasoning-replay-cache"; import { clearResponseStateForTests, clearResponseStateMemoryForTests, flushResponseState, previousResponseProviderState } from "../../src/responses/state"; import { flushThoughtSignatureReplayForTests, resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; -import { handleResponses } from "../../src/server/responses/core"; +import { handleOwnedResponses as handleResponses } from "../helpers/response-replay-owner"; import type { RequestLogContext } from "../../src/server/request-log"; import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -67,7 +67,7 @@ afterEach(async () => { function request(previousId?: string, stream = false): Request { return new Request("http://localhost/v1/responses", { method: "POST", - headers: { "content-type": "application/json", session_id: "kiro-refusal-composition" }, + headers: { "content-type": "application/json", session_id: "kiro-refusal-composition", "thread-id": "kiro-refusal-composition" }, body: JSON.stringify({ model: "kiro/claude-sonnet-4.5", input: previousId ? "Continue the fixture." : "Seed the fixture.", stream, store: false, ...(previousId ? { previous_response_id: previousId } : {}), diff --git a/tests/providers/claude-cli-client-http.test.ts b/tests/providers/claude-cli-client-http.test.ts index 0a29b554120..cc239967534 100644 --- a/tests/providers/claude-cli-client-http.test.ts +++ b/tests/providers/claude-cli-client-http.test.ts @@ -5,9 +5,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createClaudeCliAdapter } from "../../src/adapters/claude-cli/adapter"; import { getAdapterDefinition } from "../../src/adapters/registry"; -import { handleResponses } from "../../src/server/responses"; -import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleOwnedResponses as handleResponses } from "../helpers/response-replay-owner"; import { rememberResponseState } from "../../src/responses/state"; +import { handleChatCompletions } from "../../src/server/chat-completions"; import { buildDshClientConfig } from "../../src/clients/config-export/dsh"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import type { OcxConfig } from "../../src/types"; @@ -79,7 +79,7 @@ test("Codex/DSH Responses and Pi Chat retain host-owned tools, inclusive usage a const schema = { type: "object", properties: { path: { type: "string" } }, required: ["path"] }; const responseTools = [{ type: "function", name: "read_text", description: "Read the test fixture", parameters: schema }]; const post = async (path: string, body: unknown) => { - const res = await fetch(`${base}/${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const res = await fetch(`${base}/${path}`, { method: "POST", headers: { "content-type": "application/json", "thread-id": "claude-cli-client-http" }, body: JSON.stringify(body) }); const value = await res.json() as any; if (res.status !== 200) throw new Error(`HTTP ${res.status}: ${JSON.stringify(value)}`); return value; @@ -110,7 +110,7 @@ test("Codex/DSH Responses and Pi Chat retain host-owned tools, inclusive usage a expect(chatSecond.usage.prompt_tokens).toBe(112); const controller = new AbortController(); const previousChildren = children.length; - const waiting = fetch(`${base}/responses`, { method: "POST", signal: controller.signal, headers: { "content-type": "application/json" }, body: JSON.stringify({ model, stream: true, input: "WAIT_FOR_ABORT", tools: responseTools }) }).then(res => res.text()).catch(() => "aborted"); + const waiting = fetch(`${base}/responses`, { method: "POST", signal: controller.signal, headers: { "content-type": "application/json", "thread-id": "claude-cli-client-http" }, body: JSON.stringify({ model, stream: true, input: "WAIT_FOR_ABORT", tools: responseTools }) }).then(res => res.text()).catch(() => "aborted"); const deadline = Date.now() + 2500; while (children.length === previousChildren && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); expect(children.length).toBe(previousChildren + 1); diff --git a/tests/providers/opencode-go-luna-wire.test.ts b/tests/providers/opencode-go-luna-wire.test.ts index a07c66ff2de..b96547d96a3 100644 --- a/tests/providers/opencode-go-luna-wire.test.ts +++ b/tests/providers/opencode-go-luna-wire.test.ts @@ -14,6 +14,7 @@ import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-res import { parseRequest } from "../../src/responses/parser"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { handleOwnedResponses } from "../helpers/response-replay-owner"; const MODEL = "gpt-5.6-luna"; const GO_RESPONSES_MODELS = [MODEL, "grok-4.6", "muse-spark-1.3-contributor"]; @@ -158,8 +159,9 @@ describe("OpenCode Go stateless reasoning and continuation routes", () => { const config = { providers: { "opencode-go": opencodeGo() } } as unknown as OcxConfig; const drive = async (body: Record) => { takeSpendHome(); - const response = await handleResponses(new Request("http://localhost/v1/responses", { - method: "POST", headers: { "content-type": "application/json" }, + // Retained local IDs require an admitted caller and the same own thread on both turns. + const response = await handleOwnedResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "thread-id": `opencode-go-replay-${prefix}` }, body: JSON.stringify({ model: `opencode-go/${model}`, stream: streaming, reasoning: { summary: continuation.summary }, tools: [{ type: "function", name: "probe", parameters: { type: "object" } }], ...body }), }), config, { model: "", provider: "" }, { inboundWire: "responses" }); diff --git a/tests/responses/compaction-history-framing.test.ts b/tests/responses/compaction-history-framing.test.ts index 4bcc628b900..82a4b112a73 100644 --- a/tests/responses/compaction-history-framing.test.ts +++ b/tests/responses/compaction-history-framing.test.ts @@ -8,7 +8,7 @@ import { CHECKPOINT_CONTINUATION_NOTE, COMPACT_PROMPT, SUMMARY_PREFIX, encodeCom import { frameCompactionHistory, HISTORICAL_REQUEST_START, HISTORICAL_REQUEST_END } from "../../src/responses/compaction-history-framing"; import { parseRequest } from "../../src/responses/parser"; import { clearResponseStateForTests, expandPreviousResponseInput, previousResponseReplayPrefixLength, rememberResponseState } from "../../src/responses/state"; -import { handleResponses } from "../../src/server/responses"; +import { handleOwnedResponses as handleResponses } from "../helpers/response-replay-owner"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; diff --git a/tests/responses/current-turn-tool-catalog-namespace.test.ts b/tests/responses/current-turn-tool-catalog-namespace.test.ts index 243882360ca..a8c4b168c04 100644 --- a/tests/responses/current-turn-tool-catalog-namespace.test.ts +++ b/tests/responses/current-turn-tool-catalog-namespace.test.ts @@ -2,10 +2,10 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import type { ProviderAdapter } from "../../src/adapters/base"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; -import { clearResponseStateForTests, expandPreviousResponseInput, flushResponseState } from "../../src/responses/state"; +import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import * as adapterResolver from "../../src/server/adapter-resolve"; -import { handleResponses } from "../../src/server/responses"; +import { expandOwnedResponseInput, handleOwnedResponses } from "../helpers/response-replay-owner"; import { namespacedToolName, type AdapterEvent, type OcxConfig, type OcxParsedRequest } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { createTempHome } from "../helpers/temp-home"; @@ -96,7 +96,7 @@ function setup(lane: Lane): OcxConfig { } async function post(config: OcxConfig, stream: boolean, body: Rec): Promise { - const response = await handleResponses(new Request("http://127.0.0.1/v1/responses", { + const response = await handleOwnedResponses(new Request("http://127.0.0.1/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "fixture/model", stream, ...body }), }), config, { model: "", provider: "" }); @@ -125,7 +125,7 @@ function assertCurrentIdentity(reply: Reply, namespace: string | undefined, allo expect(streamedCalls).toHaveLength(0); expect((reply.response.error as Rec).message).toContain("undeclared client tool"); const continuation = { previous_response_id: reply.response.id, input: [userInput] }; - expect(expandPreviousResponseInput(continuation)).toBe(continuation); + expect(expandOwnedResponseInput(continuation)).toBe(continuation); return; } expect(reply.response.status).toBe("completed"); diff --git a/tests/responses/responses-compaction-http-isolation.test.ts b/tests/responses/responses-compaction-http-isolation.test.ts index fbce3f929e7..77e2f85b250 100644 --- a/tests/responses/responses-compaction-http-isolation.test.ts +++ b/tests/responses/responses-compaction-http-isolation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -11,20 +11,12 @@ import { clearUpstreamHostHealth } from "../../src/codex/upstream-host-health"; import type { OcxConfig } from "../../src/types"; import type { RequestLogContext } from "../../src/server/request-log"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; -import { baseCompactionBody, compactionRequest, completedPayload, drainCompactionResponseState, installCompactionRoutingAclFixture, jsonResponse, removeCompactionFixture, sseResponse, twoAccountPoolConfig } from "../helpers/compaction-routing-fixtures"; +import { providerFetch } from "../../src/server/responses/fetch-helpers"; +import * as proxyEnvironment from "../../src/lib/proxy-env"; +import { baseCompactionBody, compactionRequest, completedPayload, drainCompactionResponseState, installCompactionRoutingAclFixture, installCompactionHttpMock as installHttpMock, jsonResponse, removeCompactionFixture, sseResponse, twoAccountPoolConfig } from "../helpers/compaction-routing-fixtures"; installCompactionRoutingAclFixture(); -// These contracts exercise HTTP compact/replay, not transport selection. Bind the -// synthetic executor at the physical-send seam so inherited proxy settings cannot -// bypass global fetch mocks. Keep WebSocket out of these HTTP/SSE fixtures as well. -function installHttpMock(config: OcxConfig, mock: typeof fetch): void { - globalThis.fetch = mock; - for (const provider of Object.values(config.providers)) { - Object.assign(provider, { fetch: mock, upstreamWebsocket: false }); - } -} - async function withPoolEnv(name: string, run: (config: OcxConfig, signal: AbortSignal) => Promise, deadlineMs = 10_000): Promise { const testDir = mkdtempSync(join(tmpdir(), name)); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -75,6 +67,43 @@ async function withPoolEnv(name: string, run: (config: OcxConfig, signal: Abo } describe("HTTP compaction replay isolation", () => { + test("the HTTP fixture reaches only its physical sentinel with ordinary HTTP and WebSocket blocked", async () => { + const originalFetch = globalThis.fetch; + const originalSocket = globalThis.WebSocket; + let physicalCalls = 0; + let globalCalls = 0; + let socketCalls = 0; + const blockedFetch = (() => { globalCalls++; throw new Error("HTTP fixture blocked global fetch"); }) as typeof fetch; + const configuredFetch = spyOn(proxyEnvironment, "configuredOutboundFetch").mockImplementation(() => { + throw new Error("HTTP fixture blocked configured fetch"); + }); + globalThis.WebSocket = class { + constructor() { socketCalls++; throw new Error("HTTP fixture blocked WebSocket"); } + } as unknown as typeof WebSocket; + const mock = (async () => { physicalCalls++; return new Response("compaction-physical-sentinel"); }) as typeof fetch; + const url = "https://chatgpt.com/backend-api/codex/responses"; + const init = { method: "POST", body: JSON.stringify({ model: "gpt-5.5", stream: true, input: [] }) }; + try { + // The old global-only setup cannot escape into a real connection in this control. + globalThis.fetch = blockedFetch; + await expect(providerFetch(twoAccountPoolConfig().providers.openai!, "1.4.4")(url, init)) + .rejects.toThrow("HTTP fixture blocked"); + expect(physicalCalls).toBe(0); + const blockedBefore = [globalCalls, socketCalls, configuredFetch.mock.calls.length]; + const config = twoAccountPoolConfig(); + installHttpMock(config, mock); + globalThis.fetch = blockedFetch; + const response = await providerFetch(config.providers.openai!, "1.4.4")(url, init); + expect(await response.text()).toBe("compaction-physical-sentinel"); + expect(physicalCalls).toBe(1); + expect([globalCalls, socketCalls, configuredFetch.mock.calls.length]).toEqual(blockedBefore); + } finally { + configuredFetch.mockRestore(); + globalThis.fetch = originalFetch; + globalThis.WebSocket = originalSocket; + } + }); + test("fixture abort settles mocked work before restoring homes and fetch", async () => { const previousHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index f9a7d9c50a9..122450625cf 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1,3 +1,4 @@ +import { handleOwnedResponses } from "../helpers/response-replay-owner"; import { getAccountQuotaHistory } from "../../src/codex/quota"; import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; @@ -41,7 +42,7 @@ import { acquireNativeMainProfileDrain, tryAdmitTurn } from "../../src/server/li import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { clearComboRecallForTests, recallComboForLane, rememberComboForLane } from "../../src/server/responses/combo-session-recall"; import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; -import { baseCompactionBody, compactionRequest, completedPayload, drainCompactionResponseState, installCompactionRoutingAclFixture, jsonResponse, keyProviderConfig, nativePoolConfig, removeCompactionFixture, sseResponse, twoAccountPoolConfig } from "../helpers/compaction-routing-fixtures"; +import { baseCompactionBody, compactionRequest, completedPayload, drainCompactionResponseState, installCompactionRoutingAclFixture, installCompactionHttpMock, jsonResponse, keyProviderConfig, nativePoolConfig, removeCompactionFixture, sseResponse, twoAccountPoolConfig } from "../helpers/compaction-routing-fixtures"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; @@ -921,7 +922,7 @@ describe("compact alternate-account attempt (#913)", () => { ...options, accountId: options.accountId ?? liveConfig.activeCodexAccountId, }), ); - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + installCompactionHttpMock(config, (async (input: string | URL | Request, init?: RequestInit) => { const request = new Request(input, init); accounts.push(request.headers.get("chatgpt-account-id")); const body = await request.json() as { input?: Array<{ type?: string }> }; @@ -933,7 +934,7 @@ describe("compact alternate-account attempt (#913)", () => { ...completedPayload("native answer"), model: "gpt-5.5", ...(compact ? { output: [{ type: "compaction", encrypted_content: "native-recall-ciphertext" }] } : {}), } }]); - }) as typeof fetch; + }) as typeof fetch); const client = new AbortController(); let completionTimer: ReturnType | undefined; try { @@ -993,14 +994,14 @@ describe("compact alternate-account attempt (#913)", () => { config.codexAccountNamespaces = { side: "pool-b" }; const item = { type: "compaction", id: "cmp_native_3769", encrypted_content: "native-opaque-3769" }; const calls: Array<{ url: string; headers: Headers; body: Record }> = []; - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + installCompactionHttpMock(config, (async (input: string | URL | Request, init?: RequestInit) => { const request = new Request(input, init); calls.push({ url: request.url, headers: request.headers, body: await request.json() as Record }); if (request.url.endsWith("/responses/compact")) return Response.json({ detail: "Not Found" }, { status: 404 }); return sseResponse([{ type: "response.completed", response: { id: "resp_compact_3769", status: "completed", output: [item], } }]); - }) as typeof fetch; + }) as typeof fetch); const headers = { "session-id": "compact-3769-session", "thread-id": `compact-3769-${account}`, "x-codex-parent-thread-id": "compact-3769-parent" }; const response = await handleResponsesCompact(compactionRequest({ model, input: [{ role: "user", content: "retain this history" }], @@ -1072,13 +1073,13 @@ describe("compact alternate-account attempt (#913)", () => { test(`native 404 followed by ${status} SSE does not install replacement history`, async () => { await withPoolEnv("ocx-compact-404-terminal-", async config => { let calls = 0; - globalThis.fetch = (async () => { + installCompactionHttpMock(config, (async () => { calls++; if (calls === 1) return Response.json({ detail: "Not Found" }, { status: 404 }); return sseResponse([{ type: `response.${status}`, response: { id: "resp_compact_rejected_3769", status, output: [], } }]); - }) as typeof fetch; + }) as typeof fetch); const response = await handleResponsesCompact(compactionRequest({ model: "gpt-5.5", input: [] }), config, { model: "", provider: "" }); expect(response.status).toBe(502); const payload = await response.json() as { output?: unknown; error?: unknown }; @@ -2427,7 +2428,7 @@ describe("established-history external task input (#3807)", () => { test("stored-ID continuation replays the established tool pair and inter-task delivery in order", async () => { const captured = captureChat(acknowledged); const config = keyProviderConfig({ adapter: "openai-chat" }); - const first = await handleResponses(compactionRequest({ ...requestBody(), store: true }), + const first = await handleOwnedResponses(compactionRequest({ ...requestBody(), store: true }), config, { model: "", provider: "" }); expect(first.status).toBe(200); const saved = await first.json() as { id: string; status?: string }; @@ -2438,7 +2439,7 @@ describe("established-history external task input (#3807)", () => { expectHistory(captured[0]!); // Send only the new user turn: the handler must retrieve the previous raw history. - const res = await handleResponses(compactionRequest({ + const res = await handleOwnedResponses(compactionRequest({ ...requestBody(), previous_response_id: saved.id, input: [{ type: "message", role: "user", content: continuationText }], }), config, { model: "", provider: "" }); diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index 7577ac1559e..332cc431e27 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -1,4 +1,4 @@ -import { handleResponses } from "../../src/server/responses"; +import { handleOwnedResponses as handleResponses } from "../helpers/response-replay-owner"; import type { OcxConfig } from "../../src/types"; import { afterEach, describe, expect, test } from "bun:test"; import { diff --git a/tests/responses/responses-replay-owner-storage.test.ts b/tests/responses/responses-replay-owner-storage.test.ts new file mode 100644 index 00000000000..910f466ddbd --- /dev/null +++ b/tests/responses/responses-replay-owner-storage.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getConfigDir } from "../../src/config"; +import { + clearResponseStateForTests, clearResponseStateMemoryForTests, expandPreviousResponseInput, + rememberResponseState, previousResponseReplayFailure, runPendingResponseStatePersistForTests, + setResponseStateByteCapForTests, flushResponseState, responseStateMetrics, + previousResponseReplayPrefixLength, previousResponseRestoredPrefixLength, copyPreviousResponseReplayProvenance, +} from "../../src/responses/state"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ownerA = "a".repeat(64), ownerB = "b".repeat(64); +test("compiled request requires a valid owner before retention", () => { + clearResponseStateForTests(); + const body = { input: [] }; + for (const replayOwnerKey of [undefined, null, "invalid"]) { + const options = { compiledPlaintext: true, force: true, replayOwnerKey }; + rememberResponseState(body, { id: "resp-compiled-owner", output: [{ type: "message", content: "synthetic output" }] }, undefined, options); + const replay = { previous_response_id: "resp-compiled-owner", input: [] }; + expect(expandPreviousResponseInput(replay)).toBe(replay); + } + rememberResponseState(body, { id: "resp-compiled-owner", output: [{ type: "message", content: "synthetic output" }] }, undefined, + { compiledPlaintext: true, replayOwnerKey: ownerA }); + expect(JSON.stringify(expandPreviousResponseInput({ previous_response_id: "resp-compiled-owner", input: [] }, undefined, ownerA))).toContain("synthetic output"); + clearResponseStateForTests(); +}); +let release: () => void; +let home: string; +let previousHome: string | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-replay-storage-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + clearResponseStateForTests(); +}); +afterEach(() => { + setResponseStateByteCapForTests(null); + release(); + try { clearResponseStateForTests(); } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } +}); +function remember(key: string | null | undefined, text = "private synthetic task"): void { + rememberResponseState({ input: [] }, { id: "resp-collision", output: [{ type: "message", role: "assistant", content: text }] }, undefined, + { clientThreadId: "own-thread", replayOwnerKey: key }); +} +function replay(key: string | null | undefined): Record { + return expandPreviousResponseInput({ previous_response_id: "resp-collision", input: [] }, "own-thread", key) as Record; +} +for (const spill of [false, true]) { + test(`owner binding survives ${spill ? "spill and snapshot" : "resident snapshot"} reload`, async () => { + if (spill) setResponseStateByteCapForTests(512); + remember(ownerA, "private synthetic task".repeat(spill ? 200 : 1)); + await flushResponseState(); + await runPendingResponseStatePersistForTests(); + const snapshot = JSON.parse(readFileSync(join(getConfigDir(), "responses-state.json"), "utf8")); + expect(snapshot.states[0][1].replayOwnerKey).toBe(ownerA); + if (spill) expect(snapshot.states[0][1].kind).toBe("spill"); + clearResponseStateMemoryForTests(); + const before = responseStateMetrics(); + expect(previousResponseReplayFailure(replay(ownerB))?.reason).toBe("scope_mismatch"); + expect(responseStateMetrics().spillReadFailures).toBe(before.spillReadFailures); + expect(JSON.stringify(replay(ownerA).input)).toContain("private synthetic task"); + }); +} +test("a response id cannot be overwritten by another owner", () => { + remember(ownerA); + remember(ownerB, "foreign replacement"); + expect(previousResponseReplayFailure(replay(ownerB))?.reason).toBe("scope_mismatch"); + expect(JSON.stringify(replay(ownerA).input)).toContain("private synthetic task"); + expect(JSON.stringify(replay(ownerA).input)).not.toContain("foreign replacement"); +}); +test("a legacy response cannot overwrite a bound response id", () => { + remember(ownerA); + remember(undefined, "legacy replacement"); + expect(JSON.stringify(replay(ownerA).input)).toContain("private synthetic task"); + expect(JSON.stringify(replay(ownerA).input)).not.toContain("legacy replacement"); +}); + +test("authorized client-carried history stays distinct from proxy-restored input", () => { + const output = [{ id: "msg-owned-prefix", type: "message", role: "assistant", content: "stored reply" }]; + rememberResponseState({ input: [] }, { id: "resp-owned-prefix", output }, undefined, + { clientThreadId: "own-thread", replayOwnerKey: ownerA }); + const next = { type: "message", role: "user", content: "new request" }; + const carried = expandPreviousResponseInput({ previous_response_id: "resp-owned-prefix", input: [...output, next] }, + "own-thread", ownerA) as Record; + expect(carried.input).toEqual([...output, next]); + expect(previousResponseReplayPrefixLength(carried)).toBe(1); + expect(previousResponseRestoredPrefixLength(carried)).toBe(0); + const clone = { ...carried }; + copyPreviousResponseReplayProvenance(carried, clone); + expect(previousResponseReplayPrefixLength(clone)).toBe(1); + expect(previousResponseRestoredPrefixLength(clone)).toBe(0); + const restored = expandPreviousResponseInput({ previous_response_id: "resp-owned-prefix", input: [next] }, + "own-thread", ownerA) as Record; + expect(restored.input).toEqual([...output, next]); + expect(previousResponseRestoredPrefixLength(restored)).toBe(1); +}); +test("bound requests reject legacy entries and missing owner never authorizes replay", () => { + remember(undefined); + expect(previousResponseReplayFailure(replay(ownerA))?.reason).toBe("scope_mismatch"); + expect(previousResponseReplayFailure(replay(null))?.reason).toBe("scope_mismatch"); +}); +test("legacy API cannot read bound entries", () => { + remember(ownerA); + expect(previousResponseReplayFailure(replay(undefined))?.reason).toBe("scope_mismatch"); +}); +for (const spill of [false, true]) test(`legacy ${spill ? "spilled" : "resident"} state survives snapshot reload`, async () => { + if (spill) setResponseStateByteCapForTests(512); + remember(undefined, "private synthetic task".repeat(spill ? 200 : 1)); + expect(JSON.stringify(replay(undefined).input)).toContain("private synthetic task"); + await flushResponseState(); + await runPendingResponseStatePersistForTests(); + if (spill) expect(JSON.parse(readFileSync(join(getConfigDir(), "responses-state.json"), "utf8")).states[0][1].kind).toBe("spill"); + clearResponseStateMemoryForTests(); + expect(JSON.stringify(replay(undefined).input)).toContain("private synthetic task"); +}); +test("malformed snapshot binding cannot become legacy authority", async () => { + remember(ownerA); + await runPendingResponseStatePersistForTests(); + const path = join(getConfigDir(), "responses-state.json"); + const snapshot = JSON.parse(readFileSync(path, "utf8")); + snapshot.states[0][1].replayOwnerKey = "malformed"; + writeFileSync(path, JSON.stringify(snapshot)); + clearResponseStateMemoryForTests(); + expect(replay(ownerA).input).toEqual([]); + expect(previousResponseReplayFailure(replay(ownerA))).toBeDefined(); +}); +test("foreign replay is refused before reading a corrupt spill", async () => { + setResponseStateByteCapForTests(512); + remember(ownerA, "synthetic spill payload".repeat(200)); + await flushResponseState(); + await runPendingResponseStatePersistForTests(); + const snapshot = JSON.parse(readFileSync(join(getConfigDir(), "responses-state.json"), "utf8")); + const stub = snapshot.states[0][1]; + expect(stub.kind).toBe("spill"); + writeFileSync(join(getConfigDir(), "responses-state-spill", stub.spill.fileName), "corrupt"); + clearResponseStateMemoryForTests(); + expect(previousResponseReplayFailure(replay(ownerB))?.reason).toBe("scope_mismatch"); + expect(responseStateMetrics().spillReadFailures).toBe(0); + expect(previousResponseReplayFailure(replay(ownerA))?.reason).toBe("spill_corrupt"); + expect(responseStateMetrics().spillReadFailures).toBe(1); +}); diff --git a/tests/responses/responses-reset-replay-chained.test.ts b/tests/responses/responses-reset-replay-chained.test.ts index 7576e8f1a80..a93295dcc90 100644 --- a/tests/responses/responses-reset-replay-chained.test.ts +++ b/tests/responses/responses-reset-replay-chained.test.ts @@ -6,7 +6,7 @@ import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; // adapter strips the id before the wire. The ambiguous-reset judgment has to look at that body, // not at the inbound one that still names the id, or an operator's retryOnReset opt-in never // applies to a chained turn. -const { handleResponses } = await import("../../src/server/responses"); +const { handleOwnedResponses: handleResponses } = await import("../helpers/response-replay-owner"); const releaseSpendHome = acquireOwnedSpendHome(); afterAll(() => releaseSpendHome()); diff --git a/tests/responses/responses-snapshot-repair-server.test.ts b/tests/responses/responses-snapshot-repair-server.test.ts index 002407b1557..1b89841b0c2 100644 --- a/tests/responses/responses-snapshot-repair-server.test.ts +++ b/tests/responses/responses-snapshot-repair-server.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; -import { handleResponses } from "../../src/server/responses"; +import { handleOwnedResponses as handleResponses } from "../helpers/response-replay-owner"; import { isEagerRelaySseResponse } from "../../src/server/relay"; import { createGrokResponsesControlFrameBlockRewrite, diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 8bb611a44d7..d8e1d735354 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -222,7 +222,8 @@ async function runShutdownBudgetChild( if (timer !== undefined) clearTimeout(timer); const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); if (timedOut) { - throw new Error(`response spill shutdown budget child timed out after ${timeoutMs}ms (${scenario})`); + const progress = stderr.split(/\r?\n/).filter(line => line.startsWith("shutdown-budget-phase: ")).slice(-8).join("; "); + throw new Error(`response spill shutdown budget child timed out after ${timeoutMs}ms (${scenario}); ${progress || "no phase marker"}`); } if (exitCode !== 0) { throw new Error(`response spill shutdown budget child exited ${exitCode} (${scenario}): ${stderr.trim()}`); diff --git a/tests/responses/ws-capacity-reclamation.test.ts b/tests/responses/ws-capacity-reclamation.test.ts index 1a6da6f4abe..ca2be59292c 100644 --- a/tests/responses/ws-capacity-reclamation.test.ts +++ b/tests/responses/ws-capacity-reclamation.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; import { codexWebSocketAdmissionMetrics, MAX_TRACKED_CODEX_WEBSOCKETS } from "../../src/codex/websocket-registry"; import { clearResponseStateForTests, clearResponseStateMemoryForTests, RESPONSE_TTL_MS, @@ -10,12 +10,16 @@ import { } from "../../src/responses/state"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; +import { OPENAI_PROVIDER_TIER_VERSION } from "../../src/types"; +import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; const previousHome = process.env.OPENCODEX_HOME; const MODEL = "fixture/capacity-model"; +const REPLAY_API_KEY = "synthetic-capacity-replay-owner"; +const OTHER_REPLAY_API_KEY = "synthetic-capacity-other-owner"; type Frame = Record; type Client = { socket: WebSocket; frames: Frame[]; closed?: CloseEvent; error?: Event }; let home: string; @@ -27,6 +31,7 @@ let upstreamRequests = 0; let upstreamCancellations = 0; let heldUpstreamSignal: AbortSignal | undefined; const upstreamBodies: Frame[] = []; +const upstreamHeaders: Headers[] = []; const clients: Client[] = []; const refusedHandshakes = new Map(); let closeReleaseDelayMs = 0; @@ -52,6 +57,7 @@ beforeEach(() => { upstreamCancellations = 0; heldUpstreamSignal = undefined; upstreamBodies.length = 0; + upstreamHeaders.length = 0; finishStream = undefined; refusedHandshakes.clear(); closeReleaseDelayMs = 0; @@ -72,6 +78,7 @@ beforeEach(() => { const body = await request.json() as Frame; upstreamRequests++; upstreamBodies.push(body); + upstreamHeaders.push(request.headers); const id = `resp_capacity_${upstreamRequests}`; const tool = JSON.stringify(body.input).includes("request-fixture-tool"); const hold = JSON.stringify(body.input).includes("hold-fixture-stream"); @@ -95,7 +102,16 @@ beforeEach(() => { }, }); saveConfig({ - port: 0, hostname: "127.0.0.1", codexAutoStart: false, websockets: true, + port: 0, hostname: "127.0.0.1", codexAutoStart: false, websockets: true, multiAgentMode: "v2-plaintext", + // These socket fixtures exercise current config, not first-upgrade writes or native sync. + clientIntegrations: { codex: false }, + openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION, + subagentModelsVersion: SUBAGENT_MODELS_VERSION, + subagentModels: [...DEFAULT_SUBAGENT_MODELS], + apiKeys: [ + { id: "capacity-replay-owner", key: REPLAY_API_KEY, name: "Replay owner", createdAt: "2026-10-01T00:00:00.000Z" }, + { id: "capacity-other-owner", key: OTHER_REPLAY_API_KEY, name: "Other owner", createdAt: "2026-10-01T00:00:00.000Z" }, + ], defaultProvider: "fixture", providers: { fixture: { adapter: "openai-responses", baseUrl: `${upstream.url}v1`, apiKey: "sk-capacity-fixture", upstreamWebsocket: false, allowPrivateNetwork: true, statelessResponses: true, @@ -140,12 +156,12 @@ afterEach(async () => { } }); -async function connect(retriesRemaining = MAX_TRACKED_CODEX_WEBSOCKETS): Promise { +async function connect(headers?: Record, retriesRemaining = MAX_TRACKED_CODEX_WEBSOCKETS): Promise { const url = new URL("/v1/responses", proxy!.url); url.protocol = "ws:"; const attempt = String(clients.length); url.searchParams.set("capacity-attempt", attempt); - const client: Client = { socket: new WebSocket(url), frames: [] }; + const client: Client = { socket: new WebSocket(url, { headers }), frames: [] }; clients.push(client); client.socket.addEventListener("message", event => client.frames.push(JSON.parse(String(event.data)))); client.socket.addEventListener("close", event => { client.closed = event; }); @@ -156,7 +172,7 @@ async function connect(retriesRemaining = MAX_TRACKED_CODEX_WEBSOCKETS): Promise await until(() => codexWebSocketAdmissionMetrics().active < MAX_TRACKED_CODEX_WEBSOCKETS, "reclaimed socket releases admission before retry"); expect(retriesRemaining).toBeGreaterThan(0); - return connect(retriesRemaining - 1); + return connect(headers, retriesRemaining - 1); } return client; } @@ -296,8 +312,21 @@ test("failed HTTP upgrade releases its reservation before the next handshake", a expect(codexWebSocketAdmissionMetrics().active).toBe(baseline.active + 1); }); +test("v1 WebSocket continuation works without a caller principal", async () => { + await proxy!.stop(true); + saveConfig({ ...loadConfig(), multiAgentMode: "v1", apiKeys: [] }); + proxy = startServer(0); + const client = await connect(); + const first = await exchange(client, { input: "legacy socket history", store: false }); + await exchange(client, { previous_response_id: first.response.id, input: "next turn", store: false }); + expect(upstreamRequests).toBe(2); + expect(JSON.stringify(upstreamBodies[1]!.input)).toContain("legacy socket history"); + expect(upstreamBodies[1]!.previous_response_id).toBeUndefined(); +}, 120_000); + test.each([false, true])("stateless continuation after capacity reclamation preserves full history; expired=%s", async expired => { - const original = await connect(); + const headers = { "x-opencodex-api-key": REPLAY_API_KEY, "thread-id": "capacity-replay-thread" }; + const original = await connect(headers); const firstInput = { role: "user", content: [{ type: "input_text", text: "Remember the fixture number 47." }] }; const first = await exchange(original, { input: [firstInput], store: false }); const previousId = first.response.id; @@ -305,7 +334,7 @@ test.each([false, true])("stateless continuation after capacity reclamation pres expect(previousId.length).toBeGreaterThan(0); await until(() => responseStateMetrics().count === 1, "first response retains real continuation state"); for (let i = 1; i < MAX_TRACKED_CODEX_WEBSOCKETS; i++) await connect(); - const reconnected = await connect(); + const reconnected = await connect(headers); await until(() => original.closed?.code === 1001, "socket holding the response ID reclaimed"); if (expired) { expect(sweepExpiredResponseStates(Date.now() + RESPONSE_TTL_MS + 60_000)).toBe(1); @@ -324,5 +353,91 @@ test.each([false, true])("stateless continuation after capacity reclamation pres expect(upstreamBodies[1]!.input).toHaveLength(3); expect(upstreamBodies[1]!.input).toMatchObject([firstInput, { type: "message", role: "assistant", content: [{ type: "output_text", text: "capacity fixture completed" }] }, delta]); + for (const forwarded of upstreamHeaders) { + expect(forwarded.has("x-opencodex-api-key")).toBe(false); + expect(forwarded.get("authorization")).toBe("Bearer sk-capacity-fixture"); + expect([...forwarded.values()].some(value => value.includes(REPLAY_API_KEY))).toBe(false); + } expectBounded(); }, 15_000); + +test.each([ + ["different caller", { "x-opencodex-api-key": OTHER_REPLAY_API_KEY, "thread-id": "owned-thread" }], + ["different own thread", { "x-opencodex-api-key": REPLAY_API_KEY, "thread-id": "other-thread" }], + ["parent thread only", { "x-opencodex-api-key": REPLAY_API_KEY, "x-codex-parent-thread-id": "owned-thread" }], + ["no caller key", { "thread-id": "owned-thread" }], +] as const)("real WebSocket ingress refuses owned continuation for %s despite body identity claims", async (_label, headers) => { + const original = await connect({ "x-opencodex-api-key": REPLAY_API_KEY, "thread-id": "owned-thread" }); + const first = await exchange(original, { input: "private owner history", store: false }); + await until(() => responseStateMetrics().count === 1, "owner response is retained"); + const intruder = await connect(headers); + const denied = await exchange(intruder, { + previous_response_id: first.response.id, input: "untrusted delta", + "thread-id": "owned-thread", "x-opencodex-api-key": REPLAY_API_KEY, + replayPrincipal: { clientPrincipalId: "claimed-principal" }, + admission: { kind: "configured", keyId: "capacity-replay-owner", contextPrincipalId: "claimed-principal" }, + }, "error"); + expect(denied).toMatchObject({ status: 400, error: { code: "previous_response_not_found" } }); + expect(upstreamRequests).toBe(1); + // Refusal does not consume or rebind the original owner's retained state. + await exchange(original, { previous_response_id: first.response.id, input: "authorized delta", store: false }); + expect(upstreamRequests).toBe(2); + expect(JSON.stringify(upstreamBodies[1]!.input)).toContain("private owner history"); +}); + +test("a revoked captured environment key cannot fall back to another handshake bearer", async () => { + const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; + try { + process.env.OPENCODEX_API_AUTH_TOKEN = "synthetic-capacity-environment-owner"; + const headers = { "x-opencodex-api-key": process.env.OPENCODEX_API_AUTH_TOKEN, + authorization: `Bearer ${OTHER_REPLAY_API_KEY}`, "thread-id": "rotation-thread" }; + const original = await connect(headers); + const first = await exchange(original, { input: "environment-owned history", store: false }); + const bearerOwner = await connect({ authorization: headers.authorization, "thread-id": headers["thread-id"] }); + const bearerFirst = await exchange(bearerOwner, { input: "other caller history", store: false }); + await until(() => responseStateMetrics().count === 2, "both callers retain separately owned state"); + process.env.OPENCODEX_API_AUTH_TOKEN = "synthetic-capacity-environment-replacement"; + for (const id of [first.response.id, bearerFirst.response.id]) { + const refused = await exchange(original, { previous_response_id: id, input: "delta after revocation" }, "error"); + expect(refused).toMatchObject({ status: 400, error: { code: "previous_response_not_found" } }); + } + expect(upstreamRequests).toBe(2); + const replacement = await connect({ "x-opencodex-api-key": process.env.OPENCODEX_API_AUTH_TOKEN, + "thread-id": headers["thread-id"] }); + const refused = await exchange(replacement, { previous_response_id: first.response.id, input: "new key delta" }, "error"); + expect(refused).toMatchObject({ status: 400, error: { code: "previous_response_not_found" } }); + expect(upstreamRequests).toBe(2); + await exchange(replacement, { input: "full conversation after rotation", store: false }); + expect(upstreamRequests).toBe(3); + expect(upstreamHeaders.every(headers => !headers.has("x-opencodex-api-key"))).toBe(true); + } finally { + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + } +}); + +test("a surviving bearer keeps per-turn identity resolution ahead of a dropped x-api-key", async () => { + const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; + try { + delete process.env.OPENCODEX_API_AUTH_TOKEN; + const original = await connect({ authorization: `Bearer ${REPLAY_API_KEY}`, "x-api-key": OTHER_REPLAY_API_KEY, + "thread-id": "bearer-thread" }); + const first = await exchange(original, { input: "configured bearer history", store: false }); + await until(() => responseStateMetrics().count === 1, "configured bearer response is retained"); + // The same raw bearer now resolves to a different principal under the existing + // environment-before-configured precedence. No captured holder may freeze it. + process.env.OPENCODEX_API_AUTH_TOKEN = REPLAY_API_KEY; + const refused = await exchange(original, { previous_response_id: first.response.id, input: "old owner delta" }, "error"); + expect(refused).toMatchObject({ status: 400, error: { code: "previous_response_not_found" } }); + expect(upstreamRequests).toBe(1); + const current = await exchange(original, { input: "current environment bearer history", store: false }); + const reconnected = await connect({ authorization: `Bearer ${REPLAY_API_KEY}`, "thread-id": "bearer-thread" }); + await exchange(reconnected, { previous_response_id: current.response.id, input: "current owner delta", store: false }); + expect(upstreamRequests).toBe(3); + expect(JSON.stringify(upstreamBodies[2]!.input)).toContain("current environment bearer history"); + expect(upstreamHeaders.every(headers => !headers.has("x-api-key"))).toBe(true); + } finally { + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + } +}); diff --git a/tests/responses/ws-native-continuation-send-budget.test.ts b/tests/responses/ws-native-continuation-send-budget.test.ts index 1d7c19f91e8..fe918e5a86d 100644 --- a/tests/responses/ws-native-continuation-send-budget.test.ts +++ b/tests/responses/ws-native-continuation-send-budget.test.ts @@ -29,7 +29,7 @@ async function begin(root: string, limit?: number, enforced = false, mode: "inje configureSharedSpendLedger(spendPolicyFromConfig(enforced ? { root: { maxTokens: 1_000_000 } } : undefined)); const fields = mode === "steering" ? { multi_agent: undefined } : {}; return beginInjection(fields, { ...injectionConfig(), codexNativeSteering: mode === "steering" }, "test", - { "x-codex-parent-thread-id": root, "thread-id": root }); + { requestHeaders: { "x-codex-parent-thread-id": root, "thread-id": root } }); } for (const mode of ["injection", "steering"] as const) for (const enforced of [false, true]) for (const limit of [1, 2]) { @@ -143,7 +143,7 @@ test("native token-ceiling refusal preserves its workflow identity after earlier prepareInjectionSpendHome(); bindWorkflowBudgetConfig(null); configureSharedSpendLedger(spendPolicyFromConfig({ root: { maxTokens: 4096 } })); const x = await beginInjection({ max_output_tokens: 1024 }, injectionConfig(), "test", - { "x-codex-parent-thread-id": root, "thread-id": root }); + { requestHeaders: { "x-codex-parent-thread-id": root, "thread-id": root } }); let id = x.id, refused = false; for (let i = 1; i <= 8; i++) { const call = `token-call-${i}`, item = advertiseInjection(x.socket, call); @@ -326,7 +326,7 @@ test("a continuation is priced on the reconstructed frame, not the opening estim prepareInjectionSpendHome(); bindWorkflowBudgetConfig(null); configureSharedSpendLedger(spendPolicyFromConfig({ root: { maxTokens: 2000 } })); const x = await beginInjection({}, injectionConfig(), "test", - { "x-codex-parent-thread-id": root, "thread-id": root }); + { requestHeaders: { "x-codex-parent-thread-id": root, "thread-id": root } }); // A small saved result still fits: exact pricing is not an across-the-board refusal. const small = advertiseInjection(x.socket, "small"); completeInjection(x.socket, { output: [small] }); @@ -371,7 +371,7 @@ test("a recovered native socket still routes continuations through admission", a const x = injectionClient( { multi_agent: undefined, input: [{ type: "reasoning", encrypted_content: "opaque-blob" }] }, { ...injectionConfig(), codexNativeSteering: true }, "test", - { "x-codex-parent-thread-id": root, "thread-id": root }); + { requestHeaders: { "x-codex-parent-thread-id": root, "thread-id": root } }); const first = await (async () => { await waitForInjection(() => InjectionSocket.all.length === 1 && InjectionSocket.all[0]!.frames.length === 1); return InjectionSocket.all[0]!; diff --git a/tests/responses/ws-native-injection.test.ts b/tests/responses/ws-native-injection.test.ts index 9001c1a7a1a..b91d822f996 100644 --- a/tests/responses/ws-native-injection.test.ts +++ b/tests/responses/ws-native-injection.test.ts @@ -5,6 +5,7 @@ import { } from "../helpers/native-injection-fixture"; import { configSchema } from "../../src/config/schema/config-schema"; import { getRequestLogEntries } from "../../src/server/request-log"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; import { createNativeSteeringLogObserver } from "../../src/server/responses/native-steering-log"; import { NativeInjectionChannel } from "../../src/server/responses/native-injection"; import { MAX_NATIVE_INJECTIONS, MAX_NATIVE_INJECTION_BYTES, injectionResults } from "../../src/server/responses/native-injection-protocol"; @@ -249,7 +250,15 @@ test("control failures are never sampled into the request log or counted as resp }); test("accepted function results survive ordinary subsequent delta turns; no user message is invented", async () => { - const { socket, send, sent, ws, id } = await beginInjection(); + const settings = injectionConfig(); + const localKey = "synthetic-injection-replay-owner"; + settings.apiKeys = [{ id: "injection-replay-owner", key: localKey, + name: "Injection replay owner", createdAt: "2026-10-01T00:00:00.000Z" }]; + const admission = resolveDataPlaneAdmissionSecret(localKey, settings); + expect(admission).not.toBeNull(); + // This handler fixture has no HTTP handshake; supply the admission that a real + // dedicated local key resolves to, separately from the upstream bearer. + const { socket, send, sent, ws, id } = await beginInjection({}, settings, "test", { admission: admission! }); const call = advertiseInjection(socket); send({ type: "response.inject", response_id: id, input: [savedResult("call-1", "accepted-result")] }); acknowledgeInjection(socket); completeInjection(socket, { output: [call] }); diff --git a/tests/responses/ws-native-steering.test.ts b/tests/responses/ws-native-steering.test.ts index 3d00d6627a6..e364312b8d9 100644 --- a/tests/responses/ws-native-steering.test.ts +++ b/tests/responses/ws-native-steering.test.ts @@ -3,6 +3,7 @@ import type { ServerWebSocket } from "bun"; import type { OcxConfig } from "../../src/types"; import { createWebsocketHandler } from "../../src/server/index/websocket-handler"; import type { ServeOptionsContext } from "../../src/server/index/serve-options"; +import { resolveDataPlaneAdmissionSecret, type DataPlaneAdmission } from "../../src/server/auth-cors"; import { NativeSteeringChannel, MAX_NATIVE_STEERS, validateSteeringFrame } from "../../src/server/responses/native-steering"; import { NativeSteeringReplay, MAX_NATIVE_STEERING_REPLAY_BYTES, nativeControlReplayRetainedStoreSnapshot, setNativeControlReplayTotalCapForTests } from "../../src/server/responses/native-steering-replay"; import { NativeInjectionReplay } from "../../src/server/responses/native-injection-replay"; @@ -58,11 +59,13 @@ const waitFor = async (condition: () => boolean) => { for (let i = 0; i < 1000; i++) { if (condition()) return; await Bun.sleep(1); } throw new Error("fixture condition timed out"); }; -function downstream(fields: Frame = {}, settings = config(), credential = "test") { +function downstream(fields: Frame = {}, settings = config(), credential = "test", admission?: DataPlaneAdmission) { releaseSpendHome ??= acquireOwnedSpendHome(); const handler = createWebsocketHandler({ config: settings, deps: {} } as ServeOptionsContext); const sent: Frame[] = []; - const ws = { readyState: 1, data: { headers: new Headers({ authorization: `Bearer ${credential}`, "thread-id": `fixture-${credential}`, session_id: `fixture-${credential}` }) } as WsData, + const ws = { readyState: 1, data: { headers: new Headers({ authorization: `Bearer ${credential}`, "thread-id": `fixture-${credential}`, session_id: `fixture-${credential}` }), + ...(admission ? { admission } : {}), + } as WsData, send: (text: string) => { sent.push(JSON.parse(text)); return 1; }, close() { handler.close(ws); }, } as unknown as ServerWebSocket; const send = (frame: Frame) => handler.message(ws, JSON.stringify(frame)); @@ -70,8 +73,8 @@ function downstream(fields: Frame = {}, settings = config(), credential = "test" send({ type: "response.create", model: "gpt-5.5", input: "initial", ...fields }); return { ws, sent, send, handler }; } -async function begin(fields: Frame = {}, credential = "test") { - const client = downstream(fields, config(), credential); +async function begin(fields: Frame = {}, credential = "test", settings = config(), admission?: DataPlaneAdmission) { + const client = downstream(fields, settings, credential, admission); await waitFor(() => client.sent.some(frame => frame.type === "response.created")); const socket = Socket.all.find(s => s.options.headers.authorization === `Bearer ${credential}`)!; expect(socket).toBeDefined(); @@ -192,7 +195,15 @@ test("pending results use one same-account/lane create and never replay accepted }); test("subsequent ordinary turns retain committed steering through the scoped replay cache", async () => { - const { ws, socket, send, id, sent } = await begin(); + const settings = config(); + const localKey = "synthetic-native-steering-replay-owner"; + settings.apiKeys = [{ id: "native-steering-replay-owner", key: localKey, + name: "Native steering replay owner", createdAt: "2026-10-01T00:00:00.000Z" }]; + const admission = resolveDataPlaneAdmissionSecret(localKey, settings); + expect(admission).not.toBeNull(); + // This direct handler fixture supplies verified local admission separately from + // the upstream bearer; the socket's own thread stays the same for both turns. + const { ws, socket, send, id, sent } = await begin({}, "test", settings, admission!); send({ type: "response.steer", previous_response_id: id, input: "committed instruction" }); accept(socket, id); complete(socket, id); socket.emit({ type: "response.created", response: { id: "cached-successor", previous_response_id: id } }); diff --git a/tests/responses/ws-steering-stability.test.ts b/tests/responses/ws-steering-stability.test.ts index d040484cf57..aa9be5ae47e 100644 --- a/tests/responses/ws-steering-stability.test.ts +++ b/tests/responses/ws-steering-stability.test.ts @@ -4,6 +4,7 @@ import { NativeSteeringChannel, NATIVE_STEERING_WAIT_MS as WAIT, import { NativeSteeringReplay, MAX_NATIVE_STEERING_REPLAY_BYTES as REPLAY_LIMIT } from "../../src/server/responses/native-steering-replay"; import { beginInjection, injectionConfig, installInjectionFixture, InjectionSocket, waitForInjection, fallbackCalls, type Frame } from "../helpers/native-injection-fixture"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; installInjectionFixture(); @@ -223,7 +224,14 @@ test("merged sparse output still enforces the unchanged serialized replay budget test("real steering handler preserves sparse output through automatic and ordinary successors", async () => { const settings = { ...injectionConfig(), codexNativeInjection: false, codexNativeSteering: true }; - const { ws, socket, send, sent, id } = await beginInjection({ multi_agent: undefined }, settings); + const localKey = "synthetic-steering-replay-owner"; + settings.apiKeys = [{ id: "steering-replay-owner", key: localKey, + name: "Steering replay owner", createdAt: "2026-10-01T00:00:00.000Z" }]; + const admission = resolveDataPlaneAdmissionSecret(localKey, settings); + expect(admission).not.toBeNull(); + // Direct handler fixtures supply the verified local admission separately from + // their upstream bearer; the default fixture remains keyless. + const { ws, socket, send, sent, id } = await beginInjection({ multi_agent: undefined }, settings, "test", { admission: admission! }); for (let index = 0; index < output.length; index++) { socket.emit({ type: "response.output_item.added", output_index: index, item: output[index] }); socket.emit({ type: "response.output_item.done", output_index: index, item: output[index] }); diff --git a/tests/server/codex-model-list-plaintext.test.ts b/tests/server/codex-model-list-plaintext.test.ts new file mode 100644 index 00000000000..63bab6b7699 --- /dev/null +++ b/tests/server/codex-model-list-plaintext.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync } from "node:fs"; +import { buildCodexModelList } from "../../src/server/codex-model-list"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome | undefined; +afterEach(() => { home?.remove(); home = undefined; }); + +for (const mode of ["v2-plaintext", "v1"] as const) { + test(`live catalog helper preserves ${mode} pins for native and routed rows`, () => { + home = createTempHome("ocx-catalog-plaintext-"); + mkdirSync(home.codexHome, { recursive: true }); + const result = buildCodexModelList({ + config: { + providers: {}, multiAgentMode: mode, keepNativeChatGptOnV1: true, + } as OcxConfig, + nativeSlugs: ["gpt-5.6-sol"], + availableAccountNativeSlugs: [], accountNativeSlugs: [], + accountNativeSlugsBySelector: new Map(), + goOrdered: [{ id: "child", provider: "fixture", displayName: "Fixture child" }], + exactComboSlugs: new Set(), accountSelectors: [], + suppressedBareNativeSlugs: new Set(), disabledModels: new Set(), + }); + for (const slug of ["gpt-5.6-sol", "fixture/child"]) { + const row = result.models.find(entry => entry.slug === slug); + expect(row).toBeDefined(); + expect(row!.multi_agent_version).toBe(mode === "v2-plaintext" ? "v2" : "v1"); + } + }); +} diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index f9a4c4915fa..e1f32f5f671 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -141,6 +141,39 @@ describe("sub-agent surface default and advisory", () => { expect(multiAgentSurfaceAdvisoryRequired({ multiAgentMode: "v1", multiAgentSurfaceAdvisoryVersion: 0 })).toBe(false); }); + test("v2-plaintext is silent whatever the stored acknowledgement", () => { + // The mode is itself the consent record, whether it arrived through the acknowledged + // API/CLI entry or a hand edit; the generic v1 recommendation must not re-ask it. + expect(multiAgentSurfaceAdvisoryRequired({ multiAgentMode: "v2-plaintext" })).toBe(false); + expect(multiAgentSurfaceAdvisoryRequired({ multiAgentMode: "v2-plaintext", multiAgentSurfaceAdvisoryVersion: 0 })).toBe(false); + expect(multiAgentSurfaceAdvisory({ multiAgentMode: "v2-plaintext" })).toMatchObject({ + required: false, + mode: "v2-plaintext", + }); + }); + + test("a stored unanswered advisory stays stored and resurfaces after leaving v2-plaintext", () => { + const config = { + ...getDefaultConfig(), + multiAgentMode: "v2-plaintext" as const, + multiAgentSurfaceAdvisoryVersion: 0, + }; + expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(false); + expect(multiAgentSurfaceAdvisoryRequired({ ...config, multiAgentMode: "v2" as const })).toBe(true); + expect(multiAgentSurfaceAdvisoryRequired({ ...config, multiAgentMode: "default" as const })).toBe(true); + }); + + test("a repaired v2-plaintext config is still v2-plaintext and silent", () => { + const stored = { ...getDefaultConfig(), multiAgentMode: "v2-plaintext" } as Record; + delete stored.defaultProvider; + delete stored.multiAgentSurfaceAdvisoryVersion; + writeConfig(stored); + + const loaded = loadConfig(); + expect(loaded.multiAgentMode).toBe("v2-plaintext"); + expect(multiAgentSurfaceAdvisoryRequired(loaded)).toBe(false); + }); + test("the fresh default survives save and load", () => { saveConfig(getDefaultConfig()); const loaded = loadConfig(); diff --git a/tests/server/data-plane-admission-identity.test.ts b/tests/server/data-plane-admission-identity.test.ts index b52abb24c5e..7182e9ee211 100644 --- a/tests/server/data-plane-admission-identity.test.ts +++ b/tests/server/data-plane-admission-identity.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { hasValidApiAuth, isDataPlaneAdmissionSecret, + isContextPrincipalCurrent, isProxyAdmissionSecret, requireResponsesApiAuth, resolveApiAuth, @@ -18,6 +19,8 @@ import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { buildResponsesWsData } from "../../src/server/ws-bridge"; import type { OcxConfig } from "../../src/types"; +import { OPENAI_PROVIDER_TIER_VERSION } from "../../src/types"; +import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; import { removeTreeWithRetry } from "../helpers/remove-tree"; // The admission path already knew WHICH key matched and threw it away. These @@ -135,6 +138,72 @@ describe("resolveDataPlaneAdmissionSecret", () => { }); }); +describe("retained loopback replay principal revalidation", () => { + const principalFor = (config: OcxConfig, key: string) => resolveContextPrincipal( + request({ "x-opencodex-api-key": key }), config, { kind: "loopback", source: "loopback" }, + ); + + test("key replacement, key-id replacement and deletion revoke the captured identity", () => { + const config = loopbackConfig(); + const key = config.apiKeys![0]!; + const original = principalFor(config, key.key); + expect(isContextPrincipalCurrent(original, config)).toBe(true); + key.name = "changed label"; + expect(isContextPrincipalCurrent(original, config)).toBe(true); + key.key = "ocx_data_replacement"; + expect(isContextPrincipalCurrent(original, config)).toBe(false); + const replacement = principalFor(config, key.key); + expect(isContextPrincipalCurrent(replacement, config)).toBe(true); + key.id = "replacement-key-id"; + expect(isContextPrincipalCurrent(replacement, config)).toBe(false); + const renamed = principalFor(config, key.key); + config.apiKeys = []; + expect(isContextPrincipalCurrent(renamed, config)).toBe(false); + expect(isContextPrincipalCurrent(undefined, config)).toBe(false); + }); + + test("pending rotation expires, while promotion preserves only the promoted secret", () => { + const config = loopbackConfig(); + const key = config.apiKeys![0]!; + const original = principalFor(config, key.key); + key.pendingRotation = { id: "rotation", key: "ocx_data_pending", + createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() }; + const pending = principalFor(config, key.pendingRotation.key); + expect(isContextPrincipalCurrent(pending, config)).toBe(true); + key.pendingRotation.expiresAt = new Date(Date.now() - 1).toISOString(); + expect(isContextPrincipalCurrent(pending, config)).toBe(false); + key.key = key.pendingRotation.key; + delete key.pendingRotation; + expect(isContextPrincipalCurrent(pending, config)).toBe(true); + expect(isContextPrincipalCurrent(original, config)).toBe(false); + }); + + test("environment replacement and credential-shadowing follow the admission resolver", () => { + const config = loopbackConfig(); + const secret = config.apiKeys![0]!.key; + const configured = principalFor(config, secret); + process.env.OPENCODEX_API_AUTH_TOKEN = secret; + const environment = principalFor(config, secret); + expect(environment).not.toBe(configured); + expect(isContextPrincipalCurrent(environment, config)).toBe(true); + expect(isContextPrincipalCurrent(configured, config)).toBe(false); + process.env.OPENCODEX_API_AUTH_TOKEN = "replacement-env-secret"; + expect(isContextPrincipalCurrent(environment, config)).toBe(false); + expect(isContextPrincipalCurrent(configured, config)).toBe(true); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + expect(isContextPrincipalCurrent(environment, config)).toBe(false); + }); + + test("an earlier configured duplicate cannot inherit the later key's identity", () => { + const config = loopbackConfig(); + const second = config.apiKeys![1]!; + const original = principalFor(config, second.key); + config.apiKeys![0]!.key = second.key; + expect(isContextPrincipalCurrent(original, config)).toBe(false); + expect(isContextPrincipalCurrent(principalFor(config, second.key), config)).toBe(true); + }); +}); + describe("no admission decision changed", () => { test.each([ ["configured key", "ocx_data_firstsecret", true], @@ -275,6 +344,11 @@ describe("the Responses WebSocket handshake", () => { process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret-for-ws"; const config = remoteConfig(); config.websockets = true; + // Keep this real handshake out of unrelated first-upgrade writes and native sync. + config.clientIntegrations = { codex: false }; + config.openaiProviderTierVersion = OPENAI_PROVIDER_TIER_VERSION; + config.subagentModelsVersion = SUBAGENT_MODELS_VERSION; + config.subagentModels = [...DEFAULT_SUBAGENT_MODELS]; saveConfig(config); const server = startServer(0); try { @@ -332,9 +406,14 @@ describe("the Responses WebSocket handshake", () => { // dropping `admission` from the payload fails here rather than passing a // socket-opened assertion that never looked at it. const headers = new Headers({ "x-forwarded-for": "ignored" }); - const payload = buildResponsesWsData(headers, { kind: "configured", keyId: "second-key", source: "dedicated" }); + const principal = { clientPrincipalId: "admitted-replay-owner" }; + const payload = buildResponsesWsData(headers, { kind: "configured", keyId: "second-key", source: "dedicated" }, + undefined, "lane", "codex_cli", principal); expect(payload.admission).toEqual({ kind: "configured", keyId: "second-key", source: "dedicated" }); expect(payload.headers).toBe(headers); + expect(payload.client).toBe("codex_cli"); + expect(payload.replayPrincipal).toBe(principal); + expect(payload.sessionLaneId).toBe("lane"); }); // The phase-2 guard that asserted no telemetry symbols existed yet has served diff --git a/tests/server/link-websocket-upgrade.test.ts b/tests/server/link-websocket-upgrade.test.ts index 15445d29a95..1e67d498b3f 100644 --- a/tests/server/link-websocket-upgrade.test.ts +++ b/tests/server/link-websocket-upgrade.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, readFileSync, statSync, utimesSync, watch, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHmac } from "node:crypto"; @@ -28,6 +28,27 @@ const LINK_A = "lnk_aaaaaaaaaaaaaaaa"; const LINK_B = "lnk_bbbbbbbbbbbbbbbb"; const ADMIN = "link-ws-admin-token-0123456789"; +async function waitForHolds(path: string, linkId: string, expected: number): Promise { + const deadline = Date.now() + 3_000; + for (;;) { + let contents: string | undefined; + try { contents = readFileSync(path, "utf8"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (contents !== undefined) { + const state: unknown = JSON.parse(contents); + if (!state || typeof state !== "object" || !("version" in state) || state.version !== 1 || !("keyId" in state) || (state.keyId !== null && typeof state.keyId !== "string") || !("links" in state) || !state.links || typeof state.links !== "object" || Array.isArray(state.links)) throw new Error("Malformed activity holds state"); + const entry = (state.links as Record)[linkId]; + if (entry !== undefined && (!entry || typeof entry !== "object" || !("holds" in entry) || !Array.isArray(entry.holds) || !("unknown" in entry))) throw new Error("Malformed activity holds entry"); + if (entry !== undefined && (entry as { unknown: unknown }).unknown !== null) throw new Error("Activity holds state is unknown"); + if ((entry === undefined ? 0 : (entry as { holds: unknown[] }).holds.length) === expected) return; + } + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${expected} activity holds`); + await Bun.sleep(25); + } +} + const identityA = { keyId: KEY_A_ID, linkId: LINK_A, fingerprint: serviceApiTokenFingerprint(KEY_A) }; const identityB = { keyId: KEY_B_ID, linkId: LINK_B, fingerprint: serviceApiTokenFingerprint(KEY_B) }; @@ -154,6 +175,21 @@ afterEach(async () => { testHome = ""; }); +test("hold state wait checks persisted state without notifications and propagates bad reads", async () => { + const path = join(testHome, "holds-probe.json"); + const waiting = waitForHolds(path, LINK_A, 1); + writeFileSync(path, JSON.stringify({ version: 1, keyId: null, links: { [LINK_A]: { holds: [{}], unknown: null } } })); + await waiting; + await waitForHolds(path, LINK_A, 1); + writeFileSync(path, "{broken"); + await expect(waitForHolds(path, LINK_A, 1)).rejects.toThrow(); + writeFileSync(path, JSON.stringify({ version: 1, keyId: null, links: { [LINK_A]: {} } })); + await expect(waitForHolds(path, LINK_A, 1)).rejects.toThrow("Malformed activity holds entry"); + writeFileSync(path, JSON.stringify({ version: 1, keyId: null, links: { [LINK_A]: { holds: [], unknown: "write-failed" } } })); + await expect(waitForHolds(path, LINK_A, 0)).rejects.toThrow("Activity holds state is unknown"); + await expect(waitForHolds(testHome, LINK_A, 1)).rejects.toThrow(); +}); + describe("link Responses WebSocket switched off (characterization)", () => { test.each([ ["websockets on, link switch unset", { linkResponsesWebsocket: undefined }], @@ -334,16 +370,12 @@ describe("link Responses WebSocket switched on", () => { const port = await startHome(); const holdsPath = join(testHome, "link", "activity-holds.json"); const holdsOf = () => (JSON.parse(readFileSync(holdsPath, "utf8")) as { links: Record }).links[LINK_A]?.holds.length ?? 0; - const holdsChanged = () => new Promise(resolve => { - const watcher = watch(join(testHome, "link"), (_event, name) => { if (name === "activity-holds.json") { watcher.close(); resolve(); } }); - }); const first = await client(port); const session = await first.challenge(identityA); expect((await first.upgrade({ nonce: session.nonce, bearer: KEY_A, headers: { "thread-id": "thread-hold" } })).status).toBe(101); await runTurn(first); - const written = holdsChanged(); first.destroy(); - await written; + await waitForHolds(holdsPath, LINK_A, 1); expect(holdsOf()).toBe(1); const second = await client(port); @@ -364,9 +396,8 @@ describe("link Responses WebSocket switched on", () => { expect(failed.map(frameType).some(type => type === "error" || type === "response.failed")).toBe(true); upstreamStatus = () => 200; expect(holdsOf()).toBe(1); - const released = holdsChanged(); await runTurn(second, returned); - await released; + await waitForHolds(holdsPath, LINK_A, 0); expect(holdsOf()).toBe(0); }); @@ -379,9 +410,6 @@ describe("link Responses WebSocket switched on", () => { const port = await startHome(); const holdsPath = join(testHome, "link", "activity-holds.json"); const holdsOf = () => (JSON.parse(readFileSync(holdsPath, "utf8")) as { links: Record }).links[LINK_A]?.holds.length ?? 0; - const holdsChanged = () => new Promise(resolve => { - const watcher = watch(join(testHome, "link"), (_event, name) => { if (name === "activity-holds.json") { watcher.close(); resolve(); } }); - }); const wire = await client(port); const session = await wire.challenge(identityA); expect((await wire.upgrade({ nonce: session.nonce, bearer: KEY_A, headers: { "thread-id": "thread-same" } })).status).toBe(101); @@ -397,9 +425,8 @@ describe("link Responses WebSocket switched on", () => { const failed = await wire.until(message => frameType(message) === "error" || frameType(message) === "response.failed"); expect(failed.map(frameType).some(type => type === "error" || type === "response.failed")).toBe(true); upstreamStatus = () => 200; - const written = holdsChanged(); wire.destroy(); - await written; + await waitForHolds(holdsPath, LINK_A, 1); expect(holdsOf()).toBe(1); }); diff --git a/tests/server/plaintext-v2-agent-messages-server.test.ts b/tests/server/plaintext-v2-agent-messages-server.test.ts index 223022a3a59..d41730532ca 100644 --- a/tests/server/plaintext-v2-agent-messages-server.test.ts +++ b/tests/server/plaintext-v2-agent-messages-server.test.ts @@ -12,8 +12,8 @@ import { PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE, PLAINTEXT_V2_COLLABORATION_NAMESPACE, } from "../../src/responses/plaintext-v2-agent-messages"; -import { clearResponseStateForTests, expandPreviousResponseInput } from "../../src/responses/state"; -import { handleResponses } from "../../src/server/responses"; +import { clearResponseStateForTests, expandPreviousResponseInput as expandLegacyResponseInput } from "../../src/responses/state"; +import { handleOwnedResponses as handleResponses, expandOwnedResponseInput as expandPreviousResponseInput } from "../helpers/response-replay-owner"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -168,6 +168,53 @@ function overLimitResponsePayload(id = "resp-plaintext-v2-overflow") { } describe("plaintext v2 agent messages at the Responses server boundary", () => { + test("the same existing V2 request follows the current compiler setting without deleting retained messages", async () => { + takeInheritedSpendHome(); + const existingMessage = { type: "agent_message", author: "prior-child", recipient: "existing-parent", + content: [{ type: "input_text", text: "Earlier retained task message" }] }; + const request = collaborationRequest({ input: [existingMessage] }); + request.headers.set("session_id", "existing-v2-compiler-scope"); + const originalBody = await request.clone().text(); + const cfg = { ...config(false), multiAgentMode: "v2" as OcxConfig["multiAgentMode"] }; + const sentBodies: Array> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin !== "https://chatgpt.com" || !url.pathname.endsWith("/responses")) { + throw new Error("Unexpected destination in compiler-scope fixture"); + } + const sent = JSON.parse(String(init?.body)); + sentBodies.push(sent); + const aliased = sent.tools[0].name === PLAINTEXT_V2_COLLABORATION_NAMESPACE; + const response = completedResponsePayload(`resp-existing-v2-${sentBodies.length}`); + if (!aliased) Object.assign(response.output[0]!, { namespace: "collaboration", name: "spawn_agent" }); + return new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + for (const mode of ["v2", "v2-plaintext", "v2"] as const) { + cfg.multiAgentMode = mode; + const response = await handleResponses(request.clone(), cfg, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"name":"spawn_agent"'); + if (mode === "v2" && sentBodies.length === 1) { + const legacy = expandLegacyResponseInput({ previous_response_id: "resp-existing-v2-1", input: [] }) as { input: unknown[] }; + expect(legacy.input).toContainEqual(existingMessage); + } + } + expect(sentBodies).toHaveLength(3); + expect(sentBodies.map(body => body.tools[0].name)).toEqual([ + "collaboration", PLAINTEXT_V2_COLLABORATION_NAMESPACE, "collaboration", + ]); + expect(sentBodies.map(body => body.tools[0].tools[0].parameters.properties.message.encrypted)) + .toEqual([true, undefined, true]); + for (const body of sentBodies) expect(body.input).toContainEqual(existingMessage); + expect(cfg.plaintextV2AgentMessages).toBe(false); + expect(await request.text()).toBe(originalBody); + const replay = expandPreviousResponseInput({ previous_response_id: "resp-existing-v2-2", input: [] }) as { input: unknown[] }; + expect(replay.input).toContainEqual(existingMessage); + expect(JSON.stringify(replay.input)).toContain("plain assignment"); + }); + test.each(["json", "legacy-tee", "eager-relay"] as const)("null namespace restores before %s delivery and continuation storage", async mode => { takeInheritedSpendHome(); const id = `resp-null-namespace-${mode}`; @@ -188,7 +235,7 @@ describe("plaintext v2 agent messages at the Responses server boundary", () => { expect(JSON.stringify(replay)).not.toContain("start_delegated_task"); }); - test("rewrites the canonical request and restores every SSE response snapshot", async () => { + test.each(["legacy-flag", "v2-plaintext"] as const)("rewrites the canonical request and restores every SSE response snapshot (%s)", async selection => { takeInheritedSpendHome(); const sentBodies: string[] = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -216,7 +263,7 @@ describe("plaintext v2 agent messages at the Responses server boundary", () => { const response = await handleResponses( collaborationRequest(), - config(true), + selection === "v2-plaintext" ? { ...config(false), multiAgentMode: selection } : config(true), { model: "", provider: "" }, ); const clientBody = await response.text(); @@ -831,7 +878,7 @@ describe("plaintext v2 agent messages at the Responses server boundary", () => { }); }); - test.each([undefined, "websocket"] as const)("stores the client namespace across an option change on %s", async inboundTransport => { + test.each([undefined, "websocket"] as const)("stores the client namespace and refuses replay after disabling plaintext on %s", async inboundTransport => { takeInheritedSpendHome(); const sentBodies: string[] = []; let requestIndex = 0; @@ -859,16 +906,23 @@ describe("plaintext v2 agent messages at the Responses server boundary", () => { previousResponseId: "resp-toggle-plaintext-v2", input: [{ type: "function_call_output", call_id: "call-spawn", output: "done" }], }), - config(false), + config(true), { model: "", provider: "" }, { inboundWire: "responses", inboundTransport }, ); await second.text(); - const replay = JSON.parse(sentBodies[1]!) as { input: Array> }; + const replay = expandPreviousResponseInput({ previous_response_id: "resp-toggle-plaintext-v2", input: [] }) as { input: Array> }; const replayedCall = replay.input.find(item => item.type === "function_call"); expect(replayedCall?.namespace).toBe("collaboration"); expect(JSON.stringify(replay)).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + const disabled = await handleResponses( + collaborationRequest({ previousResponseId: "resp-toggle-plaintext-v2" }), + config(false), { model: "", provider: "" }, { inboundWire: "responses", inboundTransport }, + ); + expect(disabled.status).toBe(400); + expect(sentBodies).toHaveLength(2); + await disabled.text(); }); }); diff --git a/tests/server/response-replay-combo-identity.test.ts b/tests/server/response-replay-combo-identity.test.ts new file mode 100644 index 00000000000..96f606ad1d7 --- /dev/null +++ b/tests/server/response-replay-combo-identity.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { clearResponseStateForTests, rememberResponseState } from "../../src/responses/state"; +import { responseReplayOwnerKey } from "../../src/responses/state/replay-owner"; +import { contextPrincipalIdOf, resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { executeComboResponses } from "../../src/server/responses/core-combo"; +import { bindProviderContinuationForRoute } from "../../src/server/responses/core-replay"; +import type { OcxConfig, OcxParsedRequest, OcxProviderContinuationOwner } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +let home: string; +let previousHome: string | undefined; +let release: () => void; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-combo-replay-id-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + clearResponseStateForTests(); + globalThis.fetch = (async () => { throw new Error("The replay fixture must not use the network"); }) as typeof fetch; +}); +afterEach(() => { + globalThis.fetch = originalFetch; + clearResponseStateForTests(); + release(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +for (const padded of [true, false]) { + test(`combo retains the authorized ${padded ? "padded" : "un-padded"} response ID for provider state`, async () => { + const config: OcxConfig = { + defaultProvider: "primary", multiAgentMode: "v2-plaintext", + providers: { primary: { adapter: "openai-chat", authMode: "key", apiKey: "synthetic-provider", + baseUrl: "https://fixture.invalid/v1", models: ["model"] } }, + apiKeys: [{ id: "a", key: "synthetic-a" }, { id: "b", key: "synthetic-b" }], + combos: { fan: { strategy: "failover", targets: [{ provider: "primary", model: "model" }] } }, + }; + const foreignId = padded ? "resp-exact" : " resp-exact "; + const ownedId = padded ? " resp-exact " : "resp-exact"; + // Both callers use the same provider account: the route fence cannot replace caller ownership. + const providerOwner: OcxProviderContinuationOwner = { version: 1, providerName: "primary", + providerDestinationIdentity: `destination:${"a".repeat(64)}`, adapterName: "openai-chat", + modelId: "model", credentialIdentity: `key:${"b".repeat(64)}` }; + for (const [id, key, thread, text] of [ + [foreignId, "synthetic-a", "own-a", "foreign-state"], + [ownedId, "synthetic-b", "own-b", "owned-state"], + ]) { + const admission = resolveDataPlaneAdmissionSecret(key!, config)!; + rememberResponseState({ input: `${text}-input` }, { id, output: [], status: "completed" }, + { __ocxOwner: providerOwner, kiro: { conversationId: `${text}-continuation` } }, + { clientThreadId: thread, replayOwnerKey: responseReplayOwnerKey(contextPrincipalIdOf(admission), thread) }); + } + const body = { model: "combo/fan", input: "continue", stream: false, previous_response_id: ownedId }; + const req = new Request("http://localhost/v1/responses", { method: "POST", headers: { + "content-type": "application/json", "thread-id": "own-b", "x-opencodex-api-key": "synthetic-b", + }, body: JSON.stringify(body) }); + const translatorBudget = createTranslatorBudget(); + let childCalls = 0; + try { + const response = await executeComboResponses(req, body, "fan", config, { model: "", provider: "" }, + { admission: resolveDataPlaneAdmissionSecret("synthetic-b", config)!, translatorBudget }, { + async handleResponses(_req, _config, _log, options) { + childCalls++; + const snapshot = options?.comboReplaySnapshot; + expect(JSON.stringify(snapshot?.sourceBody)).toContain("owned-state-input"); + expect(JSON.stringify(snapshot?.sourceBody)).not.toContain("foreign-state-input"); + const parsed = { _providerContinuationCandidate: snapshot?.providerContinuation } as OcxParsedRequest; + bindProviderContinuationForRoute(parsed, providerOwner); + expect(parsed._providerContinuation?.kiro?.conversationId).toBe("owned-state-continuation"); + return Response.json({ id: "resp-next", object: "response", status: "completed", output: [] }); + }, + async handleComboResponses() { throw new Error("Unexpected nested combo"); }, + }); + expect(response.status).toBe(200); + expect(childCalls).toBe(1); + } finally { + translatorBudget.dispose(); + } + }); +} + +// The merge of dev's Remote Link observation with this PR's replay owner key left one ordering +// guarantee unproven: a continuation refused for ownership must never reach the link observer, so a +// foreign caller cannot even leave a capture for the returned turn. Both halves are asserted, so the +// refusal test cannot pass by observing nothing at all. +test("a refused replay owner never reaches the link observer, while the owner does", async () => { + const config: OcxConfig = { + defaultProvider: "primary", multiAgentMode: "v2-plaintext", + providers: { primary: { adapter: "openai-chat", authMode: "key", apiKey: "synthetic-provider", + baseUrl: "https://fixture.invalid/v1", models: ["model"] } }, + apiKeys: [{ id: "a", key: "synthetic-a" }, { id: "b", key: "synthetic-b" }], + combos: { fan: { strategy: "failover", targets: [{ provider: "primary", model: "model" }] } }, + }; + rememberResponseState({ input: "stored-input" }, { id: "resp_owned", output: [], status: "completed" }, + undefined, { clientThreadId: "own-b", replayOwnerKey: responseReplayOwnerKey( + contextPrincipalIdOf(resolveDataPlaneAdmissionSecret("synthetic-b", config)!), "own-b") }); + const run = async (apiKey: string, thread: string) => { + const observed: unknown[] = []; + let failed = 0; + const body = { model: "combo/fan", input: "continue", stream: false, previous_response_id: "resp_owned" }; + const req = new Request("http://localhost/v1/responses", { method: "POST", headers: { + "content-type": "application/json", "thread-id": thread, "x-opencodex-api-key": apiKey, + }, body: JSON.stringify(body) }); + const translatorBudget = createTranslatorBudget(); + try { + const response = await executeComboResponses(req, body, "fan", config, { model: "", provider: "" }, + { admission: resolveDataPlaneAdmissionSecret(apiKey, config)!, translatorBudget, linkTurn: { + expanded() { observed.push("expanded"); }, + expansionFailed() { failed += 1; }, + terminal() {}, + logFields() { return { transport: "link", first: false } as never; }, + } }, { + async handleResponses() { return Response.json({ id: "resp-next", object: "response", status: "completed", output: [] }); }, + async handleComboResponses() { throw new Error("Unexpected nested combo"); }, + }); + return { status: response.status, observed, failed }; + } finally { + translatorBudget.dispose(); + } + }; + // A different caller on the same thread is refused before any observation or upstream dispatch. + const foreign = await run("synthetic-a", "own-b"); + expect(foreign.status).toBe(400); + expect(foreign.observed).toEqual([]); + expect(foreign.failed).toBe(1); + // The owner advances, so the assertions above describe a real refusal rather than a dead fixture. + const owner = await run("synthetic-b", "own-b"); + expect(owner.status).toBe(200); + expect(owner.observed).toEqual(["expanded"]); + expect(owner.failed).toBe(0); +}); diff --git a/tests/server/response-replay-ownership.test.ts b/tests/server/response-replay-ownership.test.ts new file mode 100644 index 00000000000..37554601880 --- /dev/null +++ b/tests/server/response-replay-ownership.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses } from "../../src/server/responses"; +import { clearResponseStateForTests } from "../../src/responses/state"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; +import { resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests } from "../../src/codex/subagent-model-fallback"; + +const originalFetch = globalThis.fetch; +const originalCatalog = process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE; +let release: () => void; +let home: string; +let previousHome: string | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-replay-handler-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE = "fresh"; + clearResponseStateForTests(); +}); +afterEach(() => { + resetSubagentModelFallbackStateForTests(); + globalThis.fetch = originalFetch; + if (originalCatalog === undefined) delete process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE; + else process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE = originalCatalog; + release(); + try { clearResponseStateForTests(); } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } +}); + +function config(mode: "v1" | "v2-plaintext", combo = false): OcxConfig { + return { + defaultProvider: "native", multiAgentMode: mode, + providers: { native: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward", upstreamWebsocket: false } }, + apiKeys: [{ id: "a", key: "synthetic-a" }, { id: "b", key: "synthetic-b" }], + ...(combo ? { combos: { owned: { strategy: "failover", targets: [{ provider: "native", model: "gpt-5.6-sol" }] } } } : {}), + }; +} +for (const combo of [false, true]) for (const initiallyEnabled of [false, true]) { + test(`${combo ? "combo" : "direct"}: request mode remains bounded across quota wait`, async () => { + const cfg = config(initiallyEnabled ? "v2-plaintext" : "v1", combo); + const sent = upstream(); + let resume!: () => void; + let entered!: () => void; + const waiting = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { resume = resolve; }); + setSubagentQuotaPrimeForTests(async () => { entered(); await gate; }); + const req = request("own-a", "synthetic-a", "shared-parent", undefined, combo); + req.headers.set("x-openai-subagent", "collab_spawn"); + const pending = handleResponses(req, cfg, { model: "", provider: "" }, { + admission: resolveDataPlaneAdmissionSecret("synthetic-a", cfg)!, inboundWire: "responses", + }); + await waiting; + cfg.multiAgentMode = initiallyEnabled ? "v1" : "v2-plaintext"; + resume(); + const response = await pending; + await response.text(); + expect(response.status).toBe(200); + expect(sent).toHaveLength(1); + const body = JSON.parse(sent[0]!) as { tools: Array<{ name: string }> }; + expect(body.tools[0]?.name).not.toBe("collaboration-optimize"); + if (initiallyEnabled) { + expect((await send(cfg, "own-a", "synthetic-b", "shared-parent", "resp-owner-a")).status).toBe(400); + expect(sent).toHaveLength(1); + expect((await send(cfg, "own-a", "synthetic-a", "shared-parent", "resp-owner-a")).status).toBe(400); + expect(sent).toHaveLength(1); + cfg.multiAgentMode = "v2-plaintext"; + expect((await send(cfg, "own-a", "synthetic-a", "shared-parent", "resp-owner-a")).status).toBe(200); + expect(sent).toHaveLength(2); + } + }); +} +function request(own: string | undefined, key: string, parent?: string, previous?: string, combo = false): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", + ...(own ? { "thread-id": own } : {}), "x-opencodex-api-key": key, + ...(parent ? { "x-codex-parent-thread-id": parent } : {}) }, + body: JSON.stringify({ model: combo ? "combo/owned" : "native/gpt-5.6-sol", store: false, stream: true, + ...(previous ? { previous_response_id: previous } : {}), + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "synthetic public request" }] }], + tools: [{ type: "namespace", name: "collaboration", tools: [{ type: "function", name: "spawn_agent", + parameters: { type: "object", properties: { message: { type: "string", encrypted: true } } } }] }], + }), + }); +} +function upstream(): string[] { + const sent: string[] = []; + globalThis.fetch = (async (_url, init) => { + sent.push(String(init?.body)); + if (sent.length > 2) throw new Error("unexpected third upstream request"); + return Response.json({ id: sent.length === 1 ? "resp-owner-a" : "resp-owner-b", status: "completed", + output: sent.length === 1 ? [{ type: "function_call", call_id: "synthetic-call", namespace: "collaboration-optimize", + name: "start_delegated_task", arguments: JSON.stringify({ message: "SYNTHETIC_PRIVATE_TASK" }), encrypted_function_args: [] }] : [] }); + }) as typeof fetch; + return sent; +} +async function send(cfg: OcxConfig, own: string | undefined, key: string, parent?: string, previous?: string): Promise { + const admission = resolveDataPlaneAdmissionSecret(key, cfg); + expect(admission?.kind).toBe(key === "synthetic-unadmitted" ? undefined : "configured"); + const response = await handleResponses(request(own, key, parent, previous, !!cfg.combos), cfg, { model: "", provider: "" }, { + ...(admission ? { admission } : {}), inboundWire: "responses", + }); + await response.text(); + return response; +} +for (const combo of [false, true]) for (const mode of ["v2-plaintext", "flag"] as const) { + const label = `${combo ? "combo" : "direct"} ${mode}`; + const strictConfig = () => mode === "flag" + ? { ...config("v1", combo), plaintextV2AgentMessages: true } + : config("v2-plaintext", combo); + for (const [scenario, secondKey, parent] of [ + ["different callers without parent headers", "synthetic-b", undefined], + ["sibling own threads under a shared parent", "synthetic-a", "shared-parent"], + ["different callers supplying the same parent", "synthetic-b", "shared-parent"], + ] as const) { + test(`${label}: refuses retained replay across ${scenario} before dispatch`, async () => { + const cfg = strictConfig(); + const sent = upstream(); + expect((await send(cfg, "own-a", "synthetic-a", parent)).status).toBe(200); + const second = await send(cfg, "own-b", secondKey, parent, "resp-owner-a"); + expect(second.status).toBe(400); + expect(sent).toHaveLength(1); + expect(sent[1] ?? "").not.toContain("SYNTHETIC_PRIVATE_TASK"); + }); + } + test(`${label}: caller ownership is required even when the own thread matches`, async () => { + const cfg = strictConfig(); + const sent = upstream(); + expect((await send(cfg, "own-a", "synthetic-a", "shared-parent")).status).toBe(200); + expect((await send(cfg, "own-a", "synthetic-b", "shared-parent", "resp-owner-a")).status).toBe(400); + expect(sent).toHaveLength(1); + }); + test(`${label}: same caller and own thread can replay without a parent header`, async () => { + const cfg = strictConfig(); + const sent = upstream(); + expect((await send(cfg, "own-a", "synthetic-a")).status).toBe(200); + expect((await send(cfg, "own-a", "synthetic-a", undefined, "resp-owner-a")).status).toBe(200); + expect(sent).toHaveLength(2); + expect(sent[1]).toContain("SYNTHETIC_PRIVATE_TASK"); + expect(sent.join("")).not.toContain("synthetic-a"); + }); + for (const missing of ["principal", "own thread"] as const) { + test(`${label}: missing ${missing} cannot authorize a retained continuation`, async () => { + const cfg = strictConfig(); + const sent = upstream(); + const own = missing === "own thread" ? undefined : "own-a"; + const key = missing === "principal" ? "synthetic-unadmitted" : "synthetic-a"; + expect((await send(cfg, own, key, "shared-parent")).status).toBe(200); + expect((await send(cfg, own, key, "shared-parent", "resp-owner-a")).status).toBe(400); + expect(sent).toHaveLength(1); + expect(sent[1] ?? "").not.toContain("SYNTHETIC_PRIVATE_TASK"); + }); + } +} + +for (const combo of [false, true]) { + for (const strict of ["mode", "flag"] as const) test(`${combo ? "combo" : "direct"}: ${strict} transitions cannot cross replay ownership boundaries`, async () => { + const enabled = () => strict === "flag" + ? { ...config("v1", combo), plaintextV2AgentMessages: true } + : config("v2-plaintext", combo); + const sent = upstream(); + expect((await send(enabled(), "own-a", "synthetic-a", "shared-parent")).status).toBe(200); + expect((await send(config("v1", combo), "own-a", "synthetic-a", "shared-parent", "resp-owner-a")).status).toBe(400); + expect(sent).toHaveLength(1); + clearResponseStateForTests(); + expect((await send(config("v1", combo), "own-a", "synthetic-a", "shared-parent")).status).toBe(200); + expect((await send(enabled(), "own-a", "synthetic-a", "shared-parent", "resp-owner-b")).status).toBe(400); + expect(sent).toHaveLength(2); + }); + test(`${combo ? "combo" : "direct"} v1: parent scope follows legacy behavior`, async () => { + const sent = upstream(); + expect((await send(config("v1", combo), "own-a", "synthetic-a", "shared-parent")).status).toBe(200); + expect((await send(config("v1", combo), "own-b", "synthetic-b", "different-parent", "resp-owner-a")).status).toBe(400); + expect((await send(config("v1", combo), "own-b", "synthetic-b", "shared-parent", "resp-owner-a")).status).toBe(200); + expect(sent).toHaveLength(2); + expect(sent[1]).toContain("SYNTHETIC_PRIVATE_TASK"); + }); + test(`${combo ? "combo" : "direct"} v1: default loopback expands a continuation without caller identity`, async () => { + const cfg = { ...config("v1", combo), apiKeys: undefined }; + const sent: string[] = []; + globalThis.fetch = (async (_url, init) => { + sent.push(String(init?.body)); + return Response.json({ id: `resp-default-${sent.length}`, status: "completed", output: [{ + type: "message", role: "assistant", content: [{ type: "output_text", text: "previous reply" }], + }] }); + }) as typeof fetch; + const sendLoopback = async (previous?: string) => { + const req = request(undefined, "", undefined, previous, combo); + req.headers.delete("x-opencodex-api-key"); + const response = await handleResponses(req, cfg, { model: "", provider: "" }, { inboundWire: "responses" }); + await response.text(); + return response; + }; + expect((await sendLoopback()).status).toBe(200); + expect((await sendLoopback("resp-default-1")).status).toBe(200); + expect(sent).toHaveLength(2); + expect(sent[1]).toContain("previous reply"); + expect(sent[1]).not.toContain("previous_response_id"); + }); +} diff --git a/tests/windows/windows-config-root-async-writers.test.ts b/tests/windows/windows-config-root-async-writers.test.ts index 8db6b2dbde5..2ba7152502f 100644 --- a/tests/windows/windows-config-root-async-writers.test.ts +++ b/tests/windows/windows-config-root-async-writers.test.ts @@ -21,9 +21,11 @@ import { resetHardenedStateForTests, setAsyncIcaclsBeltSchedulerForTests, setAsy setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, windowsSecretAclReapPendingForPath, type IcaclsResult } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; +import { setWindowsOwnerAclRunnerForTests, setWindowsOwnerAclAsyncRunnerForTests } from "../../src/lib/windows-owner-acl"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const principalSid = "S-1-5-21-1-2-3-1001"; const turn = () => new Promise(done => setImmediate(done)); function deferred() { let resolve!: () => void; @@ -44,6 +46,7 @@ beforeEach(() => { previous = keys.map(key => process.env[key]); process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + // Keep file icacls hooks observable even when owner-verification fixtures are private. original = JSON.stringify(fixture()); writeFileSync(join(root, "config.json"), original); writeFileSync(join(root, "input.json"), JSON.stringify({ ...fixture(), effortCap: "high" })); @@ -53,13 +56,24 @@ beforeEach(() => { const hook = spyOn(console, method).mockImplementation(() => {}); restores.push(() => hook.mockRestore()); } resetHardenedStateForTests(); setPlatformForTests("win32"); - setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); setIcaclsRunnerForTests(() => success); + setSyntheticWindowsPrincipalForTests(`*${principalSid}`); setIcaclsRunnerForTests(() => success); + // Admission ordering is simulated: neither ACL backend may launch PowerShell. + // A strict async directory probe must request hardening so holdParent owns the flight. + setWindowsOwnerAclAsyncRunnerForTests(async () => ({ success: false, timedOut: false, stdout: "" })); + setWindowsOwnerAclRunnerForTests(entries => ({ ...success, stdout: [ + `U|${principalSid}|${principalSid}|False`, + ...entries.flatMap((entry, index) => [ + `E|${index}|${principalSid}|4100|1`, + `A|${index}|0|${entry.directory ? 3 : 0}|2032127|${principalSid}|False`, + ]), "END", "", + ].join("\n") })); }); afterEach(async () => { await paths.flushConfigDirHardeningAndReaps(root); for (const restore of restores.reverse()) restore(); setAsyncIcaclsRunnerForTests(null); setAsyncIcaclsBeltSchedulerForTests(null); setNowForTests(null); setIcaclsRunnerForTests(null); setPlatformForTests(null); + setWindowsOwnerAclRunnerForTests(null); setWindowsOwnerAclAsyncRunnerForTests(null); setSyntheticWindowsPrincipalForTests(null); resetHardenedStateForTests(); keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index];