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 f88ba9c10b..70622aede9 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -138,6 +138,16 @@ Recovery options are to select a native ChatGPT child, add a native ChatGPT targ v1 for heterogeneous-provider delegation, or resend the task as plaintext v2 `agent_message` content when you control the caller. +The experimental `plaintextV2AgentMessages: true` option attempts to prevent application-layer +encryption for eligible new native ChatGPT v2 tool calls. It assigns request-scoped aliases to the +namespace and three reserved message-tool names, removes the message marker, and restores the +original identities in the response. It handles +`spawn_agent`, `send_message`, and `followup_task` and adds no recovery request. HTTPS remains +encrypted, but task text can be retained in Codex history, routed-provider requests, and local +response/debug state. Existing ciphertext is unchanged, and the option depends on undocumented +ChatGPT and Codex behavior. See +[Agent configuration: Plaintext v2 agent messages](/reference/configuration/agents/#plaintext-v2-agent-messages). + An experimental, disabled-by-default `agentTaskRecovery` option can recover this specific native- to-routed shape through a raw Responses passthrough to the fixed ChatGPT `/responses` endpoint using the incoming credential shape used by the canonical `openai` provider with `authMode: "forward"`. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 76e0a23927..ebfcf20d49 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -23,6 +23,7 @@ routes, and limits delegated work. | `subagentModelFallbackPollMs?` | `number` | `60000` | Availability-probe cache interval. Values below 1000 ms fall back to the default. | | `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` | `false` | Experimental opt-in that asks 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 @@ -120,6 +121,42 @@ fails instead of routing unreadable ciphertext elsewhere. } ``` +## Plaintext v2 agent messages + +`plaintextV2AgentMessages` is an experimental, disabled-by-default alternative to post-encryption +recovery. On a v2 Responses request whose final destination is the canonical ChatGPT backend, +opencodex recognizes the v2 catalog by a top-level `collaboration` namespace with a direct +`spawn_agent` child. It removes `parameters.properties.message.encrypted: true`, when present, only +from `spawn_agent`, `send_message`, and `followup_task`. ChatGPT reserves both the `collaboration` +namespace and those three tool names, so the request uses fixed private aliases for all four +identities. OpenCodex restores the original namespace and tool names in JSON, SSE, and WebSocket +responses before Codex receives the tool call. The `encrypted_function_args: []` field is preserved +so compatible Codex clients recognize the message as plaintext. + +This path adds no recovery request and therefore does not spend the extra ChatGPT quota used by a +cache miss in `agentTaskRecovery`. It cannot change tasks that are already encrypted. If the request +already declares the private alias or a conflicting reference, opencodex leaves that request +unchanged; separately enabled recovery can still handle a routed task that is later encrypted. If +ChatGPT rejects or ignores the modified schema, or the Codex client does not recognize the plaintext +response fields, the call can fail. OpenCodex does not retry the parent request with the original +schema because doing so could duplicate quota use or tool calls. + +For successfully rewritten calls, the option removes application-layer encryption from agent +message arguments. HTTPS still encrypts network transport, but message text can appear in Codex +task history, routed-provider requests, `responses-state.json` or its spill files, and +`usage-debug.jsonl` when debug capture is enabled. The behavior depends on undocumented ChatGPT +schema and response fields and may stop working after a backend or client update. Startup prints a +warning while it is enabled. + +```json +{ + "plaintextV2AgentMessages": true +} +``` + +The equivalent CLI command is `ocx config set plaintextV2AgentMessages true`. Restart the proxy +after changing the setting. + ## Encrypted v2 task recovery `agentTaskRecovery` is an experimental compatibility path for a native ChatGPT parent spawning a 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 3882d15c9a..9ff636b9e5 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 @@ -86,6 +86,13 @@ opencodex 会安全失败,而不是转发空任务或不可读任务: 恢复选项是选择原生 ChatGPT 子级、在 combo 中添加原生 ChatGPT 目标、在异构 provider 委派中使用 v1,或者在你控制调用方时将任务作为明文 v2 `agent_message` 内容重新发送。 +实验性的 `plaintextV2AgentMessages: true` 会尝试取消符合条件的新原生 ChatGPT v2 工具调用的 +应用层加密。它临时改写 namespace 和三个保留工具名,并删除消息字段的加密标记;响应返回 Codex +前会恢复原 namespace 与工具名。它处理 `spawn_agent`、`send_message` 和 `followup_task`,不会增加恢复请求。 +HTTPS 仍会加密网络传输,但任务文字可能保存在 Codex 历史、外部模型请求和本地响应或调试文件中。 +已有密文不会改变。该选项依赖 ChatGPT 和 Codex 未公开的行为。详见 +[明文 v2 代理消息](/zh-cn/reference/configuration/agents/#明文-v2-代理消息)。 + 实验性的 `agentTaskRecovery` 默认关闭。显式启用后,它可以通过向固定 ChatGPT 端点发送额外的认证请求来恢复这种格式,但会消耗配额、增加延迟,并依赖非公开的后端行为。任何失败都会保留原有的 `unreadable_encrypted_agent_task` 错误。详见[英文配置参考](/reference/configuration/agents/#encrypted-v2-task-recovery)。 ## 更改模式 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 0afa4603fe..322ad4c3ba 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 @@ -21,6 +21,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | `subagentModelFallbackPollMs?` | `number` | `60000` | 可用性探测缓存间隔。低于 1000 ms 的值会回退到默认值。 | | `effortCap?` | `string` | — | 对符合条件的 v2 主轮次和标记的派生子轮次设置硬上限。接受 `low` 到 `ultra`。 | | `subagentEffortCap?` | `string` | — | 仅针对派生子轮次的额外上限。两个上限同时适用时,较低者生效。 | +| `plaintextV2AgentMessages?` | `boolean` | `false` | 实验性选项。启用后,opencodex 会尝试让符合条件的新 `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]` 下。 @@ -70,6 +71,31 @@ opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或 } ``` +## 明文 v2 代理消息 + +`plaintextV2AgentMessages` 默认关闭。opencodex 只识别顶层 `collaboration` namespace,而且它必须直接包含 +`spawn_agent`。原生 ChatGPT 收到这类 v2 Responses 请求前,opencodex 会删除 `spawn_agent`、 +`send_message` 和 `followup_task` 中已有的 `parameters.properties.message.encrypted: true`。 +ChatGPT 会按保留的 `collaboration` namespace 和三个工具名处理消息,因此请求会给这四个名称使用固定的 +临时别名。opencodex 在 JSON、SSE 和 WebSocket 响应中恢复原名称,并保留 +`encrypted_function_args: []`,让兼容的 Codex 客户端把参数识别为明文。 + +这个选项不会增加恢复请求,也不会使用 `agentTaskRecovery` 在缓存未命中时产生的额外 ChatGPT +配额。它只能影响新工具调用,不能修改已有密文。请求已占用私有名称或有冲突引用时,opencodex 会保持该请求不变;若它后来生成加密的路由子任务,单独启用的 `agentTaskRecovery` 仍可处理。ChatGPT 拒绝或忽略修改后的 schema,或 Codex 客户端不识别明文响应字段时,调用可能失败。opencodex 不会用原 schema 自动重发父请求,因为重发可能重复消耗配额或重复执行工具。 + +成功改写后,这个选项会取消代理消息参数的应用层加密。HTTPS 仍会加密网络传输,但消息文字可能出现在 Codex +任务历史、外部模型请求、`responses-state.json` 及其 spill 文件,以及启用调试记录时的 +`usage-debug.jsonl`。该行为依赖 ChatGPT 未公开的 schema 和响应字段,后端或客户端更新后可能失效。 +服务启动时会打印警告。 + +```json +{ + "plaintextV2AgentMessages": true +} +``` + +等价命令是 `ocx config set plaintextV2AgentMessages true`。修改后重启代理。 + ## Effort 上限 上限只适用于 v2 协作功能:当主轮次的工具暴露 v2 时,它就符合条件;当子轮次在 `x-codex-turn-metadata` 中带有 codex-rs 的精确 `x-openai-subagent: collab_spawn` 或 `"subagent_kind": "thread_spawn"` 标记时,它也符合条件,即使叶子工具已经不再暴露协作。V1 主轮次、`multiAgentMode: "v1"`、压缩、审查以及记忆整合轮次都会绕过上限。 diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 2cd481dfba..e92ecc1844 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -76,6 +76,8 @@ export interface AdapterRequest { convertedRoutedToolSearchNames?: ReadonlySet; /** Upstream-only aliases for namespace tools flattened in this request. */ convertedRoutedNamespaceToolAliases?: ReadonlyMap; + /** Request-declared collaboration child names eligible for plaintext-v2 alias restoration. */ + plaintextV2AgentMessageToolNames?: ReadonlySet; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c304e6823f..6755d6ece8 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -18,6 +18,7 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; +import { preparePlaintextV2AgentMessages } from "../responses/plaintext-v2-agent-messages"; import { openaiResponsesUrl } from "./openai-responses-url"; import { normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { @@ -1767,6 +1768,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let routedCustomToolRepairNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; let convertedRoutedNamespaceToolAliases: Map | undefined; + let plaintextV2AgentMessageToolNames: ReadonlySet | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -1858,6 +1860,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { outBody = buildRoutedCompactionBody(outBody); } + if ( + parsed._plaintextV2AgentMessages === true + && isCanonicalOpenAiForwardProvider(provider) + ) { + const prepared = preparePlaintextV2AgentMessages(outBody); + outBody = prepared.body; + plaintextV2AgentMessageToolNames = prepared.namespaceAliased + ? prepared.toolNames + : undefined; + } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( outBody, @@ -1901,6 +1913,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), + ...(plaintextV2AgentMessageToolNames ? { plaintextV2AgentMessageToolNames } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/config.ts b/src/config.ts index 1308b3a64b..1dd3383395 100644 --- a/src/config.ts +++ b/src/config.ts @@ -894,6 +894,8 @@ const configSchema = z.object({ providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), + // Invalid hand edits disable only this experimental plaintext path. + plaintextV2AgentMessages: z.boolean().optional().catch(undefined), // Invalid optional recovery config must not discard unrelated provider/account state. agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), // These selections pre-date schema validation and used to pass through as @@ -1677,6 +1679,19 @@ function warnDegradedAgentTaskRecovery(rawParsed: unknown): void { if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); } +function malformedPlaintextV2AgentMessagesWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "plaintextV2AgentMessages")) return null; + const enabled = raw.plaintextV2AgentMessages; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "plaintextV2AgentMessages ignored: expected a boolean"; +} + +function warnDegradedPlaintextV2AgentMessages(rawParsed: unknown): void { + const warning = malformedPlaintextV2AgentMessagesWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; function rawConfigRecord(rawParsed: unknown): Record | null { @@ -1829,6 +1844,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); warnDegradedAgentTaskRecovery(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } @@ -1853,6 +1869,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); warnDegradedAgentTaskRecovery(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } @@ -1873,6 +1890,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); warnDegradedAgentTaskRecovery(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } @@ -1974,6 +1992,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (hostCircuitWarning) warnings.push(hostCircuitWarning); const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed); if (recoveryWarning) warnings.push(recoveryWarning); + const plaintextMessagesWarning = malformedPlaintextV2AgentMessagesWarning(rawParsed); + if (plaintextMessagesWarning) warnings.push(plaintextMessagesWarning); if (syncDisabledReason) { warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); } @@ -2065,6 +2085,14 @@ function agentTaskRecoveryError(value: unknown): string | null { return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; } +function plaintextV2AgentMessagesError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "plaintextV2AgentMessages")) return null; + const enabled = raw.plaintextV2AgentMessages; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted"; +} + /** * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a * malformed selection-order map to undefined, which on a write would drop every entry the @@ -2176,6 +2204,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) ?? upstreamHostCircuitThresholdError(value) + ?? plaintextV2AgentMessagesError(value) ?? agentTaskRecoveryError(value) ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) diff --git a/src/responses/plaintext-v2-agent-messages.ts b/src/responses/plaintext-v2-agent-messages.ts new file mode 100644 index 0000000000..f9882ea211 --- /dev/null +++ b/src/responses/plaintext-v2-agent-messages.ts @@ -0,0 +1,735 @@ +const COLLABORATION_NAMESPACE = "collaboration"; +export const PLAINTEXT_V2_COLLABORATION_NAMESPACE = "collaboration-optimize"; +const COLLABORATION_NAME_PREFIX = `${COLLABORATION_NAMESPACE}__`; +const COLLABORATION_DOTTED_NAME_PREFIX = `${COLLABORATION_NAMESPACE}.`; +const PLAINTEXT_V2_COLLABORATION_NAME_PREFIX = `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__`; +const PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX = `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}.`; + +const PLAINTEXT_V2_AGENT_MESSAGE_TOOLS = new Set([ + "spawn_agent", + "send_message", + "followup_task", +]); + +const PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES = new Map([ + ["spawn_agent", "start_delegated_task"], + ["send_message", "deliver_delegated_message"], + ["followup_task", "continue_delegated_task"], +]); + +const PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES = new Map( + [...PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES].map(([name, alias]) => [alias, name]), +); + +export function shouldPreparePlaintextV2AgentMessages(args: { + enabled: boolean; + inboundWire: string; + canonicalChatGpt: boolean; + requestBody: unknown; +}): boolean { + return args.enabled + && args.inboundWire === "responses" + && args.canonicalChatGpt + && hasPlaintextV2CollaborationCatalog(args.requestBody); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function responseToolCatalogs(body: Record): unknown[][] { + const catalogs: unknown[][] = []; + if (Array.isArray(body.tools)) catalogs.push(body.tools); + if (!Array.isArray(body.input)) return catalogs; + for (const item of body.input) { + if ( + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + ) { + catalogs.push(item.tools); + } + } + return catalogs; +} + +function collaborationCatalogInfo(catalogs: readonly unknown[][]): { + hasV2Catalog: boolean; + toolNames: Set; +} { + let hasV2Catalog = false; + const toolNames = new Set(); + for (const tools of catalogs) { + for (const tool of tools) { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || tool.name !== COLLABORATION_NAMESPACE + || !Array.isArray(tool.tools) + ) { + continue; + } + for (const child of tool.tools) { + if ( + isPlainObject(child) + && (child.type === "function" || child.type === "custom") + && typeof child.name === "string" + ) { + toolNames.add(child.name); + if (child.type === "function" && child.name === "spawn_agent") hasV2Catalog = true; + } + } + } + } + return { hasV2Catalog, toolNames }; +} + +export function hasPlaintextV2CollaborationCatalog(body: unknown): boolean { + if (!isPlainObject(body)) return false; + return collaborationCatalogInfo(responseToolCatalogs(body)).hasV2Catalog; +} + +function hasOptimizedNamespaceConflict(catalogs: readonly unknown[][]): boolean { + const pending = [...catalogs]; + while (pending.length > 0) { + const tools = pending.pop()!; + for (const tool of tools) { + if (!isPlainObject(tool)) continue; + if ( + typeof tool.name === "string" + && ( + tool.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX) + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) + ) + ) { + return true; + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) pending.push(tool.tools); + } + } + return false; +} + +function isToolIdentity(value: Record): boolean { + return value.type === "function" + || value.type === "custom" + || value.type === "function_call" + || value.type === "custom_tool_call"; +} + +function isOptimizedToolIdentity(value: unknown): boolean { + if (!isPlainObject(value)) return false; + if ( + isToolIdentity(value) + && ( + value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || ( + typeof value.name === "string" + && ( + value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || value.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX) + || value.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) + ) + ) + ) + ) { + return true; + } + return value.type === "namespace" && value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE; +} + +function hasOptimizedReferenceConflict(body: Record): boolean { + if (isOptimizedToolIdentity(body.tool_choice)) return true; + if ( + isPlainObject(body.tool_choice) + && Array.isArray(body.tool_choice.tools) + && body.tool_choice.tools.some(isOptimizedToolIdentity) + ) { + return true; + } + if (!Array.isArray(body.input)) return false; + return body.input.some(item => ( + isPlainObject(item) + && (item.type === "function_call" || item.type === "custom_tool_call") + && isOptimizedToolIdentity(item) + )); +} + +function hasToolSearchCollaborationConflict(body: Record): boolean { + if (!Array.isArray(body.input)) return false; + for (const item of body.input) { + if (!isPlainObject(item) || item.type !== "tool_search_output" || !Array.isArray(item.tools)) { + continue; + } + const pending = [item.tools]; + while (pending.length > 0) { + const tools = pending.pop()!; + for (const tool of tools) { + if (!isPlainObject(tool)) continue; + if ( + typeof tool.name === "string" + && (tool.name === COLLABORATION_NAMESPACE + || tool.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + || tool.name.startsWith(COLLABORATION_NAME_PREFIX) + || tool.name.startsWith(COLLABORATION_DOTTED_NAME_PREFIX) + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX) + || tool.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX)) + ) { + return true; + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) pending.push(tool.tools); + } + } + } + return false; +} + +function hasAgentMessageEncryptionMarker(tool: Record): boolean { + return tool.type === "function" + && typeof tool.name === "string" + && PLAINTEXT_V2_AGENT_MESSAGE_TOOLS.has(tool.name) + && isPlainObject(tool.parameters) + && isPlainObject(tool.parameters.properties) + && isPlainObject(tool.parameters.properties.message) + && tool.parameters.properties.message.encrypted === true; +} + +function rewriteAgentMessageToolDeclaration(tool: Record): Record { + const alias = tool.type === "function" && typeof tool.name === "string" + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(tool.name) + : undefined; + if (!alias) return tool; + + let rewritten: Record = { ...tool, name: alias }; + if ( + hasAgentMessageEncryptionMarker(tool) + && isPlainObject(tool.parameters) + && isPlainObject(tool.parameters.properties) + && isPlainObject(tool.parameters.properties.message) + ) { + const { encrypted: _encrypted, ...messageSchema } = tool.parameters.properties.message; + rewritten = { + ...rewritten, + parameters: { + ...tool.parameters, + properties: { + ...tool.parameters.properties, + message: messageSchema, + }, + }, + }; + } + return rewritten; +} + +function hasAgentMessageToolAliasCatalogConflict(catalogs: readonly unknown[][]): boolean { + for (const tools of catalogs) { + for (const tool of tools) { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || tool.name !== COLLABORATION_NAMESPACE + || !Array.isArray(tool.tools) + ) { + continue; + } + if (tool.tools.some(child => ( + isPlainObject(child) + && typeof child.name === "string" + && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(child.name) + ))) { + return true; + } + } + } + return false; +} + +function hasAgentMessageToolAliasReference(value: unknown): boolean { + if (!isPlainObject(value) || !isToolIdentity(value) || typeof value.name !== "string") { + return false; + } + if ( + value.namespace === COLLABORATION_NAMESPACE + && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(value.name) + ) { + return true; + } + for (const prefix of [COLLABORATION_NAME_PREFIX, COLLABORATION_DOTTED_NAME_PREFIX]) { + if ( + value.name.startsWith(prefix) + && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(value.name.slice(prefix.length)) + ) { + return true; + } + } + return false; +} + +function hasAgentMessageToolAliasReferenceConflict(body: Record): boolean { + if (hasAgentMessageToolAliasReference(body.tool_choice)) return true; + if ( + isPlainObject(body.tool_choice) + && Array.isArray(body.tool_choice.tools) + && body.tool_choice.tools.some(hasAgentMessageToolAliasReference) + ) { + return true; + } + if (!Array.isArray(body.input)) return false; + return body.input.some(item => ( + isPlainObject(item) + && (item.type === "function_call" || item.type === "custom_tool_call") + && hasAgentMessageToolAliasReference(item) + )); +} + +function hasFlattenedCollaborationDeclarationConflict( + catalogs: readonly unknown[][], + collaborationToolNames: ReadonlySet, +): boolean { + const qualifiedNames = new Set( + [...collaborationToolNames].flatMap(name => [ + `${COLLABORATION_NAME_PREFIX}${name}`, + `${COLLABORATION_DOTTED_NAME_PREFIX}${name}`, + ]), + ); + const pending = catalogs.map(tools => ({ tools, collaborationNamespace: false })); + while (pending.length > 0) { + const { tools, collaborationNamespace } = pending.pop()!; + for (const tool of tools) { + if (!isPlainObject(tool)) continue; + if ( + !collaborationNamespace + && (tool.type === "function" || tool.type === "custom") + && typeof tool.name === "string" + && qualifiedNames.has(tool.name) + ) { + return true; + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) { + pending.push({ + tools: tool.tools, + collaborationNamespace: tool.name === COLLABORATION_NAMESPACE, + }); + } + } + } + return false; +} + +function rewriteToolCatalog(tools: unknown[]): { + tools: unknown[]; + namespaceAliased: boolean; +} { + let namespaceAliased = false; + let changed = false; + const rewritten = tools.map(tool => { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || tool.name !== COLLABORATION_NAMESPACE + || !Array.isArray(tool.tools) + ) { + return tool; + } + const childTools = tool.tools.map(child => ( + isPlainObject(child) ? rewriteAgentMessageToolDeclaration(child) : child + )); + namespaceAliased = true; + changed = true; + return { + ...tool, + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: childTools, + }; + }); + return { tools: changed ? rewritten : tools, namespaceAliased }; +} + +function aliasCollaborationReference( + value: unknown, + collaborationToolNames: ReadonlySet, +): unknown { + if (!isPlainObject(value)) return value; + const type = value.type; + const canCarryNamespace = isToolIdentity(value); + let rewritten = value; + if (canCarryNamespace && value.namespace === COLLABORATION_NAMESPACE) { + const name = (type === "function" || type === "function_call") && typeof value.name === "string" + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(value.name) ?? value.name + : value.name; + rewritten = { ...rewritten, namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, name }; + } + if (type === "namespace" && value.name === COLLABORATION_NAMESPACE) { + rewritten = { ...rewritten, name: PLAINTEXT_V2_COLLABORATION_NAMESPACE }; + } else if ( + canCarryNamespace + && typeof value.name === "string" + && value.name.startsWith(COLLABORATION_NAME_PREFIX) + && collaborationToolNames.has(value.name.slice(COLLABORATION_NAME_PREFIX.length)) + ) { + const childName = value.name.slice(COLLABORATION_NAME_PREFIX.length); + rewritten = { + ...rewritten, + name: `${PLAINTEXT_V2_COLLABORATION_NAME_PREFIX}${ + (type === "function" || type === "function_call") + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(childName) ?? childName + : childName + }`, + }; + } else if ( + canCarryNamespace + && typeof value.name === "string" + && value.name.startsWith(COLLABORATION_DOTTED_NAME_PREFIX) + && collaborationToolNames.has(value.name.slice(COLLABORATION_DOTTED_NAME_PREFIX.length)) + ) { + const childName = value.name.slice(COLLABORATION_DOTTED_NAME_PREFIX.length); + rewritten = { + ...rewritten, + name: `${PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX}${ + (type === "function" || type === "function_call") + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_ALIASES.get(childName) ?? childName + : childName + }`, + }; + } + return rewritten; +} + +function aliasCollaborationToolChoice( + toolChoice: unknown, + collaborationToolNames: ReadonlySet, +): unknown { + if (!isPlainObject(toolChoice)) return toolChoice; + let rewritten = aliasCollaborationReference( + toolChoice, + collaborationToolNames, + ) as Record; + if (!Array.isArray(toolChoice.tools)) return rewritten; + let toolsChanged = false; + const tools = toolChoice.tools.map(tool => { + const aliased = aliasCollaborationReference(tool, collaborationToolNames); + toolsChanged ||= aliased !== tool; + return aliased; + }); + if (toolsChanged) rewritten = { ...rewritten, tools }; + return rewritten; +} + +/** + * Prepare v2 collaboration tools for plaintext messages on the canonical ChatGPT wire. + * + * ChatGPT reserves both `collaboration` and the three message-tool names. The request therefore + * uses fixed, request-scoped aliases for both, then restores every identity before Codex sees it. + */ +export function preparePlaintextV2AgentMessages(body: unknown): { + body: unknown; + namespaceAliased: boolean; + toolNames: ReadonlySet; +} { + if (!isPlainObject(body)) return { body, namespaceAliased: false, toolNames: new Set() }; + const catalogs = responseToolCatalogs(body); + const catalogInfo = collaborationCatalogInfo(catalogs); + if ( + !catalogInfo.hasV2Catalog + || hasOptimizedNamespaceConflict(catalogs) + || hasOptimizedReferenceConflict(body) + || hasToolSearchCollaborationConflict(body) + || hasFlattenedCollaborationDeclarationConflict(catalogs, catalogInfo.toolNames) + || hasAgentMessageToolAliasCatalogConflict(catalogs) + || hasAgentMessageToolAliasReferenceConflict(body) + ) { + return { body, namespaceAliased: false, toolNames: new Set() }; + } + + let namespaceAliased = false; + let tools = body.tools; + if (Array.isArray(body.tools)) { + const rewritten = rewriteToolCatalog(body.tools); + tools = rewritten.tools; + namespaceAliased ||= rewritten.namespaceAliased; + } + + let input = body.input; + if (Array.isArray(body.input)) { + let inputChanged = false; + const rewrittenInput = body.input.map(item => { + if ( + !isPlainObject(item) + || item.type !== "additional_tools" + || !Array.isArray(item.tools) + ) { + return item; + } + const rewritten = rewriteToolCatalog(item.tools); + namespaceAliased ||= rewritten.namespaceAliased; + if (rewritten.tools === item.tools) return item; + inputChanged = true; + return { ...item, tools: rewritten.tools }; + }); + if (inputChanged) input = rewrittenInput; + } + + let toolChoice = body.tool_choice; + if (namespaceAliased) { + toolChoice = aliasCollaborationToolChoice(body.tool_choice, catalogInfo.toolNames); + if (Array.isArray(input)) { + let inputChanged = false; + const aliasedInput = input.map(item => { + if (!isPlainObject(item)) return item; + if (item.type !== "function_call" && item.type !== "custom_tool_call") return item; + const aliased = aliasCollaborationReference(item, catalogInfo.toolNames); + inputChanged ||= aliased !== item; + return aliased; + }); + if (inputChanged) input = aliasedInput; + } + } + + if (!namespaceAliased || (tools === body.tools && input === body.input && toolChoice === body.tool_choice)) { + return { body, namespaceAliased: false, toolNames: new Set() }; + } + return { + body: { + ...body, + ...(tools !== body.tools ? { tools } : {}), + ...(input !== body.input ? { input } : {}), + ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), + }, + namespaceAliased, + toolNames: new Set(catalogInfo.toolNames), + }; +} + +const MAX_RESTORED_TOOL_IDENTITIES = 10_000; + +type RestoreOutcome = { + value: unknown; + changed: boolean; + overflow: boolean; +}; + +type RestoreContext = { + toolNames: ReadonlySet; + remainingIdentities: number; +}; + +const unchanged = (value: unknown): RestoreOutcome => ({ value, changed: false, overflow: false }); + +function reserveIdentities(context: RestoreContext, count: number): boolean { + if (count > context.remainingIdentities) return false; + context.remainingIdentities -= count; + return true; +} + +function declaredChildName( + name: unknown, + toolNames: ReadonlySet, + allowAgentMessageAlias: boolean, +): string | undefined { + if (typeof name !== "string") return undefined; + if (toolNames.has(name)) return name; + if (allowAgentMessageAlias) { + const restoredName = PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.get(name); + if (restoredName && toolNames.has(restoredName)) return restoredName; + } + for (const prefix of [ + PLAINTEXT_V2_COLLABORATION_NAME_PREFIX, + PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX, + ]) { + if (!name.startsWith(prefix)) continue; + const childName = name.slice(prefix.length); + const restoredChildName = allowAgentMessageAlias + ? PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.get(childName) ?? childName + : childName; + return toolNames.has(restoredChildName) ? restoredChildName : undefined; + } + return undefined; +} + +function restoreToolIdentity( + value: unknown, + context: RestoreContext, + allowNamespaceDeclaration = false, +): RestoreOutcome { + if (!isPlainObject(value)) return unchanged(value); + if (!reserveIdentities(context, 1)) return { ...unchanged(value), overflow: true }; + + if ( + allowNamespaceDeclaration + && value.type === "namespace" + && value.name === PLAINTEXT_V2_COLLABORATION_NAMESPACE + ) { + const children = restoreIdentityList(value.tools, context, false); + if (children.overflow) return { ...unchanged(value), overflow: true }; + return { + value: { + ...value, + name: COLLABORATION_NAMESPACE, + ...(children.changed ? { tools: children.value } : {}), + }, + changed: true, + overflow: false, + }; + } + + const identityType = value.type; + if ( + identityType !== "function" + && identityType !== "custom" + && identityType !== "function_call" + && identityType !== "custom_tool_call" + && identityType !== "response.function_call_arguments.done" + ) { + return unchanged(value); + } + + const allowAgentMessageAlias = identityType === "function" + || identityType === "function_call" + || identityType === "response.function_call_arguments.done"; + const childName = declaredChildName(value.name, context.toolNames, allowAgentMessageAlias); + if (!childName) return unchanged(value); + + let restored = value; + let changed = false; + if (value.namespace === PLAINTEXT_V2_COLLABORATION_NAMESPACE) { + restored = { ...restored, namespace: COLLABORATION_NAMESPACE }; + changed = true; + } + if (allowAgentMessageAlias && PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.has(value.name as string)) { + restored = { ...restored, name: childName }; + changed = true; + } else if (typeof value.name === "string" && value.name.startsWith(PLAINTEXT_V2_COLLABORATION_NAME_PREFIX)) { + restored = { ...restored, name: `${COLLABORATION_NAME_PREFIX}${childName}` }; + changed = true; + } else if ( + typeof value.name === "string" + && value.name.startsWith(PLAINTEXT_V2_COLLABORATION_DOTTED_NAME_PREFIX) + ) { + restored = { ...restored, name: `${COLLABORATION_DOTTED_NAME_PREFIX}${childName}` }; + changed = true; + } + return { value: restored, changed, overflow: false }; +} + +function restoreIdentityList( + values: unknown, + context: RestoreContext, + allowNamespaceDeclaration: boolean, +): RestoreOutcome { + if (!Array.isArray(values)) return unchanged(values); + if (values.length > context.remainingIdentities) { + return { ...unchanged(values), overflow: true }; + } + let restored: unknown[] | undefined; + for (let index = 0; index < values.length; index += 1) { + const result = restoreToolIdentity(values[index], context, allowNamespaceDeclaration); + if (result.overflow) return { ...unchanged(values), overflow: true }; + if (!result.changed) continue; + restored ??= values.slice(); + restored[index] = result.value; + } + return restored + ? { value: restored, changed: true, overflow: false } + : unchanged(values); +} + +function restoreToolChoice(value: unknown, context: RestoreContext): RestoreOutcome { + const direct = restoreToolIdentity(value, context); + if (direct.overflow || !isPlainObject(value) || !Array.isArray(value.tools)) return direct; + const tools = restoreIdentityList(value.tools, context, false); + if (tools.overflow) return { ...unchanged(value), overflow: true }; + if (!tools.changed) return direct; + const base = direct.value as Record; + return { value: { ...base, tools: tools.value }, changed: true, overflow: false }; +} + +function restoreResponseSnapshot(value: unknown, context: RestoreContext): RestoreOutcome { + if (!isPlainObject(value)) return unchanged(value); + const output = restoreIdentityList(value.output, context, false); + if (output.overflow) return { ...unchanged(value), overflow: true }; + const tools = restoreIdentityList(value.tools, context, true); + if (tools.overflow) return { ...unchanged(value), overflow: true }; + const toolChoice = restoreToolChoice(value.tool_choice, context); + if (toolChoice.overflow) return { ...unchanged(value), overflow: true }; + if (!output.changed && !tools.changed && !toolChoice.changed) return unchanged(value); + return { + value: { + ...value, + ...(output.changed ? { output: output.value } : {}), + ...(tools.changed ? { tools: tools.value } : {}), + ...(toolChoice.changed ? { tool_choice: toolChoice.value } : {}), + }, + changed: true, + overflow: false, + }; +} + +/** + * Restore request-scoped collaboration aliases only at documented Responses identity positions. + * Tool arguments, tool results, and extension metadata are deliberately opaque. + */ +export function restorePlaintextV2AgentMessageCalls( + value: unknown, + toolNames: ReadonlySet, +): { value: unknown; changed: boolean; overflowed: boolean } { + if (toolNames.size === 0 || !isPlainObject(value)) { + return { value, changed: false, overflowed: false }; + } + const context: RestoreContext = { + toolNames, + remainingIdentities: MAX_RESTORED_TOOL_IDENTITIES, + }; + + const rootIdentity = restoreToolIdentity(value, context); + if (rootIdentity.overflow) return { value, changed: false, overflowed: true }; + const root = rootIdentity.value as Record; + const item = restoreToolIdentity(root.item, context); + if (item.overflow) return { value, changed: false, overflowed: true }; + const response = restoreResponseSnapshot(root.response, context); + if (response.overflow) return { value, changed: false, overflowed: true }; + const snapshot = restoreResponseSnapshot(root, context); + if (snapshot.overflow) return { value, changed: false, overflowed: true }; + + let restored = snapshot.value as Record; + let changed = rootIdentity.changed || snapshot.changed; + if (item.changed) { + restored = { ...restored, item: item.value }; + changed = true; + } + if (response.changed) { + restored = { ...restored, response: response.value }; + changed = true; + } + return changed + ? { value: restored, changed: true, overflowed: false } + : { value, changed: false, overflowed: false }; +} + +export function restorePlaintextV2AgentMessageCallsInJson( + payload: string, + toolNames: ReadonlySet, +): string { + if ( + !payload.includes(PLAINTEXT_V2_COLLABORATION_NAMESPACE) + && ![...PLAINTEXT_V2_AGENT_MESSAGE_TOOL_NAMES.keys()].some(alias => payload.includes(alias)) + ) { + return payload; + } + + let value: unknown; + try { + value = JSON.parse(payload); + } catch { + return payload; + } + const restored = restorePlaintextV2AgentMessageCalls(value, toolNames); + return restored.changed ? JSON.stringify(restored.value) : payload; +} + +export function createPlaintextV2AgentMessageCallRestoreRewrite( + toolNames: ReadonlySet, +): (payload: string) => string { + return payload => restorePlaintextV2AgentMessageCallsInJson(payload, toolNames); +} diff --git a/src/server/index.ts b/src/server/index.ts index 44d794991f..e878bea48a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -516,9 +516,19 @@ export function warnAgentTaskRecoveryStartup(config: { console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); } +export function warnPlaintextV2AgentMessagesStartup(config: { + plaintextV2AgentMessages?: boolean; +}): void { + if (config.plaintextV2AgentMessages !== true) return; + console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); + console.warn(" Eligible new canonical ChatGPT v2 spawn_agent, send_message, and followup_task calls may carry message arguments without application-layer encryption; HTTPS transport encryption is unchanged."); + console.warn(" Message text may appear in Codex history, routed-provider requests, and local response/debug state. This depends on undocumented ChatGPT and Codex behavior and may stop working after a backend or client change."); +} + export function startServer(port?: number, deps: StartServerDeps = {}): Server { const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret(); const config = runModelRenameStartupMigration(runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()))); + warnPlaintextV2AgentMessagesStartup(config); warnAgentTaskRecoveryStartup(config); setLiveStateStoreConfig(config); applyProxyEnv(config); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 08a679894d..7743ca24af 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -324,6 +324,12 @@ import { restoreRoutedNamespaceCallsInJson, type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; +import { + createPlaintextV2AgentMessageCallRestoreRewrite, + restorePlaintextV2AgentMessageCalls, + restorePlaintextV2AgentMessageCallsInJson, + shouldPreparePlaintextV2AgentMessages, +} from "../../responses/plaintext-v2-agent-messages"; import { collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, @@ -1693,6 +1699,12 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + parsed._plaintextV2AgentMessages = shouldPreparePlaintextV2AgentMessages({ + enabled: config.plaintextV2AgentMessages === true, + inboundWire, + canonicalChatGpt: isCanonicalOpenAiForwardProvider(route.provider), + requestBody: parsed._rawBody, + }); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; logCtx.provider = route.providerName; @@ -3104,8 +3116,10 @@ async function handleResponsesInner( } let routedNamespaceToolAliases: RoutedNamespaceToolAliases = new Map(); - const refreshRoutedNamespaceToolAliases = (builtRequest: AdapterRequest): void => { + let plaintextV2AgentMessageToolNames: ReadonlySet = new Set(); + const refreshRequestToolAliases = (builtRequest: AdapterRequest): void => { routedNamespaceToolAliases = builtRequest.convertedRoutedNamespaceToolAliases ?? new Map(); + plaintextV2AgentMessageToolNames = builtRequest.plaintextV2AgentMessageToolNames ?? new Set(); }; if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { @@ -3190,7 +3204,7 @@ async function handleResponsesInner( // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes. routedToolSearchNames.add(name); } - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); // #1700: the bridged paths refuse a call to a tool the request never declared // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested @@ -3283,10 +3297,17 @@ async function handleResponsesInner( const rememberPassthroughResponseChecked = rememberPassthroughResponse ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { if (inspectionSawUndeclaredTool) return; + const plaintextRestore = plaintextV2AgentMessageToolNames.size > 0 + ? restorePlaintextV2AgentMessageCalls(response, plaintextV2AgentMessageToolNames) + : undefined; + // Keep the client response intact on structural overflow, but do not persist an + // internal namespace that a later turn (or a disabled option) could replay. + if (plaintextRestore?.overflowed) return; + const replayResponse = (plaintextRestore?.value ?? response) as typeof response; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( - response, + replayResponse, declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, @@ -3294,7 +3315,7 @@ async function handleResponsesInner( ) { return; } - rememberPassthroughResponse(response); + rememberPassthroughResponse(replayResponse); } : undefined; recordAdapterReasoning(logCtx, request); @@ -3444,7 +3465,7 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, }); - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); } catch (err) { @@ -3558,7 +3579,7 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, }); - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); } catch (err) { @@ -3718,7 +3739,7 @@ async function handleResponsesInner( if (retry.kind === "retried") { authCtx = retry.authCtx; request = retry.request; - refreshRoutedNamespaceToolAliases(request); + refreshRequestToolAliases(request); refreshUndeclaredToolGuard(request); upstreamResponse = retry.upstreamResponse; selectedForwardHeaders = retry.selectedForwardHeaders; @@ -3934,6 +3955,13 @@ async function handleResponsesInner( snapshotRepairEnabled ? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget) : undefined, + // Snapshot repair can copy tools/tool_choice from the outbound request. Restore + // this request-scoped alias after that copy and before the client guard runs. + plaintextV2AgentMessageToolNames.size > 0 + ? payloadRewriteAsBlockRewrite( + createPlaintextV2AgentMessageCallRestoreRewrite(plaintextV2AgentMessageToolNames), + ) + : undefined, createResponsesFieldBackfillBlockRewrite(), // Last: every rewrite above can still rename or reshape a call item, so the guard must // compare the names the client will actually receive against the declared catalog. @@ -4125,8 +4153,9 @@ async function handleResponsesInner( const text = bounded.text; inspectResponseLogJson(logCtx, text); const clientJson = (() => { + const restoredImageGen = restoreImageGenCallsInJson(text, imageGenCallAliases); const restoredNamespace = restoreRoutedNamespaceCallsInJson( - restoreImageGenCallsInJson(text, imageGenCallAliases), + restoredImageGen, routedNamespaceToolAliases, ); const restored = restoreRoutedCustomCallsInJson( @@ -4141,9 +4170,12 @@ async function handleResponsesInner( const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) ? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody) : restoredToolSearch; + const restoredPlaintextV2Namespace = plaintextV2AgentMessageToolNames.size > 0 + ? restorePlaintextV2AgentMessageCallsInJson(repaired, plaintextV2AgentMessageToolNames) + : repaired; const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId - ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId) - : backfillResponsesFieldsJson(repaired); + ? rewriteResponsesModelJson(backfillResponsesFieldsJson(restoredPlaintextV2Namespace), parsed._responseModelId) + : backfillResponsesFieldsJson(restoredPlaintextV2Namespace); // The bounded-JSON answer bypasses the SSE payload rewrite, so content- // channel reasoning needs the same normalization here for the plain // JSON answer and every reframed-SSE variant built from clientJson. @@ -4848,7 +4880,7 @@ async function handleResponsesInner( let inputTokenEstimate: number | undefined; try { initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); - refreshRoutedNamespaceToolAliases(initialRequest); + refreshRequestToolAliases(initialRequest); recordAdapterReasoning(logCtx, initialRequest); recordAdapterTier(logCtx, initialRequest); inputTokenEstimate = typeof initialRequest.usageLog?.inputTokens === "number" @@ -4981,7 +5013,7 @@ async function handleResponsesInner( sameTargetParsed = parsed; sameTargetToken = transportToken; } - refreshRoutedNamespaceToolAliases(retryRequest); + refreshRequestToolAliases(retryRequest); const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number" ? retryRequest.usageLog.inputTokens : undefined; diff --git a/src/types/config.ts b/src/types/config.ts index 1262b68d7c..1164e4e989 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -458,6 +458,8 @@ export interface OcxConfig { * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92). */ keepNativeChatGptOnV1?: boolean; + /** Experimental, default-off plaintext delivery for native v2 collaboration messages. */ + plaintextV2AgentMessages?: boolean; /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */ agentTaskRecovery?: { enabled?: boolean; diff --git a/src/types/request.ts b/src/types/request.ts index 28f37ae666..3a220dc3a8 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -77,6 +77,8 @@ export interface OcxParsedRequest { * prepareOpaqueBlobRecovery after an authoritative rejection; consumers strip replayed blobs. */ _stripReasoningEncryptedContent?: boolean; + /** Final-route opt-in: emit v2 collaboration message arguments as plaintext on ChatGPT. */ + _plaintextV2AgentMessages?: boolean; /** * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation. * When absent (single-operator local proxy), derivation stays local-scoped. diff --git a/tests/agent-task-recovery.test.ts b/tests/agent-task-recovery.test.ts index 78647e1075..a62b90e3c6 100644 --- a/tests/agent-task-recovery.test.ts +++ b/tests/agent-task-recovery.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createTranslatorBudget } from "../src/lib/translator-budget"; -import { warnAgentTaskRecoveryStartup } from "../src/server"; +import { warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup } from "../src/server"; import { resetAgentTaskRecoveryState } from "../src/server/responses/agent-task-recovery"; import { agentTaskRecoveryWaiterCountForTests } from "../src/server/responses/agent-task-recovery-cache"; import { @@ -125,6 +125,28 @@ describe("agent task recovery (opt-in, default off)", () => { } }); + test("warns about plaintext retention only for an explicit v2 message opt-in", () => { + const originalWarn = console.warn; + const capture = (enabled: boolean | undefined): string[] => { + const warnings: string[] = []; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + warnPlaintextV2AgentMessagesStartup({ plaintextV2AgentMessages: enabled }); + return warnings; + }; + + try { + expect(capture(undefined)).toEqual([]); + expect(capture(false)).toEqual([]); + const warnings = capture(true); + expect(warnings).toHaveLength(3); + expect(warnings.join("\n")).toContain("Experimental plaintext V2 agent messages are enabled"); + expect(warnings.join("\n")).toContain("HTTPS transport encryption is unchanged"); + expect(warnings.join("\n")).toContain("local response/debug state"); + } finally { + console.warn = originalWarn; + } + }); + test("baseline: encrypted routed task still fails when recovery returns no assignment", async () => { const fetchedUrls: string[] = []; globalThis.fetch = (async (input) => { diff --git a/tests/config.test.ts b/tests/config.test.ts index 6ebae31319..a11074722a 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -590,6 +590,46 @@ describe("opencodex config defaults", () => { } }); + test("plaintextV2AgentMessages is explicit and degrades invalid hand edits", () => { + const base = { + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + }, + }, + defaultProvider: "custom", + }; + expect(getDefaultConfig().plaintextV2AgentMessages).toBeUndefined(); + + writeConfig({ ...base, plaintextV2AgentMessages: true }); + expect(loadConfig()).toMatchObject({ ...base, plaintextV2AgentMessages: true }); + expect(validateConfigCandidate({ ...base, plaintextV2AgentMessages: true })).toMatchObject({ + ok: true, + config: { plaintextV2AgentMessages: true }, + }); + + for (const invalid of [null, "true", 1, {}]) { + writeConfig({ ...base, plaintextV2AgentMessages: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics).toMatchObject({ + source: "file", + error: null, + config: base, + }); + expect(diagnostics.config.plaintextV2AgentMessages).toBeUndefined(); + expect(diagnostics.warnings).toContain( + "plaintextV2AgentMessages ignored: expected a boolean", + ); + expect(validateConfigCandidate({ ...base, plaintextV2AgentMessages: invalid })).toMatchObject({ + ok: false, + error: expect.stringContaining("plaintextV2AgentMessages"), + }); + expect(backupNames()).toEqual([]); + } + }); + test("native subagent-default sync is opt-in and ignores malformed opt-ins without falling back", () => { const base = { port: 12345, diff --git a/tests/plaintext-v2-agent-messages-server.test.ts b/tests/plaintext-v2-agent-messages-server.test.ts new file mode 100644 index 0000000000..365c6e4718 --- /dev/null +++ b/tests/plaintext-v2-agent-messages-server.test.ts @@ -0,0 +1,443 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { clearAccountQuota, updateAccountQuota } from "../src/codex/auth-api"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../src/codex/routing"; +import { CODEX_FORWARD_BASE_URL } from "../src/providers/openai-tiers"; +import { PLAINTEXT_V2_COLLABORATION_NAMESPACE } from "../src/responses/plaintext-v2-agent-messages"; +import { clearResponseStateForTests } from "../src/responses/state"; +import { handleResponses } from "../src/server/responses"; +import type { OcxConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; +beforeEach(() => { clearResponseStateForTests(); }); +afterEach(() => { + globalThis.fetch = originalFetch; + clearResponseStateForTests(); +}); + +function config(enabled: boolean, snapshotRepair = false): OcxConfig { + return { + defaultProvider: "native", + providers: { + native: { + adapter: "openai-responses", + baseUrl: CODEX_FORWARD_BASE_URL, + authMode: "forward", + ...(snapshotRepair ? { responsesSnapshotRepair: true } : {}), + }, + }, + plaintextV2AgentMessages: enabled, + } as OcxConfig; +} + +function collaborationRequest(options: { + input?: unknown[]; + model?: string; + previousResponseId?: string; + toolChoice?: unknown; +} = {}): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: options.model ?? "native/gpt-5.6-sol", + store: false, + stream: true, + input: options.input ?? [{ + type: "message", + role: "user", + content: [{ type: "input_text", text: "delegate" }], + }], + ...(options.previousResponseId ? { previous_response_id: options.previousResponseId } : {}), + ...(options.toolChoice ? { tool_choice: options.toolChoice } : {}), + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + { + type: "function", + name: "spawn_agent", + parameters: { + type: "object", + properties: { message: { type: "string", encrypted: true } }, + required: ["message"], + }, + }, + { type: "function", name: "send_message", parameters: { type: "object" } }, + ], + }], + }), + }); +} + +async function withPoolHome(run: () => Promise): Promise { + const home = mkdtempSync(join(tmpdir(), "ocx-plaintext-v2-pool-")); + const previousOpencodexHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountQuota(); + try { + return await run(); + } finally { + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountQuota(); + rmSync(home, { recursive: true, force: true }); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } +} + +function completedResponsePayload(id = "resp-plaintext-v2") { + return { + id, + status: "completed", + output: [{ + type: "function_call", + call_id: "call-spawn", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain assignment" }), + encrypted_function_args: [], + }], + }; +} + +describe("plaintext v2 agent messages at the Responses server boundary", () => { + test("rewrites the canonical request and restores every SSE response snapshot", async () => { + const sentBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + const response = completedResponsePayload(); + return new Response( + `event: response.output_item.added\ndata: ${JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: response.output[0], + })}\n\nevent: response.function_call_arguments.done\ndata: ${JSON.stringify({ + type: "response.function_call_arguments.done", + item_id: "fc-spawn", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`, + arguments: JSON.stringify({ message: "plain assignment" }), + encrypted_function_args: [], + })}\n\nevent: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response, + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + + const response = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + const sentBody = JSON.parse(sentBodies[0]!) as { + tools: Array<{ + name: string; + tools: Array<{ + name: string; + parameters: { properties: { message: Record } }; + }>; + }>; + }; + + expect(sentBodies).toHaveLength(1); + expect(sentBody.tools[0]!.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(sentBody.tools[0]!.tools[0]!.name).toBe("start_delegated_task"); + expect(sentBody.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBeUndefined(); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).toContain('"name":"collaboration__spawn_agent"'); + expect(clientBody).toContain('"encrypted_function_args":[]'); + }); + + test("restores the namespace in bounded JSON responses", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify(completedResponsePayload()), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + const response = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).toContain('"encrypted_function_args":[]'); + }); + + test("restores aliases after SSE snapshot repair copies request tools and tool choice", async () => { + globalThis.fetch = (async () => { + const response = completedResponsePayload("resp-snapshot-sse"); + return new Response( + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response, + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + + const response = await handleResponses( + collaborationRequest({ + toolChoice: { type: "function", namespace: "collaboration", name: "spawn_agent" }, + }), + config(true, true), + { model: "", provider: "" }, + ); + const completedLine = (await response.text()).split("\n") + .find(line => line.includes('"response.completed"'))!; + const completed = JSON.parse(completedLine.replace(/^data: /, "")) as { + response: { + tool_choice: { namespace: string }; + tools: Array<{ name: string }>; + output: Array<{ namespace: string; encrypted_function_args: unknown[] }>; + }; + }; + + expect(completed.response.tool_choice.namespace).toBe("collaboration"); + expect(completed.response.tools[0]!.name).toBe("collaboration"); + expect(completed.response.output[0]!.namespace).toBe("collaboration"); + expect(completed.response.output[0]!.encrypted_function_args).toEqual([]); + }); + + test("restores aliases after bounded JSON snapshot repair", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify(completedResponsePayload("resp-snapshot-json")), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const response = await handleResponses( + collaborationRequest({ + toolChoice: { type: "function", namespace: "collaboration", name: "spawn_agent" }, + }), + config(true, true), + { model: "", provider: "" }, + ); + const completed = await response.json() as { + tool_choice: { namespace: string }; + tools: Array<{ name: string }>; + output: Array<{ namespace: string; encrypted_function_args: unknown[] }>; + }; + + expect(completed.tool_choice.namespace).toBe("collaboration"); + expect(completed.tools[0]!.name).toBe("collaboration"); + expect(completed.output[0]!.namespace).toBe("collaboration"); + expect(completed.output[0]!.encrypted_function_args).toEqual([]); + }); + + test("keeps the marker and reserved namespace when the option is disabled", async () => { + const sentBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + await handleResponses(collaborationRequest(), config(false), { model: "", provider: "" }); + const sentBody = JSON.parse(sentBodies[0]!) as { + tools: Array<{ name: string; tools: Array<{ parameters: { properties: { message: Record } } }> }>; + }; + + expect(sentBodies).toHaveLength(1); + expect(sentBody.tools[0]!.name).toBe("collaboration"); + expect(sentBody.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBe(true); + }); + + test("keeps the whole request unchanged when tool-search history conflicts with the alias", async () => { + const sentBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + await handleResponses(collaborationRequest({ + input: [{ + type: "tool_search_output", + tools: [{ + type: "namespace", + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: [], + }], + }], + }), config(true), { model: "", provider: "" }); + + const sent = JSON.parse(sentBodies[0]!) as { + tools: Array<{ + name: string; + tools: Array<{ parameters: { properties: { message: Record } } }>; + }>; + }; + expect(sent.tools[0]!.name).toBe("collaboration"); + expect(sent.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBe(true); + }); + + test("rebuilds the plaintext alias after a canonical pool quota retry", async () => { + await withPoolHome(async () => { + const poolConfig = { + defaultProvider: "openai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: CODEX_FORWARD_BASE_URL, + authMode: "forward", + codexAccountMode: "pool", + }, + }, + codexAccounts: ["pool-a", "pool-b"].map(id => ({ + id, + email: `${id}@example.test`, + isMain: false, + chatgptAccountId: `${id}_chatgpt`, + })), + plaintextV2AgentMessages: true, + } as OcxConfig; + for (const [index, id] of ["pool-a", "pool-b"].entries()) { + saveCodexAccountCredential(id, { + accessToken: `${id}-access-token`, + refreshToken: `${id}-refresh-token`, + expiresAt: Date.now() + 300_000, + chatgptAccountId: `${id}_chatgpt`, + }); + updateAccountQuota(id, 10 + index * 10); + } + + const sentBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + if (sentBodies.length === 1) { + return Response.json({ error: { message: "rate limited" } }, { + status: 429, + headers: { "retry-after": "1" }, + }); + } + return Response.json(completedResponsePayload("resp-pool-retry")); + }) as typeof fetch; + + const response = await handleResponses( + collaborationRequest({ model: "gpt-5.5" }), + poolConfig, + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(sentBodies).toHaveLength(2); + for (const body of sentBodies) { + const sent = JSON.parse(body) as { tools: Array<{ name: string }> }; + expect(sent.tools[0]!.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + } + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + }); + }); + + test("returns an over-limit response but does not retain its private alias for continuation", async () => { + const sentBodies: string[] = []; + let requestIndex = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + requestIndex += 1; + const payload = requestIndex === 1 + ? { + id: "resp-plaintext-v2-overflow", + status: "completed", + output: Array.from({ length: 10_001 }, (_, index) => ({ + type: "function_call", + call_id: `call-${index}`, + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: "{}", + })), + } + : { id: "resp-after-overflow", status: "completed", output: [] }; + return Response.json(payload); + }) as typeof fetch; + + const first = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + const firstBody = await first.text(); + expect(first.status).toBe(200); + expect(firstBody).toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + + const second = await handleResponses( + collaborationRequest({ + previousResponseId: "resp-plaintext-v2-overflow", + input: [{ + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }], + }), + config(false), + { model: "", provider: "" }, + ); + const secondBody = await second.text(); + expect({ status: second.status, body: secondBody, sends: sentBodies.length }).toEqual({ + status: 400, + body: expect.stringContaining("continuation state is unavailable or expired"), + sends: 1, + }); + }); + + test("stores the client namespace so disabling the option cannot replay the private alias", async () => { + const sentBodies: string[] = []; + let requestIndex = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(typeof init?.body === "string" ? init.body : ""); + requestIndex += 1; + const payload = requestIndex === 1 + ? completedResponsePayload("resp-toggle-plaintext-v2") + : { id: "resp-after-toggle", status: "completed", output: [] }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const first = await handleResponses( + collaborationRequest(), + config(true), + { model: "", provider: "" }, + ); + await first.text(); + const second = await handleResponses( + collaborationRequest({ + previousResponseId: "resp-toggle-plaintext-v2", + input: [{ type: "function_call_output", call_id: "call-spawn", output: "done" }], + }), + config(false), + { model: "", provider: "" }, + ); + await second.text(); + + const replay = JSON.parse(sentBodies[1]!) 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); + }); +}); diff --git a/tests/plaintext-v2-agent-messages.test.ts b/tests/plaintext-v2-agent-messages.test.ts new file mode 100644 index 0000000000..1639d23cfd --- /dev/null +++ b/tests/plaintext-v2-agent-messages.test.ts @@ -0,0 +1,691 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; +import { + PLAINTEXT_V2_COLLABORATION_NAMESPACE, + preparePlaintextV2AgentMessages, + restorePlaintextV2AgentMessageCalls, + restorePlaintextV2AgentMessageCallsInJson, + shouldPreparePlaintextV2AgentMessages, +} from "../src/responses/plaintext-v2-agent-messages"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +function collaborationTool(name: string, encrypted: boolean = true): Record { + return { + type: "function", + name, + parameters: { + type: "object", + properties: { + message: { + type: "string", + encrypted, + const: { encrypted: true }, + }, + encrypted: { type: "boolean" }, + }, + required: ["message"], + }, + }; +} + +describe("plaintext v2 agent message request preparation", () => { + test("strips only the three message markers and aliases collaboration catalogs", () => { + const body = { + model: "gpt-5.6-sol", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + collaborationTool("spawn_agent"), + collaborationTool("send_message"), + collaborationTool("followup_task", false), + collaborationTool("wait_agent"), + ], + }], + input: [{ + type: "additional_tools", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("followup_task")], + }], + }], + }; + const before = structuredClone(body); + + const prepared = preparePlaintextV2AgentMessages(body); + const result = prepared.body as typeof body; + const namespace = result.tools[0] as typeof body.tools[0]; + const spawn = namespace.tools[0] as ReturnType; + const send = namespace.tools[1] as ReturnType; + const followup = namespace.tools[2] as ReturnType; + const wait = namespace.tools[3] as ReturnType; + const additionalNamespace = result.input[0].tools[0] as { + name: string; + tools: Array>; + }; + const additional = additionalNamespace.tools[0]!; + const message = (tool: Record) => ( + ((tool.parameters as Record).properties as Record>).message + ); + + expect(prepared.namespaceAliased).toBe(true); + expect([...prepared.toolNames].sort()).toEqual([ + "followup_task", + "send_message", + "spawn_agent", + "wait_agent", + ]); + expect(namespace.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(additionalNamespace.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(spawn.name).toBe("start_delegated_task"); + expect(send.name).toBe("deliver_delegated_message"); + expect(followup.name).toBe("continue_delegated_task"); + expect(additional.name).toBe("continue_delegated_task"); + expect(wait.name).toBe("wait_agent"); + expect(message(spawn).encrypted).toBeUndefined(); + expect(message(send).encrypted).toBeUndefined(); + expect(message(additional).encrypted).toBeUndefined(); + expect(message(followup).encrypted).toBe(false); + expect(message(wait).encrypted).toBe(true); + expect(message(spawn).const).toEqual({ encrypted: true }); + expect(((spawn.parameters as Record).properties as Record).encrypted) + .toEqual({ type: "boolean" }); + expect(body).toEqual(before); + }); + + test("does not reinterpret a flat same-named function as the Codex v2 catalog", () => { + const body = { tools: [collaborationTool("spawn_agent")] }; + const prepared = preparePlaintextV2AgentMessages(body); + const tool = (prepared.body as typeof body).tools[0] as Record; + const message = ((tool.parameters as Record).properties as Record>).message; + + expect(message.encrypted).toBe(true); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("does not strip same-named tools from another namespace", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { + type: "namespace", + name: "private_mail", + tools: [collaborationTool("send_message")], + }, + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const privateTool = (prepared.body as typeof body).tools[1]!.tools[0] as Record; + const privateMessage = ((privateTool.parameters as Record).properties as Record>).message; + + expect(privateMessage.encrypted).toBe(true); + expect(privateTool.name).toBe("send_message"); + }); + + test("does not strip an independent flat tool beside a collaboration namespace", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + collaborationTool("send_message"), + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const flatTool = (prepared.body as typeof body).tools[1] as Record; + const flatMessage = ((flatTool.parameters as Record).properties as Record>).message; + expect(flatMessage.encrypted).toBe(true); + expect(flatTool.name).toBe("send_message"); + }); + + test("aliases selectors and replayed calls with the rewritten collaboration catalog", () => { + const replayedCall = { + type: "function_call", + call_id: "call-old", + namespace: "collaboration", + name: "spawn_agent", + arguments: "{}", + }; + const replayedOutput = { + type: "function_call_output", + call_id: "call-old", + output: { namespace: "collaboration" }, + }; + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent"), collaborationTool("send_message")], + }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", namespace: "collaboration", name: "send_message" }, + { type: "function", namespace: "private_mail", name: "send_message" }, + ], + }, + input: [replayedCall, replayedOutput], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const result = prepared.body as typeof body; + + expect(result.tools[0]!.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(result.tool_choice.tools[0]!.namespace).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(result.tool_choice.tools[0]!.name).toBe("deliver_delegated_message"); + expect(result.tool_choice.tools[1]!.namespace).toBe("private_mail"); + expect(result.input[0]!.namespace).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(result.input[0]!.name).toBe("start_delegated_task"); + expect(result.input[1]).toEqual(replayedOutput); + }); + + test("aliases a forced collaboration tool choice", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent"), collaborationTool("followup_task")], + }], + tool_choice: { type: "function", namespace: "collaboration", name: "followup_task" }, + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect((prepared.body as typeof body).tool_choice.namespace) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect((prepared.body as typeof body).tool_choice.name).toBe("continue_delegated_task"); + }); + + test("aliases both supported qualified-name forms using declared child names", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent"), collaborationTool("send_message")], + }], + tool_choice: { type: "function", name: "collaboration__spawn_agent" }, + input: [{ + type: "function_call", + call_id: "call-send", + name: "collaboration.send_message", + arguments: "{}", + }], + }; + + const result = preparePlaintextV2AgentMessages(body).body as typeof body; + expect(result.tool_choice.name) + .toBe(`${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`); + expect(result.input[0]!.name) + .toBe(`${PLAINTEXT_V2_COLLABORATION_NAMESPACE}.deliver_delegated_message`); + }); + + test("aliases every duplicate collaboration declaration in one request", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "wait_agent", parameters: { type: "object" } }], + }, + ], + tool_choice: { type: "function", namespace: "collaboration", name: "wait_agent" }, + }; + + const result = preparePlaintextV2AgentMessages(body).body as typeof body; + expect(result.tools.map(tool => tool.name)).toEqual([ + PLAINTEXT_V2_COLLABORATION_NAMESPACE, + PLAINTEXT_V2_COLLABORATION_NAMESPACE, + ]); + expect(result.tool_choice.namespace).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + }); + + test("does not reinterpret an independent flattened-looking tool name", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { type: "function", name: "collaboration__audit", parameters: { type: "object" } }, + ], + tool_choice: { + type: "allowed_tools", + tools: [{ type: "function", name: "collaboration__audit" }], + }, + input: [{ + type: "function_call", + call_id: "call-audit", + name: "collaboration__audit", + arguments: "{}", + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + const result = prepared.body as typeof body; + expect(result.tool_choice.tools[0]!.name).toBe("collaboration__audit"); + expect(result.input[0]!.name).toBe("collaboration__audit"); + }); + + test("skips aliasing when a flat declaration collides with a namespace child", () => { + const body = { + tools: [ + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + { type: "function", name: "collaboration__spawn_agent", parameters: { type: "object" } }, + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("aliases a recognized collaboration catalog even when the marker is already absent", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent", false)], + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect((prepared.body as typeof body).tools[0]!.name) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect((prepared.body as typeof body).tools[0]!.tools[0]!.name) + .toBe("start_delegated_task"); + expect(prepared.namespaceAliased).toBe(true); + }); + + test("leaves the whole request untouched when a collaboration child already uses a private tool alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + collaborationTool("spawn_agent"), + collaborationTool("start_delegated_task"), + ], + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("leaves the whole request untouched when replay history already uses a private tool alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + input: [{ + type: "function_call", + call_id: "call-private-name", + namespace: "collaboration", + name: "start_delegated_task", + arguments: "{}", + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("leaves the whole request untouched when the private alias already exists", () => { + const body = { + tools: [ + { type: "namespace", name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, tools: [] }, + { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }, + ], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + expect(JSON.stringify(prepared.body)).toContain('"encrypted":true'); + }); + + test("leaves the request untouched when replay history already uses the private alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + input: [{ + type: "function_call", + call_id: "call-private", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "audit", + arguments: "{}", + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("leaves the request untouched when tool-search history declares the private alias", () => { + const body = { + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + input: [{ + type: "tool_search_output", + tools: [{ + type: "namespace", + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: [], + }], + }], + }; + + const prepared = preparePlaintextV2AgentMessages(body); + expect(prepared.body).toBe(body); + expect(prepared.namespaceAliased).toBe(false); + }); + + test("does not treat a collaboration namespace nested under another namespace as Codex v2", () => { + const depth = 20_000; + const collaboration = { + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + } as Record; + let root: Record = collaboration; + for (let index = 0; index < depth; index++) { + root = { type: "namespace", name: `nest-${index}`, tools: [root] }; + } + + const prepared = preparePlaintextV2AgentMessages({ tools: [root] }); + expect(prepared.namespaceAliased).toBe(false); + }); +}); + +describe("plaintext v2 agent message response restoration", () => { + const declaredToolNames = new Set(["spawn_agent", "send_message"]); + + test("restores tool identities and preserves the plaintext proof and user data", () => { + const payload = JSON.stringify({ + type: "response.completed", + response: { + tool_choice: { + type: "function", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + }, + tools: [{ + type: "namespace", + name: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + tools: [{ type: "function", name: "start_delegated_task" }], + }], + output: [ + { + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: JSON.stringify({ + message: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + }), + encrypted_function_args: [], + }, + { + type: "function_call", + name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__deliver_delegated_message`, + arguments: "{}", + encrypted_function_args: [], + }, + { + type: "function_call_output", + output: { namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE }, + }, + ], + }, + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as { + response: { + tool_choice: Record; + tools: Array>; + output: Array>; + }; + }; + const [namespaced, flattened, toolOutput] = restored.response.output; + + expect(namespaced!.namespace).toBe("collaboration"); + expect(namespaced!.name).toBe("spawn_agent"); + expect(namespaced!.encrypted_function_args).toEqual([]); + expect(JSON.parse(namespaced!.arguments as string).message) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(flattened!.name).toBe("collaboration__send_message"); + expect(flattened!.encrypted_function_args).toEqual([]); + expect(toolOutput!.output).toEqual({ namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE }); + expect(restored.response.tool_choice.namespace).toBe("collaboration"); + expect(restored.response.tool_choice.name).toBe("spawn_agent"); + expect(restored.response.tools[0]!.name).toBe("collaboration"); + expect((restored.response.tools[0]!.tools as Array>)[0]!.name) + .toBe("spawn_agent"); + }); + + test("restores the identity on streamed function-call argument completion", () => { + const payload = JSON.stringify({ + type: "response.function_call_arguments.done", + item_id: "fc-spawn", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`, + arguments: JSON.stringify({ message: PLAINTEXT_V2_COLLABORATION_NAMESPACE }), + encrypted_function_args: [], + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as Record; + expect(restored.namespace).toBe("collaboration"); + expect(restored.name).toBe("collaboration__spawn_agent"); + expect(JSON.parse(restored.arguments as string).message) + .toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(restored.encrypted_function_args).toEqual([]); + }); + + test("is byte-identical for invalid JSON and payloads without the private alias", () => { + for (const payload of ["not json", '{"type":"response.completed"}']) { + expect(restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames)).toBe(payload); + } + }); + + test("restores an unqualified private tool alias in streamed JSON", () => { + const payload = JSON.stringify({ + type: "function_call", + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain assignment" }), + encrypted_function_args: [], + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as Record; + expect(restored.name).toBe("spawn_agent"); + expect(restored.encrypted_function_args).toEqual([]); + }); + + test("leaves undeclared aliases and nested extension metadata untouched", () => { + const extensionCall = { + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + }; + const payload = JSON.stringify({ + type: "response.completed", + response: { + output: [ + { + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "audit", + arguments: "{}", + }, + { + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + arguments: "{}", + }, + ], + metadata: { + nested: extensionCall, + values: Array.from({ length: 20_000 }, (_, index) => index), + }, + }, + }); + + const restored = JSON.parse( + restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames), + ) as { + response: { + output: Array>; + metadata: { nested: Record; values: number[] }; + }; + }; + + expect(restored.response.output[0]!.namespace).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(restored.response.output[1]!.namespace).toBe("collaboration"); + expect(restored.response.metadata.nested).toEqual(extensionCall); + expect(restored.response.metadata.values).toHaveLength(20_000); + }); + + test("fails closed when known identity arrays exceed the work limit", () => { + const value = { + output: Array.from({ length: 10_001 }, () => ({ + type: "function_call", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: "start_delegated_task", + })), + }; + const payload = JSON.stringify(value); + + expect(restorePlaintextV2AgentMessageCallsInJson(payload, declaredToolNames)).toBe(payload); + expect(restorePlaintextV2AgentMessageCalls(value, declaredToolNames)).toEqual({ + value, + changed: false, + overflowed: true, + }); + }); +}); + +describe("plaintext v2 agent message route policy", () => { + test("requires an explicit opt-in, Responses inbound, canonical ChatGPT, and a v2 catalog", () => { + const requestBody = { + input: [{ + type: "additional_tools", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + }], + }; + const baseline = { + enabled: true, + inboundWire: "responses", + canonicalChatGpt: true, + requestBody, + }; + expect(shouldPreparePlaintextV2AgentMessages(baseline)).toBe(true); + expect(shouldPreparePlaintextV2AgentMessages({ ...baseline, enabled: false })).toBe(false); + expect(shouldPreparePlaintextV2AgentMessages({ ...baseline, inboundWire: "anthropic" })).toBe(false); + expect(shouldPreparePlaintextV2AgentMessages({ ...baseline, canonicalChatGpt: false })).toBe(false); + expect(shouldPreparePlaintextV2AgentMessages({ + ...baseline, + requestBody: { tools: [collaborationTool("spawn_agent")] }, + })).toBe(false); + }); +}); + +describe("canonical Responses adapter plaintext v2 integration", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward" as const, + }; + + function build(enabled: boolean) { + const rawBody = { + model: "gpt-5.6-sol", + store: false, + stream: true, + input: "delegate", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [collaborationTool("spawn_agent")], + }], + }; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + ...(enabled ? { _plaintextV2AgentMessages: true } : {}), + }, { headers: new Headers({ authorization: "Bearer test" }) }); + return { request, rawBody }; + } + + test("changes only the serialized upstream body when enabled", () => { + const { request, rawBody } = build(true); + const sent = JSON.parse(request.body) as typeof rawBody; + const namespace = sent.tools[0]!; + const spawn = namespace.tools[0] as Record; + const message = ((spawn.parameters as Record).properties as Record>).message; + + expect(namespace.name).toBe(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(spawn.name).toBe("start_delegated_task"); + expect(message.encrypted).toBeUndefined(); + expect([...(request.plaintextV2AgentMessageToolNames ?? [])]).toEqual(["spawn_agent"]); + expect(rawBody.tools[0]!.name).toBe("collaboration"); + expect(JSON.stringify(rawBody)).toContain('"encrypted":true'); + }); + + test("keeps the upstream collaboration schema unchanged when disabled", () => { + const { request, rawBody } = build(false); + const sent = JSON.parse(request.body); + + expect(sent).toEqual(rawBody); + expect(request.plaintextV2AgentMessageToolNames).toBeUndefined(); + }); +}); diff --git a/tests/ws-upstream.test.ts b/tests/ws-upstream.test.ts index c16060c9f5..e22279620d 100644 --- a/tests/ws-upstream.test.ts +++ b/tests/ws-upstream.test.ts @@ -288,6 +288,157 @@ describe("handleResponses Codex WS relay selection", () => { expect(text).toContain("data: [DONE]"); }); + test("plaintext v2 collaboration rewriting applies to WS requests and responses", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { + data: JSON.stringify({ + type: "response.created", + response: { + id: "r-plaintext-v2-ws", + object: "response", + status: "in_progress", + output: [], + }, + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: { + type: "function_call", + id: "fc_spawn", + call_id: "call-spawn", + namespace: "collaboration-optimize", + name: "start_delegated_task", + arguments: "", + encrypted_function_args: [], + status: "in_progress", + }, + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.function_call_arguments.done", + item_id: "fc_spawn", + output_index: 0, + namespace: "collaboration-optimize", + name: "collaboration-optimize__start_delegated_task", + arguments: JSON.stringify({ message: "plain WS assignment" }), + encrypted_function_args: [], + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "function_call", + id: "fc_spawn", + call_id: "call-spawn", + namespace: "collaboration-optimize", + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain WS assignment" }), + encrypted_function_args: [], + status: "completed", + }, + }), + }); + ws.emit("message", { + data: JSON.stringify({ + type: "response.completed", + response: { + id: "r-plaintext-v2-ws", + status: "completed", + output: [{ + type: "function_call", + id: "fc_spawn", + call_id: "call-spawn", + namespace: "collaboration-optimize", + name: "start_delegated_task", + arguments: JSON.stringify({ message: "plain WS assignment" }), + encrypted_function_args: [], + status: "completed", + }], + }, + }), + }); + }); + const config = { ...forwardConfig(), plaintextV2AgentMessages: true } as OcxConfig; + const request = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer test" }, + body: JSON.stringify({ + model: "gpt-5.5", + stream: true, + input: [ + { + type: "additional_tools", + tools: [{ + type: "namespace", + name: "collaboration", + tools: [ + { + type: "function", + name: "spawn_agent", + parameters: { + type: "object", + properties: { message: { type: "string", encrypted: true } }, + }, + }, + { type: "function", name: "send_message", parameters: { type: "object" } }, + ], + }], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "delegate" }] }, + ], + }), + }); + + const response = await handleResponses(request, config, { model: "", provider: "" }, { + codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME, + }); + expect(FakeWebSocket.instances).toHaveLength(1); + const frame = JSON.parse(FakeWebSocket.instances[0]!.sent[0]!) as { + type: string; + stream?: unknown; + input: Array>; + }; + const additionalTools = frame.input.find(item => item.type === "additional_tools") as { + tools: Array<{ + name: string; + tools: Array<{ + name: string; + parameters: { properties: { message: Record } }; + }>; + }>; + }; + expect(frame.type).toBe("response.create"); + expect(frame.stream).toBeUndefined(); + expect(additionalTools.tools[0]!.name).toBe("collaboration-optimize"); + expect(additionalTools.tools[0]!.tools[0]!.name).toBe("start_delegated_task"); + expect(additionalTools.tools[0]!.tools[0]!.parameters.properties.message.encrypted).toBeUndefined(); + + expect(isEagerRelaySseResponse(response)).toBe(true); + const clientText = await response.text(); + expect(clientText).toContain("response.function_call_arguments.done"); + const argumentDoneLine = clientText.split("\n") + .find(line => line.includes('"response.function_call_arguments.done"'))!; + const argumentDone = JSON.parse(argumentDoneLine.replace(/^data: /, "")) as Record; + expect(argumentDone.namespace).toBe("collaboration"); + expect(argumentDone.name).toBe("collaboration__spawn_agent"); + expect(argumentDone.encrypted_function_args).toEqual([]); + const completedLine = clientText.split("\n") + .find(line => line.includes('"response.completed"'))!; + const completed = JSON.parse(completedLine.replace(/^data: /, "")) as { + response: { output: Array> }; + }; + expect(completed.response.output[0]!.namespace).toBe("collaboration"); + expect(completed.response.output[0]!.name).toBe("spawn_agent"); + expect(completed.response.output[0]!.encrypted_function_args).toEqual([]); + }); + test("an HTTP fallback remains on the configured legacy tee path", async () => { installFake(ws => ws.close()); globalThis.fetch = (async () => new Response(