From 80f8495694130aafda0a536f82642b26c0e32001 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 10 Jul 2026 17:36:57 -0400 Subject: [PATCH 1/6] feat: add GPT-5.6 Ultra support --- docs/configuration.md | 5 + docs/development/ULTRA.md | 46 ++++ docs/development/UPSTREAM_SYNC.md | 4 +- docs/development/upstream-watch.json | 8 +- docs/index.md | 1 + lib/codex-native.ts | 15 +- lib/codex-native/chat-hooks.ts | 46 +++- lib/codex-native/client-identity.ts | 2 +- lib/codex-native/openai-loader-fetch.ts | 22 +- lib/codex-native/request-transform-payload.ts | 43 ++- lib/codex-native/ultra.ts | 139 ++++++++++ lib/model-catalog/provider.ts | 19 +- lib/model-catalog/shared.ts | 24 +- lib/shareable-debug.ts | 13 +- test/codex-native-client-version.test.ts | 4 +- test/codex-native-config-variants.test.ts | 5 +- test/model-catalog.fetch-cache.test.ts | 24 +- test/model-catalog.provider-models.test.ts | 19 +- test/ultra.test.ts | 249 ++++++++++++++++++ test/upstream-watch-config.test.ts | 2 +- 20 files changed, 645 insertions(+), 45 deletions(-) create mode 100644 docs/development/ULTRA.md create mode 100644 lib/codex-native/ultra.ts create mode 100644 test/ultra.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 9b4f171..471a30b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -158,6 +158,11 @@ Mode-derived runtime defaults when omitted: - Global reasoning effort override forwarded upstream when the request does not already set one. - When omitted, the selected model's live catalog `default_reasoning_level` is used, typically `"medium"`. - User config can still override reasoning effort globally, per model, or per variant. +- `ultra` reasoning variant + - Catalog-derived and available only when the active model advertises `ultra` with `multi_agent_version: "v2"`. + - `codex` mode adds best-effort proactive delegation guidance; `native` mode preserves OpenCode-native prompt identity. + - Literal configured `ultra` values remain safe on unsupported or stale catalogs: the backend request sends wire effort `max`, without proactive delegation. + - No separate Ultra feature flag or concurrency setting is public; existing collaboration and subagent controls remain authoritative. - `global.reasoningMode: "standard" | "pro"` (optional) - GPT-5.6 reasoning mode, emitted as `reasoning.mode` independently of `reasoning.effort`. - An explicit request value is preserved. The same per-model and per-variant precedence applies. diff --git a/docs/development/ULTRA.md b/docs/development/ULTRA.md new file mode 100644 index 0000000..6f4902d --- /dev/null +++ b/docs/development/ULTRA.md @@ -0,0 +1,46 @@ +# GPT-5.6 Ultra + +This plugin treats Ultra as a logical model variant, not as a new inference effort. + +| State | Contract | +| --- | --- | +| Catalog and picker | `ultra` remains distinct from `max` and is exposed only when the selected catalog model advertises the `ultra` effort, `multi_agent_version: "v2"`, visible status, and API support. | +| Root turn | An eligible Ultra turn in `codex` mode receives a best-effort proactive delegation instruction. Native mode preserves the OpenCode-native identity and does not add a Codex delegation overlay. | +| Child turn | A child inherits maximum reasoning but receives explicit-request-only delegation guidance to avoid uncontrolled recursive fan-out. | +| Backend request | Every literal `reasoning.effort: "ultra"` is normalized to `"max"` at the last-mile request transform. Explicit `max` never receives Ultra policy. | +| Missing or stale metadata | Ultra is disabled when catalog metadata cannot prove eligibility. A manually configured literal `ultra` is safe-degraded to wire `max` without proactive instructions. | +| Failure | Missing task tools, disabled collaboration, spawn failure, cancellation, or partial completion do not fail the root turn. The agent continues locally and must not claim delegation that did not happen. | + +The live account-scoped catalog is authoritative. GitHub fallback data is parsed through the same schema and is used only when the live source is unavailable. The plugin does not recreate account entitlement or minimum-client enforcement from catalog metadata. + +## State lifecycle + +1. `chat.params` resolves the selected model, effort suffix, variant, and custom-model target against the active catalog. +2. The logical state is retained as `ultra`; eligible `codex`-mode root turns merge the proactive instruction idempotently, while `codex`-mode child turns merge the explicit-only instruction. Native mode keeps the logical state without prompt adaptation. +3. `chat.headers` records a redacted internal Ultra state marker alongside the existing catalog scope and selected-model markers. +4. Each retry resolves the current catalog scope again and applies the same last-mile normalization. Request snapshots include logical effort, wire effort, eligibility, policy, and the reason for any degradation. +5. Compaction, resume, account rotation, and catalog-scope changes inherit only the state represented by the current request and catalog. Stale catalog defaults are removed by the existing catalog-scope cleanup path. + +## Degradation and guardrails + +Ultra is best effort at the OpenCode collaboration boundary. The plugin does not claim parity with proprietary desktop orchestration. A missing task tool or failed child spawn is observable in the host's normal tool/error path, but it is not a reason to reject the root request. Child turns are explicit-only by default; the host remains responsible for its own concurrency and cancellation controls. + +No new public concurrency or feature flag is required. Existing collaboration-profile and subagent controls remain authoritative, and no private catalog/runtime default is added to public configuration. + +## Verification matrix + +The minimum release evidence covers: + +- parser retention for effort descriptions, `multi_agent_version`, `minimal_client_version`, visibility, and API support; +- eligible Sol/Terra variants, ineligible V1/hidden/non-API variants, fallback catalogs, custom aliases, and effort suffixes; +- root proactive and child explicit-only instruction composition, including idempotent merges and preserved user/orchestrator instructions; +- literal Ultra normalization to wire Max, explicit Max remaining non-Ultra, and normalization on retries/catalog-scope changes; +- redacted snapshots for logical and wire state without internal headers reaching the backend; +- compaction and auxiliary request paths remaining safe because their payloads pass through the same last-mile transform; +- `npm run verify` and the distribution CLI smoke check. + +## Rollout and rollback + +Ultra follows the existing catalog-driven release path. It is visible only when authoritative metadata proves eligibility; there is no launch-time allowlist for Sol or Terra and no package release in this change. Before publication, run the full verification gate and a manual smoke using an eligible catalog response. + +Rollback is the smallest code/config rollback that removes the Ultra instruction and variant eligibility predicate while leaving account storage and catalog caches intact. Existing literal `reasoningEffort: "ultra"` values remain safe because the request transform continues to send wire `max`. Upstream changes are tracked through `docs/development/UPSTREAM_SYNC.md` and the repository's upstream-watch configuration; a changed Ultra contract requires a new compatibility decision before behavior is broadened. diff --git a/docs/development/UPSTREAM_SYNC.md b/docs/development/UPSTREAM_SYNC.md index 9e8a34b..443d22c 100644 --- a/docs/development/UPSTREAM_SYNC.md +++ b/docs/development/UPSTREAM_SYNC.md @@ -10,7 +10,7 @@ Track the OpenCode and Codex releases this plugin is aligned to, and how to keep - Upstream HEAD inspected: GitHub latest release/tag via `npm run check:upstream` - Native Codex reference file: `packages/opencode/src/plugin/codex.ts` - Codex upstream repo: `https://github.com/openai/codex` -- Codex upstream release track: `rust-v0.116.0` +- Codex upstream release track: `rust-v0.144.1` for the GPT-5.6 Ultra contract - Local dependency target: - `@opencode-ai/plugin`: `^1.3.0` - `@opencode-ai/sdk`: `^1.3.0` @@ -52,7 +52,7 @@ Tracked upstream surfaces include: - Provider core: `packages/opencode/src/provider/provider.ts`, `packages/opencode/src/provider/auth.ts` - Provider transforms/schema/error handling: `packages/opencode/src/provider/transform.ts`, `packages/opencode/src/provider/models.ts`, `packages/opencode/src/provider/error.ts` - Session-side OpenAI stream error handling: `packages/opencode/src/session/message-v2.ts` -- Codex upstream model/auth/runtime files: `codex-rs/core/models.json`, `codex-rs/core/src/auth.rs`, `codex-rs/core/src/client.rs`, `codex-rs/core/src/codex.rs`, `codex-rs/core/src/compact.rs` +- Codex upstream model/auth/runtime files: `codex-rs/models-manager/models.json`, `codex-rs/core/src/auth.rs`, `codex-rs/core/src/client.rs`, `codex-rs/core/src/codex.rs`, `codex-rs/core/src/compact.rs` All automated upstream checks fetch directly from GitHub release tags (`api.github.com` and `raw.githubusercontent.com`). No local upstream clones are required for drift detection. diff --git a/docs/development/upstream-watch.json b/docs/development/upstream-watch.json index f6af9d5..ea67b13 100644 --- a/docs/development/upstream-watch.json +++ b/docs/development/upstream-watch.json @@ -59,12 +59,12 @@ { "id": "codex-rs", "repo": "openai/codex", - "baselineTag": "rust-v0.116.0", - "updatedAt": "2026-03-23T16:25:00.407Z", + "baselineTag": "rust-v0.144.1", + "updatedAt": "2026-07-10T21:25:00.000Z", "files": [ { - "path": "codex-rs/core/models.json", - "sha256": "5e6bb2e7e1628967407be673f70ffefe4744711bc856cb12d4fba041203ae22f", + "path": "codex-rs/models-manager/models.json", + "sha256": "dcab00231a5178a9c84b7aef4cc06a1e1359e37ee0dd7e69d5822c4b1de723b1", "localArea": "lib/model-catalog.ts", "reason": "Model catalog defaults and capabilities parity" }, diff --git a/docs/index.md b/docs/index.md index 21b1de6..0058d1e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,7 @@ Use this page as the fast entrypoint for humans and agents. - `docs/development/CONFIG_FLOW.md` - `docs/development/TESTING.md` - `docs/development/UPSTREAM_SYNC.md` +- `docs/development/ULTRA.md` - `docs/DOCUMENTATION.md` ## Examples diff --git a/lib/codex-native.ts b/lib/codex-native.ts index f82e4d1..8e5af0d 100644 --- a/lib/codex-native.ts +++ b/lib/codex-native.ts @@ -65,6 +65,7 @@ import { createSessionAffinityRuntimeState } from "./codex-native/session-affini import { initializeCatalogSync, selectCatalogAuthCandidate } from "./codex-native/catalog-sync.js" import { createOpenAIFetchHandler } from "./codex-native/openai-loader-fetch.js" import { createShareableDebugLogger } from "./shareable-debug.js" +import { isUltraEligible, type UltraResolution } from "./codex-native/ultra.js" export { browserOpenInvocationFor } from "./codex-native/browser.js" export { upsertAccount } from "./codex-native/accounts.js" export { extractAccountId, extractAccountIdFromClaims, refreshAccessToken } from "./codex-native/oauth-utils.js" @@ -74,6 +75,7 @@ const INTERNAL_COLLABORATION_AGENT_HEADER = "x-opencode-collaboration-agent-kind const INTERNAL_CATALOG_SCOPE_HEADER = "x-opencode-catalog-scope-key" const INTERNAL_CATALOG_DEFAULTS_HEADER = "x-opencode-catalog-default-fields" const INTERNAL_SELECTED_MODEL_HEADER = "x-opencode-selected-model-slug" +const INTERNAL_ULTRA_STATE_HEADER = "x-opencode-ultra-state" const SESSION_AFFINITY_MISSING_GRACE_MS = 15 * 60 * 1000 const REASONING_VARIANT_KEYS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] as const @@ -221,7 +223,7 @@ function buildVariantConfigOverrides(model: CodexModelInfo): Record([...REASONING_VARIANT_KEYS, ...supportedEfforts]) return Object.fromEntries( Array.from(variants).map((variant) => { - if (!supportedEfforts.includes(variant)) { + if (!supportedEfforts.includes(variant) || (variant === "ultra" && !isUltraEligible(model))) { return [variant, { disabled: true }] } return [ @@ -399,7 +401,11 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO const catalogModelsByScope = new Map() const catalogRequestMetadataBySession = new Map< string, - Array<{ catalogScopeKey?: string; injectedCatalogDefaultFields: string[] }> + Array<{ + catalogScopeKey?: string + injectedCatalogDefaultFields: string[] + ultra?: UltraResolution + }> >() let activeCatalogScopeKey: string | undefined let activeCatalogModels: CodexModelInfo[] | undefined @@ -659,7 +665,8 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO const queue = catalogRequestMetadataBySession.get(sessionID) ?? [] queue.push({ catalogScopeKey: requestCatalogScopeKey, - injectedCatalogDefaultFields: paramsResult.injectedCatalogDefaultFields + injectedCatalogDefaultFields: paramsResult.injectedCatalogDefaultFields, + ultra: paramsResult.ultra }) catalogRequestMetadataBySession.set(sessionID, queue) }, @@ -676,6 +683,8 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO spoofMode, requestCatalogScopeKey, injectedCatalogDefaultFields: queuedMetadata?.injectedCatalogDefaultFields, + ultra: queuedMetadata?.ultra, + internalUltraStateHeader: INTERNAL_ULTRA_STATE_HEADER, internalCatalogScopeHeader: INTERNAL_CATALOG_SCOPE_HEADER, internalCatalogDefaultsHeader: INTERNAL_CATALOG_DEFAULTS_HEADER, internalSelectedModelHeader: INTERNAL_SELECTED_MODEL_HEADER, diff --git a/lib/codex-native/chat-hooks.ts b/lib/codex-native/chat-hooks.ts index 8338f9b..df06577 100644 --- a/lib/codex-native/chat-hooks.ts +++ b/lib/codex-native/chat-hooks.ts @@ -43,6 +43,12 @@ import { resolveCollaborationProfile, resolveSubagentHeaderValue } from "./collaboration.js" +import { + ULTRA_EXPLICIT_ONLY_INSTRUCTIONS, + ULTRA_PROACTIVE_INSTRUCTIONS, + resolveUltraSelection, + type UltraResolution +} from "./ultra.js" function normalizeVerbositySetting(value: unknown): "default" | "low" | "medium" | "high" | "none" | undefined { if (typeof value !== "string") return undefined @@ -92,7 +98,7 @@ export async function handleChatParamsHook(input: { spoofMode: CodexSpoofMode collaborationProfileEnabled: boolean orchestratorSubagentsEnabled: boolean -}): Promise<{ injectedCatalogDefaultFields: string[] }> { +}): Promise<{ injectedCatalogDefaultFields: string[]; ultra?: UltraResolution }> { const emptyResult = { injectedCatalogDefaultFields: [] } if (input.hookInput.model.providerID !== "openai") return emptyResult const modelOptions = isRecord(input.hookInput.model.options) ? input.hookInput.model.options : {} @@ -233,8 +239,26 @@ export async function handleChatParamsHook(input: { output: input.output }) + const ultraResolution = resolveUltraSelection({ + reasoningEffort: input.output.options.reasoningEffort, + model: catalogModelFromOptions ?? catalogModelFallback, + childTask: resolveSubagentHeaderValue(input.hookInput.agent) !== undefined + }) + if (input.spoofMode === "codex" && ultraResolution.selected && ultraResolution.eligible) { + const ultraInstructions = + ultraResolution.delegationPolicy === "proactive" ? ULTRA_PROACTIVE_INSTRUCTIONS : ULTRA_EXPLICIT_ONLY_INSTRUCTIONS + input.output.options.instructions = mergeInstructions( + asString(input.output.options.instructions), + ultraInstructions + ) + } + const result = (): { injectedCatalogDefaultFields: string[]; ultra?: UltraResolution } => ({ + injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields, + ...(ultraResolution.selected ? { ultra: ultraResolution } : {}) + }) + if (input.spoofMode !== "codex") { - return { injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields } + return result() } const normalizedAgentName = resolveHookAgentName(input.hookInput.agent)?.trim().toLowerCase() @@ -244,25 +268,25 @@ export async function handleChatParamsHook(input: { if (replaced) { input.output.options.instructions = replaced } - return { injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields } + return result() } if (!input.collaborationProfileEnabled) { - return { injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields } + return result() } if (!profile.enabled || !profile.kind) { - return { injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields } + return result() } if (profile.instructionPreset === "plan") { const replacedPlan = replaceCodexToolCallsForOpenCode(getCodexPlanModeInstructions()) ?? getCodexPlanModeInstructions() input.output.options.instructions = mergeInstructions(asString(input.output.options.instructions), replacedPlan) - return { injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields } + return result() } - return { injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields } + return result() } export async function handleChatHeadersHook(input: { @@ -271,6 +295,8 @@ export async function handleChatHeadersHook(input: { spoofMode: CodexSpoofMode requestCatalogScopeKey?: string injectedCatalogDefaultFields?: string[] + ultra?: UltraResolution + internalUltraStateHeader?: string internalCatalogScopeHeader: string internalCatalogDefaultsHeader: string internalSelectedModelHeader: string @@ -301,6 +327,12 @@ export async function handleChatHeadersHook(input: { } else { delete input.output.headers[input.internalCatalogDefaultsHeader] } + const internalUltraStateHeader = input.internalUltraStateHeader ?? "x-opencode-ultra-state" + if (input.ultra?.selected) { + input.output.headers[internalUltraStateHeader] = JSON.stringify(input.ultra) + } else { + delete input.output.headers[internalUltraStateHeader] + } if (!input.collaborationProfileEnabled) { delete input.output.headers["x-openai-subagent"] diff --git a/lib/codex-native/client-identity.ts b/lib/codex-native/client-identity.ts index 6a12319..f528793 100644 --- a/lib/codex-native/client-identity.ts +++ b/lib/codex-native/client-identity.ts @@ -13,7 +13,7 @@ import type { CodexOriginator } from "./originator.js" import { URL } from "node:url" const DEFAULT_PLUGIN_VERSION = "0.1.0" -const DEFAULT_CODEX_CLIENT_VERSION = "0.116.0" +const DEFAULT_CODEX_CLIENT_VERSION = "0.144.0" const CODEX_CLIENT_VERSION_CACHE_FILE = path.join(defaultOpencodeCachePath(), "codex-client-version.json") const CODEX_CLIENT_VERSION_TTL_MS = 60 * 60 * 1000 const CODEX_GITHUB_RELEASES_API = "https://api.github.com/repos/openai/codex/releases/latest" diff --git a/lib/codex-native/openai-loader-fetch.ts b/lib/codex-native/openai-loader-fetch.ts index 1e8ebde..a09a050 100644 --- a/lib/codex-native/openai-loader-fetch.ts +++ b/lib/codex-native/openai-loader-fetch.ts @@ -26,6 +26,7 @@ import { toReasoningSummaryPluginFatalError } from "./reasoning-summary.js" import type { SessionAffinityRuntimeState } from "./session-affinity-state.js" import { scheduleQuotaRefresh } from "./openai-loader-fetch-quota.js" import type { ShareableDebugLogger } from "../shareable-debug.js" +import { retainUltraState, type UltraResolution } from "./ultra.js" import { CATALOG_REFRESH_FAILURE_RETRY_MS, CATALOG_REFRESH_TTL_MS, @@ -80,6 +81,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { const internalCatalogScopeHeader = input.internalCatalogScopeHeader ?? "x-opencode-catalog-scope-key" const internalCatalogDefaultsHeader = "x-opencode-catalog-default-fields" const internalSelectedModelHeader = input.internalSelectedModelHeader ?? "x-opencode-selected-model-slug" + const internalUltraStateHeader = "x-opencode-ultra-state" const internalCollaborationAgentHeader = input.internalCollaborationAgentHeader ?? "x-opencode-collaboration-agent-kind" const trustedSubagentValues = new Set(["review", "compact", "memory_consolidation", "collab_spawn"]) @@ -197,6 +199,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { let selectedAuthForQuota: { access: string; accountId?: string; identityKey?: string } | undefined let selectedCatalogModels: CodexModelInfo[] | undefined let selectedPreviousCatalogScopeKey: string | undefined + let ultraStateForRequest: UltraResolution | undefined const promptCacheKeyStrategy = input.promptCacheKeyStrategy ?? "default" const promptCacheKeyOverride = promptCacheKeyStrategy === "project" @@ -302,6 +305,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { maxRedirects: 3, showToast: input.showToast, onAttemptRequest: async ({ attempt, maxAttempts, attemptReasonCode, request, auth, sessionKey }) => { + ultraStateForRequest = retainUltraState(ultraStateForRequest, request.headers.get(internalUltraStateHeader)) await input.shareableDebug?.emitFetchAttemptRequest({ authMode: input.authMode, rotationStrategy: auth.selectionTrace?.strategy ?? input.configuredRotationStrategy, @@ -311,7 +315,8 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { request, selectedIdentityKey: auth.identityKey ?? auth.selectionTrace?.selectedIdentityKey, activeIdentityKey: auth.selectionTrace?.activeIdentityKey, - sessionKey + sessionKey, + ultra: ultraStateForRequest }) if (attemptReasonCode !== "initial_attempt") { await input.shareableDebug?.emitRetryAfter429({ @@ -322,7 +327,8 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { attemptReasonCode, selectedIdentityKey: auth.identityKey ?? auth.selectionTrace?.selectedIdentityKey, activeIdentityKey: auth.selectionTrace?.activeIdentityKey, - sessionKey + sessionKey, + ultra: ultraStateForRequest }) } @@ -342,6 +348,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { if (request.headers.has(internalCatalogDefaultsHeader)) { request.headers.delete(internalCatalogDefaultsHeader) } + request.headers.delete(internalUltraStateHeader) const selectedCatalogScopeKey = resolveCatalogScopeKey(auth) const requestCatalogModels = requestCatalogScopeKey ? input.getCatalogModels(requestCatalogScopeKey) : undefined const requestCatalogScopeChanged = @@ -362,9 +369,13 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { projectRoot: input.projectPath, fallbackPersonality: input.personality, behaviorSettings: input.behaviorSettings, - customModels: input.customModels + customModels: input.customModels, + ultraChildTask: isSubagentRequest, + ultraState: ultraStateForRequest }) + ultraStateForRequest = payloadTransform.ultra ?? ultraStateForRequest + if (payloadTransform.reasoningSummaryValidation) { throw toReasoningSummaryPluginFatalError(payloadTransform.reasoningSummaryValidation) } @@ -381,6 +392,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { developerMessageRemapReason: payloadTransform.developerRoleRemap.reason, developerMessageRemapCount: payloadTransform.developerRoleRemap.remappedCount, developerMessagePreservedCount: payloadTransform.developerRoleRemap.preservedCount, + ultra: payloadTransform.ultra, ...(isSubagentRequest ? { subagent: subagentHeader } : {}) }) } @@ -408,6 +420,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { developerMessageRemapReason: payloadTransform.developerRoleRemap.reason, developerMessageRemapCount: payloadTransform.developerRoleRemap.remappedCount, developerMessagePreservedCount: payloadTransform.developerRoleRemap.preservedCount, + ultra: payloadTransform.ultra, promptCacheKeyOverridden: payloadTransform.promptCacheKey.changed, promptCacheKeyOverrideReason: promptCacheKeyStrategy === "project" ? payloadTransform.promptCacheKey.reason : "default_strategy", @@ -451,7 +464,8 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { status: response.status, selectedIdentityKey: auth.identityKey ?? auth.selectionTrace?.selectedIdentityKey, activeIdentityKey: auth.selectionTrace?.activeIdentityKey, - sessionKey + sessionKey, + ultra: ultraStateForRequest }) await input.requestSnapshots.captureResponse("outbound-response", response, { attempt: attempt + 1, diff --git a/lib/codex-native/request-transform-payload.ts b/lib/codex-native/request-transform-payload.ts index d4e89ff..905e3dc 100644 --- a/lib/codex-native/request-transform-payload.ts +++ b/lib/codex-native/request-transform-payload.ts @@ -15,6 +15,7 @@ import { } from "./request-transform-model.js" import { type ReasoningSummaryValidationDiagnostic, resolveReasoningSummaryValue } from "./reasoning-summary.js" import { getRequestBodyVariantCandidates } from "./request-transform-model-service-tier.js" +import { normalizeUltraWireEffort, resolveUltraSelection, type UltraResolution } from "./ultra.js" import { type CompatSanitizerTransformResult, type DeveloperRoleRemapTransformResult, @@ -149,6 +150,8 @@ type OutboundRequestPayloadTransformInput = { fallbackPersonality?: PersonalityOption behaviorSettings?: BehaviorSettings customModels?: Record + ultraChildTask?: boolean + ultraState?: UltraResolution } export type OutboundRequestPayloadTransformResult = { @@ -160,6 +163,7 @@ export type OutboundRequestPayloadTransformResult = { compatSanitizer: CompatSanitizerTransformResult serviceTier: ServiceTierTransformResult reasoningSummaryValidation?: ReasoningSummaryValidationDiagnostic + ultra?: UltraResolution } export type ServiceTierTransformResult = { @@ -342,9 +346,37 @@ export async function transformOutboundRequestPayload( const finalPayload = compatSanitizedPayload?.payload ?? payload const existingReasoning = isRecord(finalPayload.reasoning) ? finalPayload.reasoning : undefined + const selectedSlug = asString(input.selectedModelSlug) + const selectedModelCandidates = selectedSlug + ? getModelLookupCandidates({ id: selectedSlug, api: { id: selectedSlug } }) + : [] + const selectedCatalogModel = + findCatalogModelForCandidates(input.catalogModels, selectedModelCandidates) ?? + (() => { + if (!input.customModels || !selectedSlug) return undefined + const customEntry = Object.entries(input.customModels).find( + ([slug]) => slug.trim().toLowerCase() === selectedSlug.toLowerCase() + )?.[1] + if (!customEntry) return undefined + return findCatalogModelForCandidates(input.catalogModels, [customEntry.targetModel]) + })() + const ultra = resolveUltraSelection({ + reasoningEffort: input.ultraState?.selected + ? input.ultraState.logicalEffort + : (existingReasoning?.effort ?? input.ultraState?.logicalEffort), + model: selectedCatalogModel, + childTask: input.ultraChildTask === true || input.ultraState?.delegationPolicy === "explicit_request_only" + }) + let ultraChanged = false + if (ultra.selected && existingReasoning) { + const normalizedWireEffort = normalizeUltraWireEffort(existingReasoning.effort) + if (normalizedWireEffort.changed && normalizedWireEffort.value) { + existingReasoning.effort = normalizedWireEffort.value + ultraChanged = true + } + } if (asString(existingReasoning?.mode) === undefined) { - const selectedSlug = asString(input.selectedModelSlug) - const candidates = selectedSlug ? getModelLookupCandidates({ id: selectedSlug, api: { id: selectedSlug } }) : [] + const candidates = selectedModelCandidates const variants = getRequestBodyVariantCandidates({ body: finalPayload, modelSlug: selectedSlug ?? "" }) const configuredCustomMode = getConfiguredCustomModelBehaviorOverrideValue( input.customModels, @@ -396,6 +428,7 @@ export async function transformOutboundRequestPayload( compatSanitizer.changed || selectedCatalogScopeSyncChanged || gpt54LongContextClampChanged || + ultraChanged || serviceTier.changed if (!changed) { @@ -407,7 +440,8 @@ export async function transformOutboundRequestPayload( promptCacheKey, compatSanitizer, serviceTier: { ...serviceTier, request: input.request }, - reasoningSummaryValidation + reasoningSummaryValidation, + ultra: ultra.selected ? ultra : undefined } } @@ -422,7 +456,8 @@ export async function transformOutboundRequestPayload( ...serviceTier, request: input.request }, - reasoningSummaryValidation + reasoningSummaryValidation, + ultra: ultra.selected ? ultra : undefined } } diff --git a/lib/codex-native/ultra.ts b/lib/codex-native/ultra.ts new file mode 100644 index 0000000..b4e1bc1 --- /dev/null +++ b/lib/codex-native/ultra.ts @@ -0,0 +1,139 @@ +import type { CodexModelInfo } from "../model-catalog.js" + +export const ULTRA_REASONING_EFFORT = "ultra" +export const ULTRA_WIRE_REASONING_EFFORT = "max" +export const ULTRA_MULTI_AGENT_VERSION = "v2" + +export type UltraDelegationPolicy = "proactive" | "explicit_request_only" + +export type UltraEligibilityReason = + | "eligible" + | "missing_catalog" + | "missing_ultra_effort" + | "missing_multi_agent_v2" + | "not_supported_in_api" + | "not_visible" + +export type UltraResolution = { + selected: boolean + logicalEffort: string | undefined + wireEffort: string | undefined + eligible: boolean + delegationPolicy: UltraDelegationPolicy + reason: UltraEligibilityReason + modelSlug?: string + multiAgentVersion?: string +} + +export const ULTRA_PROACTIVE_INSTRUCTIONS = `# Ultra Delegation + +When independent work can materially improve speed or quality, proactively delegate it to available task or subagent tools. Keep delegation focused: do not delegate trivial, dependent, or sensitive work without a clear benefit. If task tools are unavailable, disabled, or fail, continue the work yourself without claiming that delegation happened.` + +export const ULTRA_EXPLICIT_ONLY_INSTRUCTIONS = `# Ultra Child Delegation + +Use maximum reasoning for this task, but do not proactively delegate. Spawn or use child task tools only when the user, AGENTS.md, or an installed skill explicitly requests delegation. If a requested child task fails or is unavailable, continue with the work you can complete yourself.` + +function normalize(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim().toLowerCase() + return trimmed || undefined +} + +function supportsEffort(model: CodexModelInfo, effort: string): boolean { + return (model.supported_reasoning_levels ?? []).some((level) => normalize(level.effort) === effort) +} + +function modelIsVisible(model: CodexModelInfo): boolean { + const visibility = normalize(model.visibility) + return visibility === "list" +} + +export function getUltraEligibilityReason(model: CodexModelInfo | undefined): UltraEligibilityReason { + if (!model) return "missing_catalog" + if (!supportsEffort(model, ULTRA_REASONING_EFFORT)) return "missing_ultra_effort" + if (normalize(model.multi_agent_version) !== ULTRA_MULTI_AGENT_VERSION) return "missing_multi_agent_v2" + if (model.supported_in_api !== true) return "not_supported_in_api" + if (!modelIsVisible(model)) return "not_visible" + return "eligible" +} + +export function isUltraEligible(model: CodexModelInfo | undefined): boolean { + return getUltraEligibilityReason(model) === "eligible" +} + +export function resolveUltraSelection(input: { + reasoningEffort?: unknown + model?: CodexModelInfo + childTask?: boolean +}): UltraResolution { + const logicalEffort = normalize(input.reasoningEffort) + const selected = logicalEffort === ULTRA_REASONING_EFFORT + const reason = selected ? getUltraEligibilityReason(input.model) : "missing_catalog" + const eligible = selected && reason === "eligible" + + return { + selected, + logicalEffort, + wireEffort: selected ? ULTRA_WIRE_REASONING_EFFORT : logicalEffort, + eligible, + delegationPolicy: eligible && !input.childTask ? "proactive" : "explicit_request_only", + reason, + ...(input.model?.slug ? { modelSlug: input.model.slug } : {}), + ...(input.model?.multi_agent_version ? { multiAgentVersion: input.model.multi_agent_version } : {}) + } +} + +export function normalizeUltraWireEffort(value: unknown): { value: string | undefined; changed: boolean } { + const normalized = normalize(value) + if (!normalized) return { value: undefined, changed: false } + if (normalized !== ULTRA_REASONING_EFFORT) return { value: value as string, changed: false } + return { value: ULTRA_WIRE_REASONING_EFFORT, changed: true } +} + +export function parseUltraState(value: string | null | undefined): UltraResolution | undefined { + if (!value?.trim()) return undefined + try { + const parsed = JSON.parse(value) as Partial + if (parsed.selected !== true || parsed.logicalEffort !== ULTRA_REASONING_EFFORT) return undefined + if (parsed.wireEffort !== ULTRA_WIRE_REASONING_EFFORT) return undefined + if (typeof parsed.eligible !== "boolean") return undefined + if (parsed.delegationPolicy !== "proactive" && parsed.delegationPolicy !== "explicit_request_only") { + return undefined + } + if ( + parsed.reason !== "eligible" && + parsed.reason !== "missing_catalog" && + parsed.reason !== "missing_ultra_effort" && + parsed.reason !== "missing_multi_agent_v2" && + parsed.reason !== "not_supported_in_api" && + parsed.reason !== "not_visible" + ) { + return undefined + } + if (parsed.eligible !== (parsed.reason === "eligible")) return undefined + if (parsed.delegationPolicy === "proactive" && !parsed.eligible) return undefined + return { + selected: true, + logicalEffort: ULTRA_REASONING_EFFORT, + wireEffort: ULTRA_WIRE_REASONING_EFFORT, + eligible: parsed.eligible, + delegationPolicy: parsed.delegationPolicy, + reason: parsed.reason, + ...(typeof parsed.modelSlug === "string" && parsed.modelSlug.trim() + ? { modelSlug: parsed.modelSlug.trim() } + : {}), + ...(typeof parsed.multiAgentVersion === "string" && parsed.multiAgentVersion.trim() + ? { multiAgentVersion: parsed.multiAgentVersion.trim() } + : {}) + } + } catch { + return undefined + } +} + +export function retainUltraState( + current: UltraResolution | undefined, + encoded: string | null | undefined +): UltraResolution | undefined { + return parseUltraState(encoded) ?? current +} diff --git a/lib/model-catalog/provider.ts b/lib/model-catalog/provider.ts index a7c3ebf..05ab5c6 100644 --- a/lib/model-catalog/provider.ts +++ b/lib/model-catalog/provider.ts @@ -10,6 +10,7 @@ import { normalizeVerbosity, type PersonalityOption } from "./shared.js" +import { isUltraEligible } from "../codex-native/ultra.js" const DEFAULT_OPENAI_NPM = "@ai-sdk/openai" const DEFAULT_OPENAI_API_URL = "https://chatgpt.com/backend-api/codex" @@ -149,10 +150,26 @@ function buildVariants(model: CodexModelInfo): Record normalizeReasoningEffort(level.effort)) .filter((value): value is NonNullable => value !== undefined) + .filter((effort) => effort !== "ultra" || isUltraEligible(model)) ) ) - return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + return Object.fromEntries( + efforts.map((effort) => { + const level = (model.supported_reasoning_levels ?? []).find( + (candidate) => normalizeReasoningEffort(candidate.effort)?.toLowerCase() === effort.toLowerCase() + ) + return [ + effort, + { + reasoningEffort: effort, + ...(typeof level?.description === "string" && level.description.trim() + ? { description: level.description.trim() } + : {}) + } + ] + }) + ) } function cloneValue(value: T): T { diff --git a/lib/model-catalog/shared.ts b/lib/model-catalog/shared.ts index fb447c9..c7dbdeb 100644 --- a/lib/model-catalog/shared.ts +++ b/lib/model-catalog/shared.ts @@ -40,6 +40,7 @@ type ModelMessages = { type ModelReasoningLevel = { effort?: string | null + description?: string | null } type ModelServiceTier = { @@ -51,6 +52,7 @@ type CatalogInputModality = "text" | "audio" | "image" | "video" | "pdf" export type CodexModelInfo = { slug: string + description?: string | null display_name?: string | null priority?: number | null context_window?: number | null @@ -60,6 +62,10 @@ export type CodexModelInfo = { base_instructions?: string | null apply_patch_tool_type?: string | null supported_reasoning_levels?: ModelReasoningLevel[] | null + multi_agent_version?: string | null + minimal_client_version?: string | null + visibility?: string | null + supported_in_api?: boolean | null default_reasoning_level?: string | null supports_reasoning_summaries?: boolean | null reasoning_summary_format?: string | null @@ -135,7 +141,7 @@ export type ApplyCodexCatalogInput = { export const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models" export const CODEX_GITHUB_MODELS_URL_PREFIX = "https://raw.githubusercontent.com/openai/codex" -export const DEFAULT_CLIENT_VERSION = "0.116.0" +export const DEFAULT_CLIENT_VERSION = "0.144.0" export const CACHE_TTL_MS = 15 * 60 * 1000 export const FETCH_TIMEOUT_MS = 5000 export const EFFORT_SUFFIX_REGEX = /-(none|minimal|low|medium|high|xhigh|max|ultra)$/i @@ -188,7 +194,12 @@ export function parseReasoningLevels(value: unknown): ModelReasoningLevel[] | nu if (!isRecord(item)) continue const effort = normalizeReasoningEffort(item.effort) if (!effort) continue - out.push({ effort }) + out.push({ + effort, + ...(typeof item.description === "string" && item.description.trim() + ? { description: item.description.trim() } + : {}) + }) } return out.length > 0 ? out : null } @@ -239,6 +250,7 @@ export function parseCatalogResponse(payload: unknown): CodexModelInfo[] { if (!slug) continue deduped.set(slug, { slug, + ...(typeof item.description === "string" ? { description: item.description } : {}), display_name: typeof item.display_name === "string" ? item.display_name : null, priority: typeof item.priority === "number" && Number.isFinite(item.priority) ? item.priority : null, context_window: @@ -282,6 +294,14 @@ export function parseCatalogResponse(payload: unknown): CodexModelInfo[] { base_instructions: typeof item.base_instructions === "string" ? item.base_instructions : null, apply_patch_tool_type: typeof item.apply_patch_tool_type === "string" ? item.apply_patch_tool_type : null, supported_reasoning_levels: parseReasoningLevels(item.supported_reasoning_levels), + ...(typeof item.multi_agent_version === "string" + ? { multi_agent_version: item.multi_agent_version.trim() || null } + : {}), + ...(typeof item.minimal_client_version === "string" + ? { minimal_client_version: item.minimal_client_version.trim() || null } + : {}), + ...(typeof item.visibility === "string" ? { visibility: item.visibility.trim() || null } : {}), + ...(typeof item.supported_in_api === "boolean" ? { supported_in_api: item.supported_in_api } : {}), default_reasoning_level: normalizeReasoningEffort(item.default_reasoning_level) ?? null, supports_reasoning_summaries: typeof item.supports_reasoning_summaries === "boolean" ? item.supports_reasoning_summaries : null, diff --git a/lib/shareable-debug.ts b/lib/shareable-debug.ts index ae96f96..13333c8 100644 --- a/lib/shareable-debug.ts +++ b/lib/shareable-debug.ts @@ -13,6 +13,7 @@ import { import type { Logger } from "./logger.js" import { defaultShareableDebugLogPath } from "./paths.js" import type { OpenAIAuthMode, RotationStrategy } from "./types.js" +import type { UltraResolution } from "./codex-native/ultra.js" const PROCESS_SECRET = randomBytes(32) @@ -125,6 +126,7 @@ export type ShareableDebugLogger = { activeIdentityKey?: string sessionKey?: string | null rotationStrategy?: string + ultra?: Pick } ) => Promise emitFetchAttemptResponse: ( @@ -138,6 +140,7 @@ export type ShareableDebugLogger = { activeIdentityKey?: string sessionKey?: string | null rotationStrategy?: string + ultra?: Pick } ) => Promise emitRetryAfter429: ( @@ -149,6 +152,7 @@ export type ShareableDebugLogger = { activeIdentityKey?: string sessionKey?: string | null rotationStrategy?: string + ultra?: Pick } ) => Promise emitAuthFailure: ( @@ -1309,7 +1313,8 @@ export function createShareableDebugLogger(input: { selectedIdentity: pseudonym("ident", event.selectedIdentityKey), activeIdentity: pseudonym("ident", event.activeIdentityKey), session: pseudonym("sess", event.sessionKey), - promptCacheKey: pseudonym("pck", await extractPromptCacheKey(event.request)) + promptCacheKey: pseudonym("pck", await extractPromptCacheKey(event.request)), + ...(event.ultra ? { ultra: event.ultra } : {}) }) }, async emitFetchAttemptResponse(event) { @@ -1323,7 +1328,8 @@ export function createShareableDebugLogger(input: { status: event.status, selectedIdentity: pseudonym("ident", event.selectedIdentityKey), activeIdentity: pseudonym("ident", event.activeIdentityKey), - session: pseudonym("sess", event.sessionKey) + session: pseudonym("sess", event.sessionKey), + ...(event.ultra ? { ultra: event.ultra } : {}) }) }, async emitRetryAfter429(event) { @@ -1335,7 +1341,8 @@ export function createShareableDebugLogger(input: { attemptReasonCode: event.attemptReasonCode, selectedIdentity: pseudonym("ident", event.selectedIdentityKey), activeIdentity: pseudonym("ident", event.activeIdentityKey), - session: pseudonym("sess", event.sessionKey) + session: pseudonym("sess", event.sessionKey), + ...(event.ultra ? { ultra: event.ultra } : {}) }) }, async emitAuthFailure(event) { diff --git a/test/codex-native-client-version.test.ts b/test/codex-native-client-version.test.ts index 2e265c0..3f2f1e3 100644 --- a/test/codex-native-client-version.test.ts +++ b/test/codex-native-client-version.test.ts @@ -19,10 +19,10 @@ describe("codex client version resolution", () => { expect(__testOnly.resolveCodexClientVersion(cacheFile)).toBe("0.98.0") }) - it("falls back to 0.116.0 when cache file is missing", async () => { + it("falls back to 0.144.0 when cache file is missing", async () => { const dir = await makeTmpDir() const cacheFile = path.join(dir, "missing.json") - expect(__testOnly.resolveCodexClientVersion(cacheFile)).toBe("0.116.0") + expect(__testOnly.resolveCodexClientVersion(cacheFile)).toBe("0.144.0") }) it("refreshes stale cache from GitHub release tag", async () => { diff --git a/test/codex-native-config-variants.test.ts b/test/codex-native-config-variants.test.ts index 810bd52..92ec210 100644 --- a/test/codex-native-config-variants.test.ts +++ b/test/codex-native-config-variants.test.ts @@ -118,7 +118,10 @@ describe("codex-native config variants", () => { { effort: "max" }, { effort: "ultra" }, { effort: "future-custom" } - ] + ], + multi_agent_version: "v2", + visibility: "list", + supported_in_api: true }, { slug: "gpt-5-codex-mini", diff --git a/test/model-catalog.fetch-cache.test.ts b/test/model-catalog.fetch-cache.test.ts index e6403b7..d8f99ea 100644 --- a/test/model-catalog.fetch-cache.test.ts +++ b/test/model-catalog.fetch-cache.test.ts @@ -25,11 +25,11 @@ describe("model catalog fetch and primary cache", () => { const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : new URL(url.url).toString() if (endpoint.includes("/backend-api/codex/models")) { - expect(endpoint).toContain("client_version=0.116.0") + expect(endpoint).toContain("client_version=0.144.0") const headers = init?.headers as Record expect(headers.authorization).toBe("Bearer at") expect(headers["chatgpt-account-id"]).toBe("acc_123") - expect(headers.version).toBe("0.116.0") + expect(headers.version).toBe("0.144.0") return new Response( JSON.stringify({ @@ -39,7 +39,9 @@ describe("model catalog fetch and primary cache", () => { ) } - expect(endpoint).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.116.0/codex-rs/core/models.json") + expect(endpoint).toBe( + "https://raw.githubusercontent.com/openai/codex/rust-v0.144.0/codex-rs/models-manager/models.json" + ) return new Response( JSON.stringify({ models: [ @@ -119,7 +121,7 @@ describe("model catalog fetch and primary cache", () => { version: "0.98.0", tag: "rust-v0.98.0", lastChecked: 100, - url: "https://raw.githubusercontent.com/openai/codex/rust-v0.98.0/codex-rs/core/models.json" + url: "https://raw.githubusercontent.com/openai/codex/rust-v0.98.0/codex-rs/models-manager/models.json" }, null, 2 @@ -129,7 +131,9 @@ describe("model catalog fetch and primary cache", () => { const fetchImpl = vi.fn(async (url: string | URL | Request) => { const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : new URL(url.url).toString() - expect(endpoint).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json") + expect(endpoint).toBe( + "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" + ) return new Response( JSON.stringify({ models: [{ slug: "gpt-5.3-codex" }] @@ -171,7 +175,9 @@ describe("model catalog fetch and primary cache", () => { expect(meta.etag).toBe('W/"models-099"') expect(meta.tag).toBe("rust-v0.99.0") expect(meta.lastChecked).toBe(200) - expect(meta.url).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json") + expect(meta.url).toBe( + "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" + ) expect(meta.version).toBeUndefined() }) @@ -183,7 +189,7 @@ describe("model catalog fetch and primary cache", () => { { tag: "rust-v0.99.0", lastChecked: 100, - url: "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json" + url: "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" }, null, 2 @@ -193,7 +199,9 @@ describe("model catalog fetch and primary cache", () => { const fetchImpl = vi.fn(async (url: string | URL | Request) => { const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : new URL(url.url).toString() - expect(endpoint).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json") + expect(endpoint).toBe( + "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" + ) return new Response( JSON.stringify({ models: [{ slug: "gpt-5.4-codex", context_window: 272000 }] diff --git a/test/model-catalog.provider-models.test.ts b/test/model-catalog.provider-models.test.ts index 0d99d71..a7b97e3 100644 --- a/test/model-catalog.provider-models.test.ts +++ b/test/model-catalog.provider-models.test.ts @@ -58,12 +58,18 @@ describe("model catalog provider model mapping", () => { { slug: "gpt-5.4", display_name: "gpt-5.4", + description: "A test model", priority: 0, context_window: 272000, input_modalities: ["text", "image"] as const, service_tiers: [{ id: "priority", name: "Fast" }, { id: " FLEX ", name: 42 }, { id: "" }, null], additional_speed_tiers: ["fast", " FAST ", "", null], - supports_parallel_tool_calls: true + supports_parallel_tool_calls: true, + multi_agent_version: "v2", + minimal_client_version: "0.144.0", + visibility: "list", + supported_in_api: true, + supported_reasoning_levels: [{ effort: "ultra", description: "Maximum with delegation" }] } ] }) @@ -71,6 +77,7 @@ describe("model catalog provider model mapping", () => { expect(parsed).toEqual([ { slug: "gpt-5.4", + description: "A test model", display_name: "gpt-5.4", priority: 0, context_window: 272000, @@ -84,7 +91,11 @@ describe("model catalog provider model mapping", () => { model_messages: null, base_instructions: null, apply_patch_tool_type: null, - supported_reasoning_levels: null, + supported_reasoning_levels: [{ effort: "ultra", description: "Maximum with delegation" }], + multi_agent_version: "v2", + minimal_client_version: "0.144.0", + visibility: "list", + supported_in_api: true, default_reasoning_level: null, default_reasoning_summary: null, supports_reasoning_summaries: null, @@ -119,6 +130,10 @@ describe("model catalog provider model mapping", () => { { effort: "ultra" }, { effort: "future-custom" } ], + multi_agent_version: "v2", + minimal_client_version: "0.144.0", + visibility: "list", + supported_in_api: true, supports_reasoning_summaries: true, reasoning_summary_format: "experimental", supports_parallel_tool_calls: false, diff --git a/test/ultra.test.ts b/test/ultra.test.ts new file mode 100644 index 0000000..238307c --- /dev/null +++ b/test/ultra.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest" + +import type { CodexModelInfo } from "../lib/model-catalog.js" +import { handleChatParamsHook } from "../lib/codex-native/chat-hooks.js" +import { transformOutboundRequestPayload } from "../lib/codex-native/request-transform-payload.js" +import { + ULTRA_EXPLICIT_ONLY_INSTRUCTIONS, + ULTRA_PROACTIVE_INSTRUCTIONS, + isUltraEligible, + parseUltraState, + retainUltraState, + resolveUltraSelection +} from "../lib/codex-native/ultra.js" + +function eligibleModel(overrides: Partial = {}): CodexModelInfo { + return { + slug: "gpt-5.6-sol", + context_window: 372000, + multi_agent_version: "v2", + supported_in_api: true, + visibility: "list", + default_reasoning_level: "ultra", + supported_reasoning_levels: [{ effort: "max" }, { effort: "ultra" }], + ...overrides + } +} + +function chatOutput(): { temperature: number; topP: number; topK: number; options: Record } { + return { temperature: 0, topP: 1, topK: 0, options: {} } +} + +describe("GPT-5.6 Ultra contract", () => { + it("requires Ultra, V2, visible status, and explicit API support", () => { + expect(isUltraEligible(eligibleModel())).toBe(true) + expect(isUltraEligible(eligibleModel({ multi_agent_version: "v1" }))).toBe(false) + expect(isUltraEligible(eligibleModel({ supported_in_api: false }))).toBe(false) + expect(isUltraEligible(eligibleModel({ visibility: "hidden" }))).toBe(false) + expect(isUltraEligible(eligibleModel({ supported_in_api: undefined }))).toBe(false) + expect(isUltraEligible(eligibleModel({ visibility: undefined }))).toBe(false) + }) + + it("parses only valid internal logical-state metadata", () => { + const state = resolveUltraSelection({ reasoningEffort: "ultra", model: eligibleModel() }) + expect(parseUltraState(JSON.stringify(state))).toEqual(state) + expect(parseUltraState("not-json")).toBeUndefined() + expect(parseUltraState(JSON.stringify({ selected: false, logicalEffort: "max" }))).toBeUndefined() + expect(parseUltraState(JSON.stringify({ ...state, secret: "must-not-survive" }))).toEqual(state) + expect(parseUltraState(JSON.stringify({ ...state, wireEffort: "ultra" }))).toBeUndefined() + expect(retainUltraState(state, undefined)).toEqual(state) + }) + + it("keeps logical Ultra and adds proactive instructions for codex root turns", async () => { + const output = chatOutput() + const result = await handleChatParamsHook({ + hookInput: { + model: { + id: "gpt-5.6-sol", + providerID: "openai", + options: { + codexCatalogModel: eligibleModel(), + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + }, + agent: "build", + message: {} + }, + output, + lastCatalogModels: [eligibleModel()], + spoofMode: "codex", + collaborationProfileEnabled: false, + orchestratorSubagentsEnabled: false + }) + + expect(output.options.reasoningEffort).toBe("ultra") + expect(output.options.instructions).toContain(ULTRA_PROACTIVE_INSTRUCTIONS) + expect(result.ultra).toMatchObject({ + logicalEffort: "ultra", + wireEffort: "max", + delegationPolicy: "proactive", + eligible: true + }) + }) + + it("preserves native identity and uses explicit-only instructions for child codex turns", async () => { + const nativeOutput = chatOutput() + await handleChatParamsHook({ + hookInput: { + model: { + id: "gpt-5.6-sol", + providerID: "openai", + options: { + codexCatalogModel: eligibleModel(), + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + }, + agent: "build", + message: {} + }, + output: nativeOutput, + lastCatalogModels: [eligibleModel()], + spoofMode: "native", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true + }) + expect(nativeOutput.options.instructions).toBeUndefined() + + const childOutput = chatOutput() + const childResult = await handleChatParamsHook({ + hookInput: { + model: { + id: "gpt-5.6-sol", + providerID: "openai", + options: { + codexCatalogModel: eligibleModel(), + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + }, + agent: "codex-review", + message: {} + }, + output: childOutput, + lastCatalogModels: [eligibleModel()], + spoofMode: "codex", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true + }) + expect(childOutput.options.instructions).toContain(ULTRA_EXPLICIT_ONLY_INSTRUCTIONS) + expect(childOutput.options.instructions).not.toContain(ULTRA_PROACTIVE_INSTRUCTIONS) + expect(childResult.ultra?.delegationPolicy).toBe("explicit_request_only") + }) + + it("normalizes logical Ultra to wire Max at the final request boundary", async () => { + const request = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "ultra" } }) + }) + + const transformed = await transformOutboundRequestPayload({ + request, + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + catalogModels: [eligibleModel()] + }) + + expect(JSON.parse(await transformed.request.text()).reasoning.effort).toBe("max") + expect(transformed.ultra).toMatchObject({ logicalEffort: "ultra", wireEffort: "max" }) + }) + + it("keeps explicit Max separate from Ultra and resolves custom targets", async () => { + const maxRequest = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "max" } }) + }) + const max = await transformOutboundRequestPayload({ + request: maxRequest, + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + catalogModels: [eligibleModel()] + }) + expect(max.ultra).toBeUndefined() + + const customRequest = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ model: "my-sol", reasoning: { effort: "ultra" } }) + }) + const custom = await transformOutboundRequestPayload({ + request: customRequest, + selectedModelSlug: "my-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + catalogModels: [eligibleModel()], + customModels: { "my-sol": { targetModel: "gpt-5.6-sol" } } + }) + expect(JSON.parse(await custom.request.text()).reasoning.effort).toBe("max") + expect(custom.ultra?.eligible).toBe(true) + }) + + it("preserves logical Ultra metadata when a retry body already contains wire Max", async () => { + const request = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "max" } }) + }) + const transformed = await transformOutboundRequestPayload({ + request, + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + catalogModels: [eligibleModel()], + ultraState: resolveUltraSelection({ reasoningEffort: "ultra", model: eligibleModel() }) + }) + + expect(transformed.ultra).toMatchObject({ logicalEffort: "ultra", wireEffort: "max" }) + expect(JSON.parse(await transformed.request.text()).reasoning.effort).toBe("max") + }) + + it("retains explicit-only child policy when collaboration headers are unavailable", async () => { + const request = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "max" } }) + }) + const transformed = await transformOutboundRequestPayload({ + request, + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + catalogModels: [eligibleModel()], + ultraState: resolveUltraSelection({ reasoningEffort: "ultra", model: eligibleModel(), childTask: true }) + }) + + expect(transformed.ultra?.delegationPolicy).toBe("explicit_request_only") + }) + + it("degrades an Ultra selection without authoritative V2 metadata to wire Max only", async () => { + const request = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "ultra" } }) + }) + const transformed = await transformOutboundRequestPayload({ + request, + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + catalogModels: [eligibleModel({ multi_agent_version: undefined })] + }) + expect(JSON.parse(await transformed.request.text()).reasoning.effort).toBe("max") + expect( + resolveUltraSelection({ reasoningEffort: "ultra", model: eligibleModel({ multi_agent_version: undefined }) }) + ).toMatchObject({ + eligible: false, + delegationPolicy: "explicit_request_only", + reason: "missing_multi_agent_v2" + }) + }) +}) diff --git a/test/upstream-watch-config.test.ts b/test/upstream-watch-config.test.ts index 12f56c6..b52e0c2 100644 --- a/test/upstream-watch-config.test.ts +++ b/test/upstream-watch-config.test.ts @@ -25,7 +25,7 @@ describe("upstream watch coverage", () => { expect(tracked.has("packages/opencode/src/provider/models.ts")).toBe(true) expect(tracked.has("packages/opencode/src/provider/error.ts")).toBe(true) expect(tracked.has("packages/opencode/src/session/message-v2.ts")).toBe(true) - expect(tracked.has("codex-rs/core/models.json")).toBe(true) + expect(tracked.has("codex-rs/models-manager/models.json")).toBe(true) expect(tracked.has("codex-rs/core/src/auth.rs")).toBe(true) expect(tracked.has("codex-rs/core/src/client.rs")).toBe(true) expect(tracked.has("codex-rs/core/src/codex.rs")).toBe(true) From aa3284b8876d3a3de66dd49c0665691b0adabb98 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 10 Jul 2026 17:59:52 -0400 Subject: [PATCH 2/6] fix: fail closed on fallback Ultra metadata --- docs/development/ULTRA.md | 2 +- lib/codex-native/ultra.ts | 62 +++++++++++++++----------- lib/model-catalog/cache-helpers.ts | 2 +- lib/model-catalog/provider.ts | 17 +++---- lib/model-catalog/shared.ts | 1 + test/model-catalog.fetch-cache.test.ts | 41 ++++++++++++----- test/ultra.test.ts | 1 + 7 files changed, 78 insertions(+), 48 deletions(-) diff --git a/docs/development/ULTRA.md b/docs/development/ULTRA.md index 6f4902d..70618a2 100644 --- a/docs/development/ULTRA.md +++ b/docs/development/ULTRA.md @@ -11,7 +11,7 @@ This plugin treats Ultra as a logical model variant, not as a new inference effo | Missing or stale metadata | Ultra is disabled when catalog metadata cannot prove eligibility. A manually configured literal `ultra` is safe-degraded to wire `max` without proactive instructions. | | Failure | Missing task tools, disabled collaboration, spawn failure, cancellation, or partial completion do not fail the root turn. The agent continues locally and must not claim delegation that did not happen. | -The live account-scoped catalog is authoritative. GitHub fallback data is parsed through the same schema and is used only when the live source is unavailable. The plugin does not recreate account entitlement or minimum-client enforcement from catalog metadata. +The live account-scoped catalog is authoritative. GitHub fallback data is parsed through the same schema and remains usable for ordinary model defaults when the live source is unavailable, but it cannot prove Ultra eligibility. The plugin does not recreate account entitlement or minimum-client enforcement from catalog metadata. ## State lifecycle diff --git a/lib/codex-native/ultra.ts b/lib/codex-native/ultra.ts index b4e1bc1..f55272d 100644 --- a/lib/codex-native/ultra.ts +++ b/lib/codex-native/ultra.ts @@ -1,8 +1,8 @@ import type { CodexModelInfo } from "../model-catalog.js" -export const ULTRA_REASONING_EFFORT = "ultra" -export const ULTRA_WIRE_REASONING_EFFORT = "max" -export const ULTRA_MULTI_AGENT_VERSION = "v2" +const ULTRA_REASONING_EFFORT = "ultra" +const ULTRA_WIRE_REASONING_EFFORT = "max" +const ULTRA_MULTI_AGENT_VERSION = "v2" export type UltraDelegationPolicy = "proactive" | "explicit_request_only" @@ -14,6 +14,15 @@ export type UltraEligibilityReason = | "not_supported_in_api" | "not_visible" +const ULTRA_ELIGIBILITY_REASONS = new Set([ + "eligible", + "missing_catalog", + "missing_ultra_effort", + "missing_multi_agent_v2", + "not_supported_in_api", + "not_visible" +]) + export type UltraResolution = { selected: boolean logicalEffort: string | undefined @@ -48,8 +57,9 @@ function modelIsVisible(model: CodexModelInfo): boolean { return visibility === "list" } -export function getUltraEligibilityReason(model: CodexModelInfo | undefined): UltraEligibilityReason { +function getUltraEligibilityReason(model: CodexModelInfo | undefined): UltraEligibilityReason { if (!model) return "missing_catalog" + if (model.catalog_source === "github_fallback") return "missing_catalog" if (!supportsEffort(model, ULTRA_REASONING_EFFORT)) return "missing_ultra_effort" if (normalize(model.multi_agent_version) !== ULTRA_MULTI_AGENT_VERSION) return "missing_multi_agent_v2" if (model.supported_in_api !== true) return "not_supported_in_api" @@ -90,6 +100,19 @@ export function normalizeUltraWireEffort(value: unknown): { value: string | unde return { value: ULTRA_WIRE_REASONING_EFFORT, changed: true } } +function isDelegationPolicy(value: unknown): value is UltraDelegationPolicy { + return value === "proactive" || value === "explicit_request_only" +} + +function isEligibilityReason(value: unknown): value is UltraEligibilityReason { + return typeof value === "string" && ULTRA_ELIGIBILITY_REASONS.has(value as UltraEligibilityReason) +} + +function optionalStateString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + return value.trim() || undefined +} + export function parseUltraState(value: string | null | undefined): UltraResolution | undefined { if (!value?.trim()) return undefined try { @@ -97,35 +120,24 @@ export function parseUltraState(value: string | null | undefined): UltraResoluti if (parsed.selected !== true || parsed.logicalEffort !== ULTRA_REASONING_EFFORT) return undefined if (parsed.wireEffort !== ULTRA_WIRE_REASONING_EFFORT) return undefined if (typeof parsed.eligible !== "boolean") return undefined - if (parsed.delegationPolicy !== "proactive" && parsed.delegationPolicy !== "explicit_request_only") { - return undefined - } - if ( - parsed.reason !== "eligible" && - parsed.reason !== "missing_catalog" && - parsed.reason !== "missing_ultra_effort" && - parsed.reason !== "missing_multi_agent_v2" && - parsed.reason !== "not_supported_in_api" && - parsed.reason !== "not_visible" - ) { - return undefined - } + if (!isDelegationPolicy(parsed.delegationPolicy)) return undefined + if (!isEligibilityReason(parsed.reason)) return undefined if (parsed.eligible !== (parsed.reason === "eligible")) return undefined if (parsed.delegationPolicy === "proactive" && !parsed.eligible) return undefined - return { + + const result: UltraResolution = { selected: true, logicalEffort: ULTRA_REASONING_EFFORT, wireEffort: ULTRA_WIRE_REASONING_EFFORT, eligible: parsed.eligible, delegationPolicy: parsed.delegationPolicy, - reason: parsed.reason, - ...(typeof parsed.modelSlug === "string" && parsed.modelSlug.trim() - ? { modelSlug: parsed.modelSlug.trim() } - : {}), - ...(typeof parsed.multiAgentVersion === "string" && parsed.multiAgentVersion.trim() - ? { multiAgentVersion: parsed.multiAgentVersion.trim() } - : {}) + reason: parsed.reason } + const modelSlug = optionalStateString(parsed.modelSlug) + const multiAgentVersion = optionalStateString(parsed.multiAgentVersion) + if (modelSlug) result.modelSlug = modelSlug + if (multiAgentVersion) result.multiAgentVersion = multiAgentVersion + return result } catch { return undefined } diff --git a/lib/model-catalog/cache-helpers.ts b/lib/model-catalog/cache-helpers.ts index a3a76a8..efaf98a 100644 --- a/lib/model-catalog/cache-helpers.ts +++ b/lib/model-catalog/cache-helpers.ts @@ -160,7 +160,7 @@ export async function readCatalogFromGitHubCache(cacheDir: string): Promise ({ ...model, catalog_source: "github_fallback" })) } } diff --git a/lib/model-catalog/provider.ts b/lib/model-catalog/provider.ts index 05ab5c6..c3dd39b 100644 --- a/lib/model-catalog/provider.ts +++ b/lib/model-catalog/provider.ts @@ -144,15 +144,18 @@ function buildInputCapabilities(model: CodexModelInfo): CapabilityMap { } } -function buildVariants(model: CodexModelInfo): Record> { - const efforts = Array.from( +function getSupportedReasoningEfforts(model: CodexModelInfo): string[] { + return Array.from( new Set( (model.supported_reasoning_levels ?? []) .map((level) => normalizeReasoningEffort(level.effort)) .filter((value): value is NonNullable => value !== undefined) - .filter((effort) => effort !== "ultra" || isUltraEligible(model)) ) ) +} + +function buildVariants(model: CodexModelInfo): Record> { + const efforts = getSupportedReasoningEfforts(model).filter((effort) => effort !== "ultra" || isUltraEligible(model)) return Object.fromEntries( efforts.map((effort) => { @@ -512,13 +515,7 @@ export function getRuntimeDefaultsForModel(model: CodexModelInfo | undefined): C if (next) out.defaultReasoningSummary = next } - const supportedReasoningEfforts = Array.from( - new Set( - (model.supported_reasoning_levels ?? []) - .map((level) => normalizeReasoningEffort(level.effort)) - .filter((value): value is NonNullable => value !== undefined) - ) - ) + const supportedReasoningEfforts = getSupportedReasoningEfforts(model) if (supportedReasoningEfforts.length > 0) { out.supportedReasoningEfforts = supportedReasoningEfforts } diff --git a/lib/model-catalog/shared.ts b/lib/model-catalog/shared.ts index c7dbdeb..a95ed3b 100644 --- a/lib/model-catalog/shared.ts +++ b/lib/model-catalog/shared.ts @@ -52,6 +52,7 @@ type CatalogInputModality = "text" | "audio" | "image" | "video" | "pdf" export type CodexModelInfo = { slug: string + catalog_source?: "github_fallback" description?: string | null display_name?: string | null priority?: number | null diff --git a/test/model-catalog.fetch-cache.test.ts b/test/model-catalog.fetch-cache.test.ts index d8f99ea..4679b9e 100644 --- a/test/model-catalog.fetch-cache.test.ts +++ b/test/model-catalog.fetch-cache.test.ts @@ -5,6 +5,8 @@ import path from "node:path" import { describe, expect, it, vi } from "vitest" import { getCodexModelCatalog, githubModelsUrl, type CodexModelCatalogEvent } from "../lib/model-catalog" +import { codexModelsSharedCachePath } from "../lib/codex-cache-layout" +import { readCatalogFromGitHubCache } from "../lib/model-catalog/cache-helpers" async function makeCacheDir(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-model-catalog-")) @@ -20,6 +22,29 @@ describe("model catalog fetch and primary cache", () => { ) }) + it("marks GitHub catalog models as non-authoritative fallback data", async () => { + const cacheDir = await makeCacheDir() + try { + await fs.mkdir(path.dirname(codexModelsSharedCachePath(cacheDir)), { recursive: true }) + await fs.writeFile( + codexModelsSharedCachePath(cacheDir), + JSON.stringify({ + fetchedAt: 100, + source: "github", + models: [{ slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "ultra" }] }] + }) + ) + + const cached = await readCatalogFromGitHubCache(cacheDir) + expect(cached?.models[0]).toMatchObject({ + slug: "gpt-5.6-sol", + catalog_source: "github_fallback" + }) + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }) + } + }) + it("fetches /codex/models with auth headers", async () => { const cacheDir = await makeCacheDir() const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { @@ -121,7 +146,7 @@ describe("model catalog fetch and primary cache", () => { version: "0.98.0", tag: "rust-v0.98.0", lastChecked: 100, - url: "https://raw.githubusercontent.com/openai/codex/rust-v0.98.0/codex-rs/models-manager/models.json" + url: "https://raw.githubusercontent.com/openai/codex/rust-v0.98.0/codex-rs/core/models.json" }, null, 2 @@ -131,9 +156,7 @@ describe("model catalog fetch and primary cache", () => { const fetchImpl = vi.fn(async (url: string | URL | Request) => { const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : new URL(url.url).toString() - expect(endpoint).toBe( - "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" - ) + expect(endpoint).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json") return new Response( JSON.stringify({ models: [{ slug: "gpt-5.3-codex" }] @@ -175,9 +198,7 @@ describe("model catalog fetch and primary cache", () => { expect(meta.etag).toBe('W/"models-099"') expect(meta.tag).toBe("rust-v0.99.0") expect(meta.lastChecked).toBe(200) - expect(meta.url).toBe( - "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" - ) + expect(meta.url).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json") expect(meta.version).toBeUndefined() }) @@ -189,7 +210,7 @@ describe("model catalog fetch and primary cache", () => { { tag: "rust-v0.99.0", lastChecked: 100, - url: "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" + url: "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json" }, null, 2 @@ -199,9 +220,7 @@ describe("model catalog fetch and primary cache", () => { const fetchImpl = vi.fn(async (url: string | URL | Request) => { const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : new URL(url.url).toString() - expect(endpoint).toBe( - "https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/models-manager/models.json" - ) + expect(endpoint).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.99.0/codex-rs/core/models.json") return new Response( JSON.stringify({ models: [{ slug: "gpt-5.4-codex", context_window: 272000 }] diff --git a/test/ultra.test.ts b/test/ultra.test.ts index 238307c..5a096ae 100644 --- a/test/ultra.test.ts +++ b/test/ultra.test.ts @@ -37,6 +37,7 @@ describe("GPT-5.6 Ultra contract", () => { expect(isUltraEligible(eligibleModel({ visibility: "hidden" }))).toBe(false) expect(isUltraEligible(eligibleModel({ supported_in_api: undefined }))).toBe(false) expect(isUltraEligible(eligibleModel({ visibility: undefined }))).toBe(false) + expect(isUltraEligible(eligibleModel({ catalog_source: "github_fallback" }))).toBe(false) }) it("parses only valid internal logical-state metadata", () => { From b96d71c1587e5eb58bbee99470e616ead95be9cf Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 10 Jul 2026 18:28:23 -0400 Subject: [PATCH 3/6] chore: sync OpenCode v1.17.18 plugin contract --- docs/development/OPENCODE_V1_17_18_SYNC.md | 96 +++++ docs/development/README.md | 1 + docs/development/UPSTREAM_SYNC.md | 25 +- docs/development/upstream-watch.json | 36 +- index.ts | 65 ++-- lib/codex-native.ts | 3 +- lib/codex-native/chat-hooks.ts | 3 +- lib/fetch-orchestrator-helpers.ts | 3 +- lib/plugin-lifecycle.ts | 32 ++ lib/request-snapshots.ts | 3 + package-lock.json | 342 +++++++++++++++++- package.json | 6 +- scripts/check-upstream-watch.js | 14 + scripts/patch-opencode-plugin-dts.js | 2 + test/codex-native-snapshots.test.ts | 10 +- test/codex-native-spoof-mode.test.ts | 11 +- ...chestrator.snapshots-and-redirects.test.ts | 4 +- test/mode-smoke.test.ts | 2 +- test/models-gpt-5.3-codex.test.ts | 2 +- test/plugin-lifecycle.test.ts | 74 ++++ test/request-snapshots.test.ts | 4 +- test/upstream-watch-config.test.ts | 6 +- 22 files changed, 668 insertions(+), 76 deletions(-) create mode 100644 docs/development/OPENCODE_V1_17_18_SYNC.md create mode 100644 lib/plugin-lifecycle.ts create mode 100644 test/plugin-lifecycle.test.ts diff --git a/docs/development/OPENCODE_V1_17_18_SYNC.md b/docs/development/OPENCODE_V1_17_18_SYNC.md new file mode 100644 index 0000000..199bcbf --- /dev/null +++ b/docs/development/OPENCODE_V1_17_18_SYNC.md @@ -0,0 +1,96 @@ +# OpenCode v1.3.0 → v1.17.18 sync findings + +Research date: 2026-07-10. Compared upstream commits `eb3bfffad453f1c8c3f0f92bba0d8e34c83fa244` (`v1.3.0`) and `b1fc8113948b518835c2a39ece49553cffe9b30c` (`v1.17.18`). Scope is the built-in ChatGPT/Codex OAuth plugin and adjacent OpenAI provider, auth, catalog, transform, and retry behavior. Sources are first-party tagged files, commits, and compares only. + +## Executive summary + +Most upstream churn is architectural, but five behavior changes matter here: + +1. **Request identity changed:** OpenCode now sends `session-id`, not `session_id`, with `originator: opencode` and an `opencode/ (...)` user agent. This repo previously emitted `session_id`; the parity defect is fixed in this sync while legacy input and redaction compatibility remain. [Upstream fix](https://github.com/anomalyco/opencode/commit/a78605f8ea1a6e56cab516c20d9b3311cd0ce0b1) and [v1.17.18 implementation](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/codex.ts#L541-L554). +2. **OAuth model selection became catalog-driven:** the plugin no longer invents a hard-coded GPT-5.3 model. It filters the provider catalog through the `provider.models` hook, zeroes subscription costs, corrects GPT-5.5 limits, explicitly hides `gpt-5.5-pro`, and admits future `gpt-X.Y` models when the version is greater than 5.4. [Removal of synthesized model](https://github.com/anomalyco/opencode/commit/2929774acb2eb694800bccfc6a9f84ec691eb999), [models-hook migration](https://github.com/anomalyco/opencode/commit/b80f52f8ad3173acee143e1355a2ab4585443db1), and [current filter](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/codex.ts#L279-L309). +3. **Concurrent refresh is deduplicated:** one in-flight refresh promise is shared by requests using the native OpenCode credential. This repo has richer per-account acquisition/rotation, so it must preserve deduplication per strict account identity rather than adopt upstream's single global promise literally. [Fix](https://github.com/anomalyco/opencode/commit/c64ac905e19cd881e4d3c8af6449f228941a2674) and [tagged code](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/codex.ts#L327-L396). +4. **OpenAI Responses WebSockets were added as an experimental transport.** This caused the file move and adds pooling, lifecycle cleanup, HTTP fallback for title requests, custom base-URL support, and stream retry/idle fixes. It is optional for this plugin unless it intentionally exposes the transport; HTTP remains supported. [Transport commit and rename](https://github.com/anomalyco/opencode/commit/62da1e76826276b493ce7f8a9581d482cd7c16ee), [custom URL fix](https://github.com/anomalyco/opencode/commit/ec26d7845005d5db3166b9d181f802b04e99d864), [stream retry fix](https://github.com/anomalyco/opencode/commit/14e0b9b17f886c9157c92e1b98caca5a40d21797), and [idle-state fix](https://github.com/anomalyco/opencode/commit/7f8412ec3e8b964ae3794e5c38e67dbe100c4cc7). +5. **OpenCode does not implement a special GPT-5.6 Ultra wire effort.** At v1.17.18 it accepts future post-5.4 model IDs through the dynamic filter, while reasoning variants come from provider/catalog capabilities. Therefore this repo's logical `ultra` → wire `max` policy remains a Codex-runtime extension, not an OpenCode-native parity item; keep it catalog-gated and keep native mode free of the Codex collaboration overlay. + +The current [OpenCode plugin documentation](https://opencode.ai/docs/plugins/) confirms sequential hook execution and lifecycle event delivery, including `session.deleted`. The v1.17.18 package contract additionally exposes `dispose`, plugin option tuples, and a v1 default module object. This plugin now composes `dispose` to stop its proactive-refresh scheduler. It intentionally keeps the function export for older-loader compatibility and keeps runtime settings in `codex-config.jsonc`; plugin option tuples are therefore not used as a second configuration surface. + +The full tagged comparison is [v1.3.0...v1.17.18](https://github.com/anomalyco/opencode/compare/v1.3.0...v1.17.18). + +## File/path migration map + +| v1.3.0 | v1.17.18 | Meaning | Action here | +|---|---|---|---| +| `packages/opencode/src/plugin/codex.ts` | `packages/opencode/src/plugin/openai/codex.ts` plus `openai/ws.ts` and `openai/ws-pool.ts` | The May 27 Responses-WebSocket change grouped the Codex OAuth adapter with OpenAI-specific transports. The tagged diff still recognizes a 57% rename; this is organization plus substantial behavior, not a replacement of the built-in plugin. | Update upstream-watch path; optionally watch both WebSocket files. Do not mirror the folder layout locally merely for parity. | +| `packages/opencode/src/provider/models.ts` | `packages/core/src/models-dev.ts` | Moved into `@opencode-ai/core`; core now owns the models.dev schema, bundled snapshot, cache, refresh, and flags. [Move commit](https://github.com/anomalyco/opencode/commit/16c457e71233b2eca6a73aa292ec1ed225d87af7). | Replace the dead watch path with `packages/core/src/models-dev.ts` (and `packages/core/src/catalog.ts` if tracking the core catalog contract). Keep this repo's separate account-scoped Codex catalog. | +| hard-coded model creation inside Codex loader | `provider.models` hook over the resolved provider catalog | Catalog entries are no longer synthesized by the OAuth adapter. | Preserve this repo's live Codex catalog authority; remove/avoid cross-model metadata synthesis. | +| inline Bun OAuth server/pages | Node `http.createServer` plus shared `OauthCallbackPage` | Runtime portability and shared presentation; callback URI, PKCE/state validation, and token semantics are materially unchanged. [Node migration](https://github.com/anomalyco/opencode/commit/2e4c43c1cf6a14c6b2d1d502b70337fae35bc1ce) and [shared page](https://github.com/anomalyco/opencode/commit/e8fea9e63a437fb839fa925a6b63ace31b243471). | No behavior port required; local Node controller already covers this boundary. | +| provider/session namespace and Zod-era schemas | Effect/core-owned schemas and split session LLM modules | Broad internal architecture migration. | No direct port; consume only public plugin/SDK contracts. | + +## Behavior changes by risk + +### High / breaking for parity + +- **Header spelling:** `session-id` replaced `session_id`. Local `lib/codex-native/chat-hooks.ts` and affinity/redaction helpers are underscore-based. Change the outbound native header and make internal readers accept both during migration; tests should assert that the final backend request contains only `session-id` in native mode. Upstream's earlier v1.3.0 code used neither hook spelling consistently enough to override the explicit fix commit. +- **Model allow/hide semantics:** v1.17.18 allows `gpt-5.5`, Spark, 5.4, 5.4-mini, and future versions above 5.4, while hiding 5.5 Pro and removing sunset GPT-5.2/5.3 Codex entries. [Sunset removal](https://github.com/anomalyco/opencode/commit/4668db8fa2eb043ca3cdc895877e7c0657135beb) and [5.5 Pro exclusion](https://github.com/anomalyco/opencode/commit/c5a4a8288cbe115f673f3f9933fe217402c85406). Local account-scoped live-catalog filtering is safer than copying this heuristic, but native mode must not re-expose a model the authoritative catalog marks hidden/unsupported, and fallback data must not fabricate eligibility. +- **Refresh races:** upstream now shares concurrent refresh work. Local rotation can issue simultaneous requests against one account, so refresh dedupe must be keyed by strict identity (`accountId|email|plan`) and must not merge refreshes across accounts or native/codex auth domains. +- **Dependency/API drift:** the development dependency baseline is now `@opencode-ai/plugin` and `@opencode-ai/sdk` `^1.17.18`. The published plugin declaration's missing `HeadersInit` qualification is handled by the existing narrow declaration patch, and config helpers consume the plugin package's expanded tuple-aware `Config` type. + +### Medium + +- **Retry/error classification expanded:** session retry now treats unmarked 5xx responses as retryable, recognizes additional OpenAI retry cases, and retries `server_is_overloaded`/`server_error`. [5xx fix](https://github.com/anomalyco/opencode/commit/4ca809ef4e71ee6d62990c815c82c7ee57395a8b), [OpenAI case](https://github.com/anomalyco/opencode/commit/334ab4707c809172e77619ae7d6b22c5577c7238), and [overload fix](https://github.com/anomalyco/opencode/commit/25ecf0af6b8a022d284f9a5a9e9155ced6a37041). Local fetch orchestration specializes in bounded 429 account switching; host-level 5xx/stream retry should remain the default owner unless this plugin consumes and hides the response. Add contract tests, not a second unbounded retry loop. +- **Request transforms evolved:** OpenAI-family requests continue to default `store: false`, use session-derived `promptCacheKey`, gate reasoning summaries to compatible providers, and clear max output tokens in the Codex plugin. [Fast/service-tier support](https://github.com/anomalyco/opencode/commit/b0600664abacabc3b6d41de88859248bc2a2594), [reasoning-summary gate](https://github.com/anomalyco/opencode/commit/cc487dd032ebed11bac5694210adcbd0b3db2399), and [Codex max-token ownership](https://github.com/anomalyco/opencode/commit/48c1b6b3387647edfde931c3a50a325c37245b06). Local transforms already implement these concepts; verify exact final payloads after the SDK bump. +- **WebSocket transport:** adopting it would change lifecycle, retry, endpoint, header stripping, and title-generation behavior. Treat it as a separate feature with HTTP fallback and transport-specific tests, not incidental sync work. + +### Low / architectural + +- Core/Effect/schema/module-barrel moves, branded IDs, shared OAuth HTML, logger replacement, and Node server conversion do not alter this plugin's external native identity contract by themselves. +- Zero subscription pricing is UI/accounting behavior in OpenCode's resolved provider catalog. It does not change Codex billing or this plugin's account rotation. + +## Parity gap assessment against this repo + +| Area | Assessment | +|---|---| +| OAuth authorize/device flow | **Aligned:** issuer/client, loopback callback, PKCE/state, device endpoint, polling safety margin, account-ID extraction, and native `opencode/` device UA are represented locally. Local multi-account persistence intentionally exceeds upstream. | +| Request identity | **Aligned:** final hooks emit `session-id`; legacy `session_id` remains an inbound/redaction compatibility alias. Originator and native UA remain aligned. | +| Endpoint routing | **Aligned for HTTP:** both Responses and Chat Completions are rewritten to the Codex Responses backend. | +| Model catalog | **Mostly aligned and intentionally richer:** local live account-scoped Codex metadata plus tagged GitHub fallback is stronger than upstream's models.dev-based hook. Confirm hidden/API-support filtering and never clone metadata across slugs. | +| Model visibility | **Aligned by stronger authority:** local behavior follows account-scoped live catalog visibility/support fields and refuses to grant Ultra eligibility from GitHub fallback metadata. It intentionally does not copy OpenCode's version heuristic. | +| Refresh | **Aligned by stronger isolation:** catalog fetches are single-flight and account refresh/persistence remains lock-guarded by strict identity. Upstream's single-record global promise is not copied across rotating accounts. | +| Retry/error | **Layering gap, not necessarily code gap:** local bounded 429 rotation is intentional; verify host 5xx and OpenAI `server_error`/`server_is_overloaded` semantics survive unchanged. | +| GPT-5.6 Ultra | **No OpenCode parity gap:** upstream has no literal Ultra contract. Local logical Ultra normalization is an extension and should remain isolated from native request identity and authorized only by live catalog metadata. | +| WebSockets | **Optional gap:** v1.17.18 has experimental Responses WebSockets; local HTTP-only behavior remains valid unless feature parity is explicitly desired. | +| Plugin lifecycle | **Aligned:** `dispose` stops the instance's proactive-refresh scheduler and composes any Codex-layer cleanup. | +| Upstream watcher | **Aligned:** paths and hashes target v1.17.18, including the moved Codex plugin, models.dev core, and optional WebSocket transport files. Source-filtered checks allow OpenCode to advance independently of Codex path drift. | + +## Implementation disposition + +### Completed now + +1. Changed outbound identity from `session_id` to `session-id`; affinity, redirect stripping, snapshots/redaction, and tests accept legacy input where needed while generated hooks emit only the canonical header. +2. Updated the upstream watch and sync guide to v1.17.18 paths/hashes, including `plugin/openai/codex.ts`, `packages/core/src/models-dev.ts`, `ws.ts`, and `ws-pool.ts`. +3. Preserved the account-scoped live Codex catalog as the stronger authority for visibility, defaults, and Ultra eligibility; GitHub fallback metadata remains fail-closed for Ultra. +4. Upgraded `@opencode-ai/plugin` and `@opencode-ai/sdk` to `^1.17.18`, adapted the narrow declaration shim and config type boundary, and passed full type/test/build verification. +5. Composed the new plugin `dispose` hook to stop proactive-refresh timers without allowing disposal of an older instance to clear a newer instance's scheduler. + +### Optional follow-up + +- Prototype experimental Responses WebSockets behind an explicit opt-in. Match upstream pooling/disposal, custom base URL, title HTTP fallback, internal-header stripping, stream retry, and idle handling before enabling it by default. +- Track upstream's models.dev/core catalog only for OpenCode host compatibility; keep the live account-scoped Codex catalog authoritative for Codex defaults and Ultra eligibility. +- Add a documented ownership matrix: host retries transport/5xx errors; this plugin rotates accounts only for bounded 429/auth cases; neither layer silently multiplies attempts. + +### No action + +- Do not move local files merely to mirror upstream paths. +- Do not replace multi-account storage with upstream's single OpenAI auth record. +- Do not synthesize a GPT-5.6 model or infer Ultra from a model-name/version heuristic. +- Do not port core Effect/logging/schema refactors unless required by a public plugin/SDK API change. +- Do not enable WebSockets solely because upstream colocated the Codex plugin under `plugin/openai/`. +- Do not move runtime settings into OpenCode's plugin option tuple; preserve `opencode.json` for installation and `codex-config.jsonc` for behavior. + +## Primary-source reference index + +- Tagged Codex plugin: [v1.3.0](https://github.com/anomalyco/opencode/blob/v1.3.0/packages/opencode/src/plugin/codex.ts), [v1.17.18](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/codex.ts) +- Catalog/model implementation: [v1.3.0 provider/models.ts](https://github.com/anomalyco/opencode/blob/v1.3.0/packages/opencode/src/provider/models.ts), [v1.17.18 core/models-dev.ts](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/core/src/models-dev.ts), [move commit](https://github.com/anomalyco/opencode/commit/16c457e71233b2eca6a73aa292ec1ed225d87af7) +- OpenAI transport: [WebSocket pool](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/ws-pool.ts), [WebSocket protocol adapter](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/ws.ts) +- Provider/session behavior: [transform.ts](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/provider/transform.ts), [error.ts](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/provider/error.ts), [retry.ts](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/session/retry.ts) +- Release/tag comparison: [v1.3.0...v1.17.18](https://github.com/anomalyco/opencode/compare/v1.3.0...v1.17.18) diff --git a/docs/development/README.md b/docs/development/README.md index 6e8e517..f72af6f 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -17,4 +17,5 @@ Use this section for implementation internals and maintenance workflows. - `docs/development/CONFIG_FLOW.md` - config load/merge resolution behavior. - `docs/development/TESTING.md` - test strategy and verification commands. - `docs/development/UPSTREAM_SYNC.md` - how we track/sync upstream behavior. +- `docs/development/OPENCODE_V1_17_18_SYNC.md` - detailed v1.3.0 to v1.17.18 parity audit and disposition. - `docs/development/upstream-watch.json` - upstream watch state artifact. diff --git a/docs/development/UPSTREAM_SYNC.md b/docs/development/UPSTREAM_SYNC.md index 443d22c..5c4dc90 100644 --- a/docs/development/UPSTREAM_SYNC.md +++ b/docs/development/UPSTREAM_SYNC.md @@ -4,18 +4,18 @@ Track the OpenCode and Codex releases this plugin is aligned to, and how to keep ## Current baseline -- OpenCode release: `v1.3.0` +- OpenCode release: `v1.17.18` - Upstream repo: `https://github.com/anomalyco/opencode` - Baseline tag commit: tracked in `docs/development/upstream-watch.json` - Upstream HEAD inspected: GitHub latest release/tag via `npm run check:upstream` -- Native Codex reference file: `packages/opencode/src/plugin/codex.ts` +- Native Codex reference file: `packages/opencode/src/plugin/openai/codex.ts` - Codex upstream repo: `https://github.com/openai/codex` - Codex upstream release track: `rust-v0.144.1` for the GPT-5.6 Ultra contract - Local dependency target: - - `@opencode-ai/plugin`: `^1.3.0` - - `@opencode-ai/sdk`: `^1.3.0` + - `@opencode-ai/plugin`: `^1.17.18` + - `@opencode-ai/sdk`: `^1.17.18` -## Latest parity audit (2026-03-23) +## Latest parity audit (2026-07-10) - Verified OAuth constants and authorize URL semantics against upstream `codex.ts`. - Verified native callback URI now uses `http://localhost:1455/auth/callback`. @@ -24,9 +24,14 @@ Track the OpenCode and Codex releases this plugin is aligned to, and how to keep - Verified live Codex model payload now includes `default_reasoning_summary` and newer GPT-5.4-era catalog metadata in addition to `display_name`, `priority`, and `supports_parallel_tool_calls`. - Verified default prompt caching remains upstream-owned: OpenCode sets `promptCacheKey` from `sessionID`, and Codex sends `prompt_cache_key` from the active conversation ID. - Verified GPT-5.4 fast mode remains request-body `service_tier: "priority"`; no new HTTP priority header is used on the normal request path. -- Verified normal HTTP request parity for both `native` and `codex` modes continues to use `session_id` and excludes websocket-only `OpenAI-Beta` headers. +- Updated normal HTTP request parity for both `native` and `codex` modes to use canonical `session-id`; legacy `session_id` remains accepted as an inbound compatibility alias and in snapshot redaction. +- Verified OpenCode's new Responses WebSocket transport remains experimental and optional. This plugin continues to use the supported HTTP path and now watches the upstream transport files for future stabilization. +- Integrated OpenCode's plugin lifecycle contract by composing `dispose` to stop the proactive-refresh scheduler. The v1 module-object export and plugin option tuples remain optional; the function export preserves older-host compatibility and runtime settings remain in `codex-config.jsonc`. +- Verified OpenCode's OAuth model filter is catalog-driven. This plugin retains its stricter account-scoped live Codex catalog authority and does not synthesize metadata across model slugs. +- Verified concurrent catalog fetches are deduplicated and account refreshes remain lock-guarded by strict account identity in this plugin's multi-account architecture. - Verified the plugin now uses Codex `default_reasoning_summary` instead of treating `reasoning_summary_format` as the default summary value. -- Parity tests live in `test/codex-native-oauth-parity.test.ts`. +- Detailed findings and dispositions are in `docs/development/OPENCODE_V1_17_18_SYNC.md`. +- Parity tests live in `test/codex-native-oauth-parity.test.ts`, `test/codex-native-spoof-mode.test.ts`, and `test/upstream-watch-config.test.ts`. ## Sync checklist @@ -43,14 +48,16 @@ Track the OpenCode and Codex releases this plugin is aligned to, and how to keep - Tracked file manifest: `docs/development/upstream-watch.json` - Local check command: `npm run check:upstream` - Baseline refresh command (after parity update): `npm run check:upstream:update` +- OpenCode-only check/update commands: `npm run check:upstream:opencode`, `npm run check:upstream:opencode:update` - Scheduled CI watcher: `.github/workflows/upstream-watch.yml` (weekly + manual dispatch) Tracked upstream surfaces include: -- Codex plugin: `packages/opencode/src/plugin/codex.ts` +- Codex plugin: `packages/opencode/src/plugin/openai/codex.ts` +- Experimental OpenAI transport: `packages/opencode/src/plugin/openai/ws.ts`, `packages/opencode/src/plugin/openai/ws-pool.ts` - Plugin wiring: `packages/opencode/src/plugin/index.ts` - Provider core: `packages/opencode/src/provider/provider.ts`, `packages/opencode/src/provider/auth.ts` -- Provider transforms/schema/error handling: `packages/opencode/src/provider/transform.ts`, `packages/opencode/src/provider/models.ts`, `packages/opencode/src/provider/error.ts` +- Provider transforms/schema/error handling: `packages/opencode/src/provider/transform.ts`, `packages/core/src/models-dev.ts`, `packages/opencode/src/provider/error.ts` - Session-side OpenAI stream error handling: `packages/opencode/src/session/message-v2.ts` - Codex upstream model/auth/runtime files: `codex-rs/models-manager/models.json`, `codex-rs/core/src/auth.rs`, `codex-rs/core/src/client.rs`, `codex-rs/core/src/codex.rs`, `codex-rs/core/src/compact.rs` diff --git a/docs/development/upstream-watch.json b/docs/development/upstream-watch.json index ea67b13..e2e3e0e 100644 --- a/docs/development/upstream-watch.json +++ b/docs/development/upstream-watch.json @@ -3,54 +3,66 @@ { "id": "opencode", "repo": "anomalyco/opencode", - "baselineTag": "v1.3.0", - "updatedAt": "2026-03-23T16:25:00.338Z", + "baselineTag": "v1.17.18", + "updatedAt": "2026-07-10T22:17:25.114Z", "files": [ { - "path": "packages/opencode/src/plugin/codex.ts", - "sha256": "2f7ddda4b6d8619c883ac227832614f0c2a4dac2cabfe31c737fcd006666752e", + "path": "packages/opencode/src/plugin/openai/codex.ts", + "sha256": "d45533d80269f959bd8eb403b288d78be5064ff9e6523dc6af01ed2a788ef064", "localArea": "lib/codex-native/", "reason": "Codex OAuth/auth headers/request routing parity" }, + { + "path": "packages/opencode/src/plugin/openai/ws.ts", + "sha256": "15415cbf18806caf3475e09ea0f731e96d5397daa19740d456b6aa8468640498", + "localArea": "lib/codex-native/openai-loader-fetch.ts", + "reason": "Experimental OpenAI Responses WebSocket protocol and HTTP fallback signals" + }, + { + "path": "packages/opencode/src/plugin/openai/ws-pool.ts", + "sha256": "5a8b681d69ed8cfb592a107254f9df05e5a0c7574a5099853cac92a1c6d69226", + "localArea": "lib/codex-native/openai-loader-fetch.ts", + "reason": "Experimental WebSocket pooling, retry, and lifecycle behavior" + }, { "path": "packages/opencode/src/plugin/index.ts", - "sha256": "c3aa395685bceeae29b93b1eb740e762f4449a6834fcc2e6863dd9d1a53a0abe", + "sha256": "4495d8730a87a0b40509be27bdd432b53a8c4c914dfcba4121e3651b3e649555", "localArea": "index.ts", "reason": "Built-in plugin boot order affects auth integration behavior" }, { "path": "packages/opencode/src/provider/provider.ts", - "sha256": "53bd3146d0ad5c06f5ae62d9e4c088385c221ab31e8f6a6bba15144dfe5b737c", + "sha256": "0a984775696b2874154b06eb29f5d933d762b03a48460c140eb59b5df82b64e7", "localArea": "lib/model-catalog.ts", "reason": "OpenAI model surface and provider model wiring parity" }, { "path": "packages/opencode/src/provider/auth.ts", - "sha256": "ec8be328fcfe43b9129949ceaf93acc6e30f4daaf9c3e280c5ac64b4419e08c4", + "sha256": "7331fda8558fe517aa5a69a8aa78dcbb80af719f43caf16c8960f9c5ad7a0d2e", "localArea": "lib/storage.ts", "reason": "OpenAI auth structure/parsing parity signals" }, { "path": "packages/opencode/src/provider/transform.ts", - "sha256": "398f78dde2876aa4ba2e23a35a35d6d6cbff663db033c4eb69715c363d683134", + "sha256": "061a7eb8c56072aa0bc3dec999f460ac8fc2fc5cbd7e35f7ee47cc959896aff2", "localArea": "lib/codex-native/request-transform*.ts", "reason": "Request transform behavior changes affecting auth/model requests" }, { "path": "packages/opencode/src/provider/error.ts", - "sha256": "23be550ec590ac18b10bf2b6c00abdf68badc2c5e207926da59869aaf4b1c65c", + "sha256": "e2c87611d8cb331a509580dc0cea5d37a5d0677df1c9646d99cba03595eaf27b", "localArea": "lib/codex-native/openai-loader-fetch.ts", "reason": "OpenAI/Codex error semantics and retry-stop signals" }, { - "path": "packages/opencode/src/provider/models.ts", - "sha256": "9ed3382225efb5c7b40bce0bff031a5df1ed69249f03ad81c7f9b7a50bf26051", + "path": "packages/core/src/models-dev.ts", + "sha256": "6326ca2f6d79ed55d330707cb7442f5d2fa54e2bbabbbd0307b116ca1ac59e16", "localArea": "lib/model-catalog.ts", "reason": "Provider model schema changes impacting catalog parsing" }, { "path": "packages/opencode/src/session/message-v2.ts", - "sha256": "1d3c1d014c398cec06fb95e81fa5d2961370e548afa1784886d13fe6e94ee332", + "sha256": "6094dbeaf39b70ca00fca09cfc708c3304a8dec3636dbbc77472bf7716758c54", "localArea": "lib/fetch-orchestrator.ts", "reason": "Streaming error handling and retry behavior parity" } diff --git a/index.ts b/index.ts index b1c407b..407e795 100644 --- a/index.ts +++ b/index.ts @@ -48,6 +48,7 @@ import { toolOutputForStatus } from "./lib/codex-status-tool.js" import { requireOpenAIMultiOauthAuth, saveAuthStorage } from "./lib/storage.js" import { refreshCachedCodexPrompts } from "./lib/codex-prompts-cache.js" import { setCodexPlanModeInstructions } from "./lib/codex-native/collaboration.js" +import { composePluginDispose } from "./lib/plugin-lifecycle.js" let scheduler: { stop: () => void } | undefined @@ -142,28 +143,50 @@ export const OpenAIMultiAuthPlugin: Plugin = async (input) => { scheduler = { stop: refreshScheduler.stop } } + // Capture ownership before any further async initialization. A newer plugin + // invocation may replace the module-level scheduler while this one awaits. + const instanceScheduler = scheduler + log.debug("plugin init") - const hooks = await CodexAuthPlugin(input, { - log, - personality: getPersonality(cfg), - mode: runtimeMode, - quietMode: getQuietMode(cfg), - pidOffsetEnabled: getPidOffsetEnabled(cfg), - rotationStrategy: getRotationStrategy(cfg), - promptCacheKeyStrategy: getPromptCacheKeyStrategy(cfg), - spoofMode: getSpoofMode(cfg), - compatInputSanitizer: getCompatInputSanitizerEnabled(cfg), - remapDeveloperMessagesToUser: getRemapDeveloperMessagesToUserEnabled(cfg), - codexCompactionOverride: getCodexCompactionOverrideEnabled(cfg), - shareableDebug: getShareableDebugEnabled(cfg), - headerSnapshots: getHeaderSnapshotsEnabled(cfg), - headerSnapshotBodies: getHeaderSnapshotBodiesEnabled(cfg), - headerTransformDebug: getHeaderTransformDebugEnabled(cfg), - collaborationProfileEnabled, - orchestratorSubagentsEnabled: getOrchestratorSubagentsEnabled(cfg), - behaviorSettings: getBehaviorSettings(cfg), - customModels: getCustomModels(cfg), - modelAliases: getModelAliasSettings(cfg) + let hooks: Awaited> + try { + hooks = await CodexAuthPlugin(input, { + log, + personality: getPersonality(cfg), + mode: runtimeMode, + quietMode: getQuietMode(cfg), + pidOffsetEnabled: getPidOffsetEnabled(cfg), + rotationStrategy: getRotationStrategy(cfg), + promptCacheKeyStrategy: getPromptCacheKeyStrategy(cfg), + spoofMode: getSpoofMode(cfg), + compatInputSanitizer: getCompatInputSanitizerEnabled(cfg), + remapDeveloperMessagesToUser: getRemapDeveloperMessagesToUserEnabled(cfg), + codexCompactionOverride: getCodexCompactionOverrideEnabled(cfg), + shareableDebug: getShareableDebugEnabled(cfg), + headerSnapshots: getHeaderSnapshotsEnabled(cfg), + headerSnapshotBodies: getHeaderSnapshotBodiesEnabled(cfg), + headerTransformDebug: getHeaderTransformDebugEnabled(cfg), + collaborationProfileEnabled, + orchestratorSubagentsEnabled: getOrchestratorSubagentsEnabled(cfg), + behaviorSettings: getBehaviorSettings(cfg), + customModels: getCustomModels(cfg), + modelAliases: getModelAliasSettings(cfg) + }) + } catch (error) { + instanceScheduler?.stop() + if (scheduler === instanceScheduler) { + scheduler = undefined + } + throw error + } + composePluginDispose({ + hooks, + scheduler: instanceScheduler, + clearScheduler: () => { + if (scheduler === instanceScheduler) { + scheduler = undefined + } + } }) const z = tool.schema diff --git a/lib/codex-native.ts b/lib/codex-native.ts index 8e5af0d..7d71b43 100644 --- a/lib/codex-native.ts +++ b/lib/codex-native.ts @@ -1,5 +1,4 @@ -import type { Hooks, PluginInput } from "@opencode-ai/plugin" -import type { Config } from "@opencode-ai/sdk" +import type { Config, Hooks, PluginInput } from "@opencode-ai/plugin" import process from "node:process" import { loadAuthStorage, setAccountCooldown } from "./storage.js" diff --git a/lib/codex-native/chat-hooks.ts b/lib/codex-native/chat-hooks.ts index df06577..e453145 100644 --- a/lib/codex-native/chat-hooks.ts +++ b/lib/codex-native/chat-hooks.ts @@ -309,7 +309,8 @@ export async function handleChatHeadersHook(input: { const originator = resolveCodexOriginator(input.spoofMode) input.output.headers.originator = originator input.output.headers["User-Agent"] = resolveRequestUserAgent(input.spoofMode, originator) - input.output.headers.session_id = input.hookInput.sessionID + input.output.headers["session-id"] = input.hookInput.sessionID + delete input.output.headers.session_id if (typeof input.hookInput.model.id === "string" && input.hookInput.model.id.trim()) { input.output.headers[input.internalSelectedModelHeader] = input.hookInput.model.id } else { diff --git a/lib/fetch-orchestrator-helpers.ts b/lib/fetch-orchestrator-helpers.ts index e8e06c5..1daabcf 100644 --- a/lib/fetch-orchestrator-helpers.ts +++ b/lib/fetch-orchestrator-helpers.ts @@ -4,6 +4,7 @@ const CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS = new Set([ "authorization", "proxy-authorization", "chatgpt-account-id", + "session-id", "session_id", "cookie", "set-cookie" @@ -24,7 +25,7 @@ function normalizeSessionKey(value: unknown): string | null { } export async function resolveSessionKey(request: Request): Promise { - return normalizeSessionKey(request.headers.get("session_id")) + return normalizeSessionKey(request.headers.get("session-id") ?? request.headers.get("session_id")) } export function formatAccountLabel(auth: AuthData): string { diff --git a/lib/plugin-lifecycle.ts b/lib/plugin-lifecycle.ts new file mode 100644 index 0000000..fab178b --- /dev/null +++ b/lib/plugin-lifecycle.ts @@ -0,0 +1,32 @@ +import type { Hooks } from "@opencode-ai/plugin" + +export function composePluginDispose(input: { + hooks: Hooks + scheduler?: { stop: () => void } + clearScheduler: () => void +}): void { + const downstreamDispose = input.hooks.dispose + input.hooks.dispose = async () => { + let schedulerError: unknown + try { + input.scheduler?.stop() + } catch (error) { + schedulerError = error + } finally { + input.clearScheduler() + } + + try { + await downstreamDispose?.() + } catch (error) { + if (schedulerError !== undefined) { + throw new AggregateError([schedulerError, error], "Plugin disposal failed") + } + throw error + } + + if (schedulerError !== undefined) { + throw schedulerError + } + } +} diff --git a/lib/request-snapshots.ts b/lib/request-snapshots.ts index a820eb2..4690e76 100644 --- a/lib/request-snapshots.ts +++ b/lib/request-snapshots.ts @@ -26,6 +26,7 @@ const REDACTED_HEADERS = new Set([ "cf-connecting-ip", "x-client-ip", "chatgpt-account-id", + "session-id", "session_id" ]) const REDACTED_HEADER_KEY_FRAGMENTS = ["api-key", "token", "secret", "session", "cookie", "auth"] @@ -51,6 +52,7 @@ const REDACTED_BODY_KEYS = new Set([ "accesstoken", "refreshtoken", "idtoken", + "session-id", "session_id", "sessionid", "chatgpt-account-id", @@ -233,6 +235,7 @@ const REDACTED_QUERY_KEYS = new Set([ "accesstoken", "refreshtoken", "idtoken", + "session-id", "session_id", "chatgpt-account-id", "chatgpt_account_id" diff --git a/package-lock.json b/package-lock.json index f3687a9..317996b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.0.6", - "@opencode-ai/plugin": "^1.3.0", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/plugin": "^1.17.18", + "@opencode-ai/sdk": "^1.17.18", "@types/node": "^20.17.24", "@types/proper-lockfile": "^4.1.2", "@vitest/coverage-v8": "^3.2.4", @@ -32,6 +32,19 @@ "@opencode-ai/plugin": "^1.3.0" } }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -778,23 +791,128 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@opencode-ai/plugin": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.0.tgz", - "integrity": "sha512-mR1Kdcpr3Iv+KS7cL2DRFB6QAcSoR6/DojmwuxYF/pMCahMtaCLiqZGQjoSNl12+gQ6RsIJJyUh/jX3JVlOx8A==", + "version": "1.17.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.18.tgz", + "integrity": "sha512-tqVBzhTHYUzO0laAmcQeBtT56tXYM5VGUk9V60O+cMx4kkSfac4qEfPVguCGUMJnfcU+u+EPvpME6xmHWoQE8w==", "dev": true, "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.3.0", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.17.18", + "effect": "4.0.0-beta.83", "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } } }, "node_modules/@opencode-ai/sdk": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.0.tgz", - "integrity": "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g==", + "version": "1.17.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", + "integrity": "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1157,6 +1275,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1533,6 +1658,17 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1540,6 +1676,25 @@ "dev": true, "license": "MIT" }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -1616,6 +1771,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1634,6 +1812,13 @@ } } }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1724,6 +1909,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1818,6 +2013,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1903,6 +2112,46 @@ "dev": true, "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -1922,6 +2171,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2033,6 +2298,23 @@ "signal-exit": "^3.0.2" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -2405,6 +2687,16 @@ "node": ">=14.0.0" } }, + "node_modules/toml": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.2.tgz", + "integrity": "sha512-m0vXfHODcw3gk+KONAOlVQ5yNHc3yS3B1ybM3HS1vqDoS0RWTDDVBVVTYi8hH0k+2OM1vmo9fb1WX9EVqjqfHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2426,6 +2718,20 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2725,6 +3031,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", diff --git a/package.json b/package.json index ebad99f..c74ecfa 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "perf:profile:compare": "npm run build && node dist/scripts/perf-profile.js 300", "check:upstream": "node scripts/check-upstream-watch.js", "check:upstream:update": "node scripts/check-upstream-watch.js --update", + "check:upstream:opencode": "node scripts/check-upstream-watch.js --source=opencode", + "check:upstream:opencode:update": "node scripts/check-upstream-watch.js --update --source=opencode", "hooks:install": "node scripts/install-git-hooks.mjs", "prepack": "npm run build", "release": "node scripts/release.js", @@ -85,8 +87,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.0.6", - "@opencode-ai/plugin": "^1.3.0", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/plugin": "^1.17.18", + "@opencode-ai/sdk": "^1.17.18", "@types/node": "^20.17.24", "@types/proper-lockfile": "^4.1.2", "@vitest/coverage-v8": "^3.2.4", diff --git a/scripts/check-upstream-watch.js b/scripts/check-upstream-watch.js index d7a63ed..99fcf37 100644 --- a/scripts/check-upstream-watch.js +++ b/scripts/check-upstream-watch.js @@ -192,8 +192,17 @@ async function collectSourceResult(source) { async function main() { const update = process.argv.includes("--update") + const sourceArg = process.argv.find((arg) => arg.startsWith("--source=")) + const sourceFilter = sourceArg?.slice("--source=".length).trim() const watch = await loadWatchConfig() + if (sourceArg && !sourceFilter) { + throw new Error("Invalid --source value: expected a non-empty source id") + } + if (sourceFilter && !watch.sources.some((source) => source.id === sourceFilter)) { + throw new Error(`Unknown upstream watch source: ${sourceFilter}`) + } + const reports = [] const nextSources = [] let anyDrift = false @@ -202,6 +211,11 @@ async function main() { if (!source || typeof source.repo !== "string" || !Array.isArray(source.files)) { throw new Error("Invalid upstream watch source entry") } + if (sourceFilter && source.id !== sourceFilter) { + nextSources.push(source) + continue + } + const collected = await collectSourceResult(source) reports.push( buildReport({ diff --git a/scripts/patch-opencode-plugin-dts.js b/scripts/patch-opencode-plugin-dts.js index b312f7e..41d8f4b 100644 --- a/scripts/patch-opencode-plugin-dts.js +++ b/scripts/patch-opencode-plugin-dts.js @@ -16,6 +16,8 @@ async function patchPluginTypes() { .replaceAll('from "./tool";', 'from "./tool.js";') .replaceAll('from "./tool";', 'from "./tool.js";') .replaceAll('export * from "./tool";', 'export * from "./tool.js";') + .replaceAll("headers?: HeadersInit;", 'headers?: RequestInit["headers"];') + .replaceAll("headers?: globalThis.HeadersInit;", 'headers?: RequestInit["headers"];') if (patched !== raw) { await fs.writeFile(filePath, patched, "utf8") diff --git a/test/codex-native-snapshots.test.ts b/test/codex-native-snapshots.test.ts index 741c4c1..29eb166 100644 --- a/test/codex-native-snapshots.test.ts +++ b/test/codex-native-snapshots.test.ts @@ -199,7 +199,7 @@ describe("codex-native snapshots", () => { const request = input as Request capturedUserAgent = request.headers.get("user-agent") ?? "" capturedOriginator = request.headers.get("originator") ?? "" - capturedSessionId = request.headers.get("session_id") ?? "" + capturedSessionId = request.headers.get("session-id") ?? "" return new Response("ok", { status: 200 }) }) ) @@ -226,7 +226,7 @@ describe("codex-native snapshots", () => { "content-type": "application/json", originator: "codex_exec", "user-agent": "opencode-codex-auth (...) ai-sdk/provider-utils/3.0.20 runtime/bun/1.3.5", - session_id: "ses_codex_fetch_1" + "session-id": "ses_codex_fetch_1" }, body: JSON.stringify({ model: "gpt-5.2-codex", input: "hi" }) }) @@ -239,7 +239,7 @@ describe("codex-native snapshots", () => { expect(capturedSessionId).toBe("ses_codex_fetch_1") }) - it("preserves native originator/user-agent/session_id in native mode before outbound fetch", async () => { + it("preserves native originator/user-agent/session-id in native mode before outbound fetch", async () => { vi.resetModules() const auth = { @@ -319,7 +319,7 @@ describe("codex-native snapshots", () => { const request = input as Request capturedUserAgent = request.headers.get("user-agent") ?? "" capturedOriginator = request.headers.get("originator") ?? "" - capturedSessionId = request.headers.get("session_id") ?? "" + capturedSessionId = request.headers.get("session-id") ?? "" return new Response("ok", { status: 200 }) }) ) @@ -346,7 +346,7 @@ describe("codex-native snapshots", () => { "content-type": "application/json", originator: "opencode", "user-agent": "opencode/1.2.3 (Darwin)", - session_id: "ses_native_fetch_1" + "session-id": "ses_native_fetch_1" }, body: JSON.stringify({ model: "gpt-5.2-codex", input: "hi" }) }) diff --git a/test/codex-native-spoof-mode.test.ts b/test/codex-native-spoof-mode.test.ts index b1877f8..b5efbb8 100644 --- a/test/codex-native-spoof-mode.test.ts +++ b/test/codex-native-spoof-mode.test.ts @@ -871,7 +871,8 @@ describe("codex-native spoof + params hooks", () => { expect(output.headers.originator).toBe("opencode") expect(output.headers["User-Agent"]).toMatch(/^opencode\//) - expect(output.headers.session_id).toBe("ses_native") + expect(output.headers["session-id"]).toBe("ses_native") + expect(output.headers.session_id).toBeUndefined() expect(output.headers.conversation_id).toBeUndefined() expect(output.headers["OpenAI-Beta"]).toBeUndefined() }) @@ -891,7 +892,7 @@ describe("codex-native spoof + params hooks", () => { expect(output.headers.originator).toBe("opencode") expect(output.headers["User-Agent"]).toMatch(/^opencode\//) - expect(output.headers.session_id).toBe("ses_no_prompt_cache") + expect(output.headers["session-id"]).toBe("ses_no_prompt_cache") expect(output.headers.conversation_id).toBeUndefined() expect(output.headers["OpenAI-Beta"]).toBeUndefined() }) @@ -916,7 +917,7 @@ describe("codex-native spoof + params hooks", () => { expect(output.headers.originator).toBe("codex_cli_rs") expect(output.headers["User-Agent"]).toMatch(/^codex_cli_rs\//) - expect(output.headers.session_id).toBe("ses_strict") + expect(output.headers["session-id"]).toBe("ses_strict") expect(output.headers["OpenAI-Beta"]).toBeUndefined() expect(output.headers.conversation_id).toBeUndefined() }) @@ -957,7 +958,7 @@ describe("codex-native spoof + params hooks", () => { }) }) - it("uses sessionID as codex-mode session_id when prompt cache key is absent", async () => { + it("uses sessionID as codex-mode session-id when prompt cache key is absent", async () => { const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex" }) const chatHeaders = hooks["chat.headers"] expect(chatHeaders).toBeTypeOf("function") @@ -972,7 +973,7 @@ describe("codex-native spoof + params hooks", () => { expect(output.headers.originator).toBe("codex_cli_rs") expect(output.headers["User-Agent"]).toMatch(/^codex_cli_rs\//) - expect(output.headers.session_id).toBe("ses_strict_fallback") + expect(output.headers["session-id"]).toBe("ses_strict_fallback") expect(output.headers["OpenAI-Beta"]).toBeUndefined() expect(output.headers.conversation_id).toBeUndefined() }) diff --git a/test/fetch-orchestrator.snapshots-and-redirects.test.ts b/test/fetch-orchestrator.snapshots-and-redirects.test.ts index ac7d857..2075ade 100644 --- a/test/fetch-orchestrator.snapshots-and-redirects.test.ts +++ b/test/fetch-orchestrator.snapshots-and-redirects.test.ts @@ -274,7 +274,7 @@ describe("FetchOrchestrator snapshots and redirect policy", () => { expect(onSessionObserved).not.toHaveBeenCalled() }) - it("uses session_id as canonical session key", async () => { + it("uses session-id as the canonical session key", async () => { const acquireAuth = vi.fn(async () => ({ access: "token_123", identityKey: "id1", @@ -298,7 +298,7 @@ describe("FetchOrchestrator snapshots and redirect policy", () => { await orch.execute("https://api.com", { method: "POST", - headers: { "content-type": "application/json", session_id: "ses_header" }, + headers: { "content-type": "application/json", "session-id": "ses_header" }, body: JSON.stringify({ input: "hello with mixed keys", prompt_cache_key: "ses_prompt_cache" diff --git a/test/mode-smoke.test.ts b/test/mode-smoke.test.ts index 34d517c..9adad15 100644 --- a/test/mode-smoke.test.ts +++ b/test/mode-smoke.test.ts @@ -24,7 +24,7 @@ describe("mode smoke: native vs codex", () => { expect(codexOut.headers.originator).toBe("codex_cli_rs") expect(nativeOut.headers["OpenAI-Beta"]).toBeUndefined() expect(codexOut.headers["OpenAI-Beta"]).toBeUndefined() - expect(nativeOut.headers["session_id"]).toBe("ses_mode_smoke") + expect(nativeOut.headers["session-id"]).toBe("ses_mode_smoke") expect(nativeOut.headers["conversation_id"]).toBeUndefined() expect(codexOut.headers["conversation_id"]).toBeUndefined() expect(nativeOut.headers["User-Agent"]).toMatch(/^opencode\//) diff --git a/test/models-gpt-5.3-codex.test.ts b/test/models-gpt-5.3-codex.test.ts index ca5ee0f..3ff9b4a 100644 --- a/test/models-gpt-5.3-codex.test.ts +++ b/test/models-gpt-5.3-codex.test.ts @@ -688,7 +688,7 @@ describe("codex-native model allowlist", () => { } const request = url as Request - const sessionID = request.headers.get("session_id") ?? "" + const sessionID = request.headers.get("session-id") ?? "" if (sessionID === interleavingSessionID) { sawInterleavingRequest = true return new Response("ok", { status: 200 }) diff --git a/test/plugin-lifecycle.test.ts b/test/plugin-lifecycle.test.ts new file mode 100644 index 0000000..174d9ea --- /dev/null +++ b/test/plugin-lifecycle.test.ts @@ -0,0 +1,74 @@ +import type { Hooks } from "@opencode-ai/plugin" +import { describe, expect, it, vi } from "vitest" + +import { composePluginDispose } from "../lib/plugin-lifecycle" + +describe("plugin lifecycle", () => { + it("stops the scheduler, clears ownership, and composes downstream cleanup", async () => { + const stop = vi.fn() + const clearScheduler = vi.fn() + const downstreamDispose = vi.fn(async () => {}) + const hooks = { dispose: downstreamDispose } as Hooks + + composePluginDispose({ hooks, scheduler: { stop }, clearScheduler }) + await hooks.dispose?.() + + expect(stop).toHaveBeenCalledOnce() + expect(clearScheduler).toHaveBeenCalledOnce() + expect(downstreamDispose).toHaveBeenCalledOnce() + expect(stop.mock.invocationCallOrder[0]).toBeLessThan(downstreamDispose.mock.invocationCallOrder[0] ?? 0) + }) + + it("still installs cleanup when proactive refresh is disabled", async () => { + const clearScheduler = vi.fn() + const hooks = {} as Hooks + + composePluginDispose({ hooks, clearScheduler }) + await hooks.dispose?.() + + expect(clearScheduler).toHaveBeenCalledOnce() + }) + + it("runs ownership and downstream cleanup even when scheduler stop throws", async () => { + const stopError = new Error("stop failed") + const clearScheduler = vi.fn() + const downstreamDispose = vi.fn(async () => {}) + const hooks = { dispose: downstreamDispose } as Hooks + + composePluginDispose({ + hooks, + scheduler: { + stop: () => { + throw stopError + } + }, + clearScheduler + }) + + await expect(hooks.dispose?.()).rejects.toBe(stopError) + expect(clearScheduler).toHaveBeenCalledOnce() + expect(downstreamDispose).toHaveBeenCalledOnce() + }) + + it("does not let stale-instance cleanup clear newer scheduler ownership", async () => { + const firstStop = vi.fn() + const secondStop = vi.fn() + const firstScheduler = { stop: firstStop } + const secondScheduler = { stop: secondStop } + let scheduler: { stop: () => void } | undefined = secondScheduler + const hooks = {} as Hooks + + composePluginDispose({ + hooks, + scheduler: firstScheduler, + clearScheduler: () => { + if (scheduler === firstScheduler) scheduler = undefined + } + }) + await hooks.dispose?.() + + expect(firstStop).toHaveBeenCalledOnce() + expect(secondStop).not.toHaveBeenCalled() + expect(scheduler).toBe(secondScheduler) + }) +}) diff --git a/test/request-snapshots.test.ts b/test/request-snapshots.test.ts index d4498e6..0cb8577 100644 --- a/test/request-snapshots.test.ts +++ b/test/request-snapshots.test.ts @@ -142,7 +142,7 @@ describe("request snapshots", () => { headers: { Authorization: "Bearer super-secret-token", "ChatGPT-Account-Id": "acc_sensitive", - session_id: "ses_sensitive", + "session-id": "ses_sensitive", "Content-Type": "application/json" }, body: JSON.stringify({ model: "gpt-5.3-codex" }) @@ -158,7 +158,7 @@ describe("request snapshots", () => { expect(payload.headers.authorization).toBe("Bearer [redacted]") expect(payload.headers["chatgpt-account-id"]).toBe("[redacted]") - expect(payload.headers.session_id).toBe("[redacted]") + expect(payload.headers["session-id"]).toBe("[redacted]") }) it("redacts token-like custom header names", async () => { diff --git a/test/upstream-watch-config.test.ts b/test/upstream-watch-config.test.ts index b52e0c2..024f90d 100644 --- a/test/upstream-watch-config.test.ts +++ b/test/upstream-watch-config.test.ts @@ -17,12 +17,14 @@ describe("upstream watch coverage", () => { .filter((value): value is string => !!value) ) - expect(tracked.has("packages/opencode/src/plugin/codex.ts")).toBe(true) + expect(tracked.has("packages/opencode/src/plugin/openai/codex.ts")).toBe(true) + expect(tracked.has("packages/opencode/src/plugin/openai/ws.ts")).toBe(true) + expect(tracked.has("packages/opencode/src/plugin/openai/ws-pool.ts")).toBe(true) expect(tracked.has("packages/opencode/src/plugin/index.ts")).toBe(true) expect(tracked.has("packages/opencode/src/provider/provider.ts")).toBe(true) expect(tracked.has("packages/opencode/src/provider/auth.ts")).toBe(true) expect(tracked.has("packages/opencode/src/provider/transform.ts")).toBe(true) - expect(tracked.has("packages/opencode/src/provider/models.ts")).toBe(true) + expect(tracked.has("packages/core/src/models-dev.ts")).toBe(true) expect(tracked.has("packages/opencode/src/provider/error.ts")).toBe(true) expect(tracked.has("packages/opencode/src/session/message-v2.ts")).toBe(true) expect(tracked.has("codex-rs/models-manager/models.json")).toBe(true) From ce20e1c981a0fd6e8597bfb26e60f5525cd94007 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 10 Jul 2026 19:53:13 -0400 Subject: [PATCH 4/6] feat: complete Ultra agent mode --- docs/development/ULTRA.md | 14 +- lib/codex-native.ts | 16 +- lib/codex-native/agent-execution.ts | 156 ++++++++++++++ lib/codex-native/chat-hooks.ts | 17 +- lib/codex-native/request-transform-payload.ts | 9 +- lib/codex-native/ultra.ts | 64 +++++- test/agent-execution.test.ts | 191 ++++++++++++++++++ test/ultra.test.ts | 84 +++++++- 8 files changed, 531 insertions(+), 20 deletions(-) create mode 100644 lib/codex-native/agent-execution.ts create mode 100644 test/agent-execution.test.ts diff --git a/docs/development/ULTRA.md b/docs/development/ULTRA.md index 70618a2..e32445e 100644 --- a/docs/development/ULTRA.md +++ b/docs/development/ULTRA.md @@ -5,8 +5,9 @@ This plugin treats Ultra as a logical model variant, not as a new inference effo | State | Contract | | --- | --- | | Catalog and picker | `ultra` remains distinct from `max` and is exposed only when the selected catalog model advertises the `ultra` effort, `multi_agent_version: "v2"`, visible status, and API support. | -| Root turn | An eligible Ultra turn in `codex` mode receives a best-effort proactive delegation instruction. Native mode preserves the OpenCode-native identity and does not add a Codex delegation overlay. | -| Child turn | A child inherits maximum reasoning but receives explicit-request-only delegation guidance to avoid uncontrolled recursive fan-out. | +| Root turn | An eligible Ultra turn in `codex` mode receives proactive delegation instructions: parallelize independent sidecar work, assign explicit ownership, avoid duplicate work, wait for required children, verify results, and synthesize them. Native mode preserves the OpenCode-native identity and does not add a Codex delegation overlay. | +| Child turn | A child inherits maximum reasoning but receives explicit-request-only delegation guidance to avoid uncontrolled recursive fan-out and duplicate parent or sibling work. | +| Auxiliary turn | OpenCode title, summary, and compaction turns retain wire normalization but receive no delegation instructions. | | Backend request | Every literal `reasoning.effort: "ultra"` is normalized to `"max"` at the last-mile request transform. Explicit `max` never receives Ultra policy. | | Missing or stale metadata | Ultra is disabled when catalog metadata cannot prove eligibility. A manually configured literal `ultra` is safe-degraded to wire `max` without proactive instructions. | | Failure | Missing task tools, disabled collaboration, spawn failure, cancellation, or partial completion do not fail the root turn. The agent continues locally and must not claim delegation that did not happen. | @@ -15,15 +16,15 @@ The live account-scoped catalog is authoritative. GitHub fallback data is parsed ## State lifecycle -1. `chat.params` resolves the selected model, effort suffix, variant, and custom-model target against the active catalog. +1. `config` records each custom agent's OpenCode mode. `chat.params` resolves session lineage through OpenCode's session API and classifies the execution as root, child, or auxiliary. Session lineage is authoritative for `mode: all`; built-in and configured modes provide a fail-closed fallback. 2. The logical state is retained as `ultra`; eligible `codex`-mode root turns merge the proactive instruction idempotently, while `codex`-mode child turns merge the explicit-only instruction. Native mode keeps the logical state without prompt adaptation. 3. `chat.headers` records a redacted internal Ultra state marker alongside the existing catalog scope and selected-model markers. 4. Each retry resolves the current catalog scope again and applies the same last-mile normalization. Request snapshots include logical effort, wire effort, eligibility, policy, and the reason for any degradation. -5. Compaction, resume, account rotation, and catalog-scope changes inherit only the state represented by the current request and catalog. Stale catalog defaults are removed by the existing catalog-scope cleanup path. +5. Agent role is retained with the logical Ultra state across retries, account rotation, and catalog-scope changes. Stale catalog defaults are removed by the existing catalog-scope cleanup path. ## Degradation and guardrails -Ultra is best effort at the OpenCode collaboration boundary. The plugin does not claim parity with proprietary desktop orchestration. A missing task tool or failed child spawn is observable in the host's normal tool/error path, but it is not a reason to reject the root request. Child turns are explicit-only by default; the host remains responsible for its own concurrency and cancellation controls. +Ultra supplies the complete agent-mode policy available at the OpenCode plugin boundary. OpenCode remains the execution host for task tools, concurrency, steering, cancellation, and child lifecycle. A missing task tool or failed child spawn is observable in the host's normal tool/error path, but it is not a reason to reject the root request. Unknown agents and failed lineage lookups fail closed to child policy rather than enabling recursive fan-out. No new public concurrency or feature flag is required. Existing collaboration-profile and subagent controls remain authoritative, and no private catalog/runtime default is added to public configuration. @@ -33,7 +34,8 @@ The minimum release evidence covers: - parser retention for effort descriptions, `multi_agent_version`, `minimal_client_version`, visibility, and API support; - eligible Sol/Terra variants, ineligible V1/hidden/non-API variants, fallback catalogs, custom aliases, and effort suffixes; -- root proactive and child explicit-only instruction composition, including idempotent merges and preserved user/orchestrator instructions; +- session-lineage classification for root, child, custom `mode: all`, built-in agents, and fail-closed lookup errors; +- root proactive, child explicit-only, and auxiliary-disabled instruction composition, including idempotent merges and preserved user/orchestrator instructions; - literal Ultra normalization to wire Max, explicit Max remaining non-Ultra, and normalization on retries/catalog-scope changes; - redacted snapshots for logical and wire state without internal headers reaching the backend; - compaction and auxiliary request paths remaining safe because their payloads pass through the same last-mile transform; diff --git a/lib/codex-native.ts b/lib/codex-native.ts index 7d71b43..29ca3d8 100644 --- a/lib/codex-native.ts +++ b/lib/codex-native.ts @@ -65,6 +65,7 @@ import { initializeCatalogSync, selectCatalogAuthCandidate } from "./codex-nativ import { createOpenAIFetchHandler } from "./codex-native/openai-loader-fetch.js" import { createShareableDebugLogger } from "./shareable-debug.js" import { isUltraEligible, type UltraResolution } from "./codex-native/ultra.js" +import { createAgentExecutionResolver, deletedSessionIDFromEvent } from "./codex-native/agent-execution.js" export { browserOpenInvocationFor } from "./codex-native/browser.js" export { upsertAccount } from "./codex-native/accounts.js" export { extractAccountId, extractAccountIdFromClaims, refreshAccessToken } from "./codex-native/oauth-utils.js" @@ -409,6 +410,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO let activeCatalogScopeKey: string | undefined let activeCatalogModels: CodexModelInfo[] | undefined let providerModelsForCatalogSync: Record> | undefined + const agentExecutionResolver = createAgentExecutionResolver({ client: input.client }) const quotaFetchCooldownByIdentity = new Map() const aliasSettingsFor = (authType: "oauth" | "api") => ({ fast: opts.modelAliases?.fast !== false, @@ -497,7 +499,14 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO } return { + event: async ({ event }) => { + const sessionID = deletedSessionIDFromEvent(event) + if (!sessionID) return + agentExecutionResolver.deleteSession(sessionID) + catalogRequestMetadataBySession.delete(sessionID) + }, async config(config) { + agentExecutionResolver.updateConfig(config) try { const catalogAuth = await selectCatalogAuthCandidate( authMode, @@ -652,7 +661,12 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO projectRoot: typeof input.worktree === "string" && input.worktree.trim() ? input.worktree : process.cwd(), spoofMode, collaborationProfileEnabled, - orchestratorSubagentsEnabled + orchestratorSubagentsEnabled, + resolveAgentExecution: () => + agentExecutionResolver.resolve({ + sessionID: typeof hookInput.sessionID === "string" ? hookInput.sessionID : undefined, + agentName: hookInput.agent + }) }) const sessionID = typeof (hookInput as { sessionID?: unknown }).sessionID === "string" ? hookInput.sessionID : "" diff --git a/lib/codex-native/agent-execution.ts b/lib/codex-native/agent-execution.ts new file mode 100644 index 0000000..fba77e6 --- /dev/null +++ b/lib/codex-native/agent-execution.ts @@ -0,0 +1,156 @@ +import type { Config } from "@opencode-ai/plugin" + +export type OpenCodeAgentMode = "primary" | "subagent" | "all" +export type AgentExecutionRole = "root" | "child" | "auxiliary" +export type AgentExecutionReason = + | "session_parent" + | "session_root" + | "configured_primary" + | "configured_subagent" + | "builtin_primary" + | "builtin_subagent" + | "builtin_auxiliary" + | "conservative_fallback" + +export type AgentExecution = { + role: AgentExecutionRole + reason: AgentExecutionReason + agentName?: string + configuredMode?: OpenCodeAgentMode +} + +type SessionClient = { + session?: { + get?: (options: { path: { id: string } }) => Promise<{ + data?: { id?: unknown; parentID?: unknown } + error?: unknown + }> + } +} + +const BUILTIN_PRIMARY_AGENTS = new Set(["build", "plan", "orchestrator"]) +const BUILTIN_SUBAGENTS = new Set(["general", "explore", "scout"]) +const BUILTIN_AUXILIARY_AGENTS = new Set(["title", "summary", "compaction", "compact"]) + +function normalizeAgentName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase().replace(/\s+/g, "-") + return normalized || undefined +} + +function normalizeMode(value: unknown): OpenCodeAgentMode | undefined { + return value === "primary" || value === "subagent" || value === "all" ? value : undefined +} + +export function readAgentModes(config: Config): Map { + const modes = new Map() + const agents = (config as Config & { agent?: Record }).agent + if (!agents) return modes + + for (const [name, value] of Object.entries(agents)) { + const normalizedName = normalizeAgentName(name) + const mode = normalizeMode(value?.mode) + if (normalizedName && mode) modes.set(normalizedName, mode) + } + return modes +} + +export function classifyAgentExecutionFallback(input: { + agentName?: unknown + configuredModes?: ReadonlyMap +}): AgentExecution { + const agentName = normalizeAgentName(input.agentName) + const configuredMode = agentName ? input.configuredModes?.get(agentName) : undefined + + if (agentName && BUILTIN_AUXILIARY_AGENTS.has(agentName)) { + return { role: "auxiliary", reason: "builtin_auxiliary", agentName, configuredMode } + } + if (configuredMode === "primary") { + return { role: "root", reason: "configured_primary", agentName, configuredMode } + } + if (configuredMode === "subagent") { + return { role: "child", reason: "configured_subagent", agentName, configuredMode } + } + if (agentName && BUILTIN_PRIMARY_AGENTS.has(agentName)) { + return { role: "root", reason: "builtin_primary", agentName, configuredMode } + } + if (agentName && BUILTIN_SUBAGENTS.has(agentName)) { + return { role: "child", reason: "builtin_subagent", agentName, configuredMode } + } + + // Unknown and mode:all agents cannot be proven root when session lookup is unavailable. + return { role: "child", reason: "conservative_fallback", agentName, configuredMode } +} + +export function createAgentExecutionResolver(input: { client?: SessionClient }) { + let configuredModes = new Map() + const sessionRoles = new Map() + const sessionGenerations = new Map() + const pendingSessionRoles = new Map>() + + const fetchSessionRole = async (sessionID: string): Promise<"root" | "child" | undefined> => { + const getSession = input.client?.session?.get + if (!getSession) return undefined + + const generation = sessionGenerations.get(sessionID) ?? 0 + try { + const response = await getSession({ path: { id: sessionID } }) + if (response.error || response.data?.id !== sessionID) return undefined + const parentID = response.data.parentID + if (parentID !== undefined && parentID !== null && typeof parentID !== "string") return undefined + const role = typeof parentID === "string" && parentID.trim() ? "child" : "root" + if ((sessionGenerations.get(sessionID) ?? 0) === generation) sessionRoles.set(sessionID, role) + return role + } catch { + return undefined + } + } + + return { + updateConfig(config: Config): void { + configuredModes = readAgentModes(config) + }, + deleteSession(sessionID: string): void { + sessionRoles.delete(sessionID) + sessionGenerations.set(sessionID, (sessionGenerations.get(sessionID) ?? 0) + 1) + pendingSessionRoles.delete(sessionID) + }, + async resolve(options: { sessionID?: string; agentName?: unknown }): Promise { + const fallback = classifyAgentExecutionFallback({ + agentName: options.agentName, + configuredModes + }) + if (fallback.role === "auxiliary") return fallback + + const sessionID = options.sessionID?.trim() + if (!sessionID || !input.client?.session?.get) return fallback + + const cached = sessionRoles.get(sessionID) + if (cached) { + return { ...fallback, role: cached, reason: cached === "child" ? "session_parent" : "session_root" } + } + + let pending = pendingSessionRoles.get(sessionID) + if (!pending) { + pending = fetchSessionRole(sessionID) + pendingSessionRoles.set(sessionID, pending) + void pending.finally(() => { + if (pendingSessionRoles.get(sessionID) === pending) pendingSessionRoles.delete(sessionID) + }) + } + const role = await pending + return role ? { ...fallback, role, reason: role === "child" ? "session_parent" : "session_root" } : fallback + } + } +} + +export function deletedSessionIDFromEvent(event: unknown): string | undefined { + if (!event || typeof event !== "object" || (event as { type?: unknown }).type !== "session.deleted") return undefined + const properties = (event as { properties?: unknown }).properties + if (!properties || typeof properties !== "object") return undefined + const info = (properties as { info?: unknown }).info + if (info && typeof info === "object" && typeof (info as { id?: unknown }).id === "string") { + return (info as { id: string }).id + } + return typeof (properties as { id?: unknown }).id === "string" ? (properties as { id: string }).id : undefined +} diff --git a/lib/codex-native/chat-hooks.ts b/lib/codex-native/chat-hooks.ts index e453145..a021e7b 100644 --- a/lib/codex-native/chat-hooks.ts +++ b/lib/codex-native/chat-hooks.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BehaviorSettings, CodexSpoofMode, PersonalityOption } from "../config.js" import type { CodexModelInfo } from "../model-catalog.js" +import type { AgentExecution } from "./agent-execution.js" import { getRuntimeDefaultsForModel, resolveInstructionsForModel } from "../model-catalog.js" import { applyCodexRuntimeDefaultsToParams, @@ -98,6 +99,8 @@ export async function handleChatParamsHook(input: { spoofMode: CodexSpoofMode collaborationProfileEnabled: boolean orchestratorSubagentsEnabled: boolean + agentExecution?: AgentExecution + resolveAgentExecution?: () => Promise }): Promise<{ injectedCatalogDefaultFields: string[]; ultra?: UltraResolution }> { const emptyResult = { injectedCatalogDefaultFields: [] } if (input.hookInput.model.providerID !== "openai") return emptyResult @@ -239,12 +242,22 @@ export async function handleChatParamsHook(input: { output: input.output }) + const ultraSelected = asString(input.output.options.reasoningEffort)?.trim().toLowerCase() === "ultra" + const agentExecution = + input.agentExecution ?? + (ultraSelected && input.resolveAgentExecution ? await input.resolveAgentExecution() : undefined) const ultraResolution = resolveUltraSelection({ reasoningEffort: input.output.options.reasoningEffort, model: catalogModelFromOptions ?? catalogModelFallback, - childTask: resolveSubagentHeaderValue(input.hookInput.agent) !== undefined + agentExecution, + childTask: agentExecution ? undefined : resolveSubagentHeaderValue(input.hookInput.agent) !== undefined }) - if (input.spoofMode === "codex" && ultraResolution.selected && ultraResolution.eligible) { + if ( + input.spoofMode === "codex" && + ultraResolution.selected && + ultraResolution.eligible && + ultraResolution.delegationPolicy !== "disabled" + ) { const ultraInstructions = ultraResolution.delegationPolicy === "proactive" ? ULTRA_PROACTIVE_INSTRUCTIONS : ULTRA_EXPLICIT_ONLY_INSTRUCTIONS input.output.options.instructions = mergeInstructions( diff --git a/lib/codex-native/request-transform-payload.ts b/lib/codex-native/request-transform-payload.ts index 905e3dc..52d3996 100644 --- a/lib/codex-native/request-transform-payload.ts +++ b/lib/codex-native/request-transform-payload.ts @@ -365,7 +365,14 @@ export async function transformOutboundRequestPayload( ? input.ultraState.logicalEffort : (existingReasoning?.effort ?? input.ultraState?.logicalEffort), model: selectedCatalogModel, - childTask: input.ultraChildTask === true || input.ultraState?.delegationPolicy === "explicit_request_only" + agentExecution: input.ultraState + ? { + role: input.ultraState.agentRole, + reason: input.ultraState.agentReason, + ...(input.ultraState.agentName ? { agentName: input.ultraState.agentName } : {}) + } + : undefined, + childTask: input.ultraState ? undefined : input.ultraChildTask === true }) let ultraChanged = false if (ultra.selected && existingReasoning) { diff --git a/lib/codex-native/ultra.ts b/lib/codex-native/ultra.ts index f55272d..be23eae 100644 --- a/lib/codex-native/ultra.ts +++ b/lib/codex-native/ultra.ts @@ -1,10 +1,11 @@ import type { CodexModelInfo } from "../model-catalog.js" +import type { AgentExecution, AgentExecutionReason, AgentExecutionRole } from "./agent-execution.js" const ULTRA_REASONING_EFFORT = "ultra" const ULTRA_WIRE_REASONING_EFFORT = "max" const ULTRA_MULTI_AGENT_VERSION = "v2" -export type UltraDelegationPolicy = "proactive" | "explicit_request_only" +export type UltraDelegationPolicy = "proactive" | "explicit_request_only" | "disabled" export type UltraEligibilityReason = | "eligible" @@ -29,18 +30,23 @@ export type UltraResolution = { wireEffort: string | undefined eligible: boolean delegationPolicy: UltraDelegationPolicy + agentRole: AgentExecutionRole + agentReason: AgentExecutionReason + agentName?: string reason: UltraEligibilityReason modelSlug?: string multiAgentVersion?: string } -export const ULTRA_PROACTIVE_INSTRUCTIONS = `# Ultra Delegation +export const ULTRA_PROACTIVE_INSTRUCTIONS = ` +Proactive multi-agent delegation is active for this root turn. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it. -When independent work can materially improve speed or quality, proactively delegate it to available task or subagent tools. Keep delegation focused: do not delegate trivial, dependent, or sensitive work without a clear benefit. If task tools are unavailable, disabled, or fail, continue the work yourself without claiming that delegation happened.` +Delegate only independent sidecar work; keep immediate blockers local. Avoid duplicate assignments and avoid doing a delegated task yourself while it is in flight. Give each child a clear objective, ownership boundary, write scope, and enough context to know it is part of a larger effort. Respect the tools and permissions available in the host. Coordinate active children, wait for required results, verify their work, and synthesize it before the final response. If delegation is unavailable or fails, continue locally and report only work that actually completed. +` -export const ULTRA_EXPLICIT_ONLY_INSTRUCTIONS = `# Ultra Child Delegation - -Use maximum reasoning for this task, but do not proactively delegate. Spawn or use child task tools only when the user, AGENTS.md, or an installed skill explicitly requests delegation. If a requested child task fails or is unavailable, continue with the work you can complete yourself.` +export const ULTRA_EXPLICIT_ONLY_INSTRUCTIONS = ` +Explicit-request-only multi-agent delegation is active for this child turn. Do not spawn sub-agents unless the user, AGENTS.md, or an installed skill explicitly requests it. Focus on the assigned scope, do not duplicate the parent or sibling work, and return a concise result that the parent can verify and synthesize. If explicitly requested delegation is unavailable or fails, continue locally and report only work that actually completed. +` function normalize(value: unknown): string | undefined { if (typeof value !== "string") return undefined @@ -74,19 +80,34 @@ export function isUltraEligible(model: CodexModelInfo | undefined): boolean { export function resolveUltraSelection(input: { reasoningEffort?: unknown model?: CodexModelInfo + agentExecution?: AgentExecution childTask?: boolean }): UltraResolution { const logicalEffort = normalize(input.reasoningEffort) const selected = logicalEffort === ULTRA_REASONING_EFFORT const reason = selected ? getUltraEligibilityReason(input.model) : "missing_catalog" const eligible = selected && reason === "eligible" + const agentExecution: AgentExecution = + input.agentExecution ?? + (input.childTask + ? { role: "child", reason: "conservative_fallback" } + : { role: "root", reason: "conservative_fallback" }) + const delegationPolicy: UltraDelegationPolicy = + agentExecution.role === "auxiliary" + ? "disabled" + : eligible && agentExecution.role === "root" + ? "proactive" + : "explicit_request_only" return { selected, logicalEffort, wireEffort: selected ? ULTRA_WIRE_REASONING_EFFORT : logicalEffort, eligible, - delegationPolicy: eligible && !input.childTask ? "proactive" : "explicit_request_only", + delegationPolicy, + agentRole: agentExecution.role, + agentReason: agentExecution.reason, + ...(agentExecution.agentName ? { agentName: agentExecution.agentName } : {}), reason, ...(input.model?.slug ? { modelSlug: input.model.slug } : {}), ...(input.model?.multi_agent_version ? { multiAgentVersion: input.model.multi_agent_version } : {}) @@ -101,7 +122,26 @@ export function normalizeUltraWireEffort(value: unknown): { value: string | unde } function isDelegationPolicy(value: unknown): value is UltraDelegationPolicy { - return value === "proactive" || value === "explicit_request_only" + return value === "proactive" || value === "explicit_request_only" || value === "disabled" +} + +function isAgentRole(value: unknown): value is AgentExecutionRole { + return value === "root" || value === "child" || value === "auxiliary" +} + +const AGENT_EXECUTION_REASONS = new Set([ + "session_parent", + "session_root", + "configured_primary", + "configured_subagent", + "builtin_primary", + "builtin_subagent", + "builtin_auxiliary", + "conservative_fallback" +]) + +function isAgentReason(value: unknown): value is AgentExecutionReason { + return typeof value === "string" && AGENT_EXECUTION_REASONS.has(value as AgentExecutionReason) } function isEligibilityReason(value: unknown): value is UltraEligibilityReason { @@ -121,9 +161,13 @@ export function parseUltraState(value: string | null | undefined): UltraResoluti if (parsed.wireEffort !== ULTRA_WIRE_REASONING_EFFORT) return undefined if (typeof parsed.eligible !== "boolean") return undefined if (!isDelegationPolicy(parsed.delegationPolicy)) return undefined + if (!isAgentRole(parsed.agentRole) || !isAgentReason(parsed.agentReason)) return undefined if (!isEligibilityReason(parsed.reason)) return undefined if (parsed.eligible !== (parsed.reason === "eligible")) return undefined if (parsed.delegationPolicy === "proactive" && !parsed.eligible) return undefined + if (parsed.delegationPolicy === "proactive" && parsed.agentRole !== "root") return undefined + if (parsed.delegationPolicy === "disabled" && parsed.agentRole !== "auxiliary") return undefined + if (parsed.agentRole === "auxiliary" && parsed.delegationPolicy !== "disabled") return undefined const result: UltraResolution = { selected: true, @@ -131,12 +175,16 @@ export function parseUltraState(value: string | null | undefined): UltraResoluti wireEffort: ULTRA_WIRE_REASONING_EFFORT, eligible: parsed.eligible, delegationPolicy: parsed.delegationPolicy, + agentRole: parsed.agentRole, + agentReason: parsed.agentReason, reason: parsed.reason } const modelSlug = optionalStateString(parsed.modelSlug) const multiAgentVersion = optionalStateString(parsed.multiAgentVersion) + const agentName = optionalStateString(parsed.agentName) if (modelSlug) result.modelSlug = modelSlug if (multiAgentVersion) result.multiAgentVersion = multiAgentVersion + if (agentName) result.agentName = agentName return result } catch { return undefined diff --git a/test/agent-execution.test.ts b/test/agent-execution.test.ts new file mode 100644 index 0000000..c88c99f --- /dev/null +++ b/test/agent-execution.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from "vitest" + +import { CodexAuthPlugin } from "../lib/codex-native.js" +import { + classifyAgentExecutionFallback, + createAgentExecutionResolver, + deletedSessionIDFromEvent, + readAgentModes +} from "../lib/codex-native/agent-execution.js" + +describe("Ultra agent execution classification", () => { + it("classifies OpenCode built-ins without relying on Codex names", () => { + expect(classifyAgentExecutionFallback({ agentName: "build" }).role).toBe("root") + expect(classifyAgentExecutionFallback({ agentName: "general" }).role).toBe("child") + expect(classifyAgentExecutionFallback({ agentName: "explore" }).role).toBe("child") + expect(classifyAgentExecutionFallback({ agentName: "scout" }).role).toBe("child") + expect(classifyAgentExecutionFallback({ agentName: "title" }).role).toBe("auxiliary") + expect(classifyAgentExecutionFallback({ agentName: "summary" }).role).toBe("auxiliary") + expect(classifyAgentExecutionFallback({ agentName: "compaction" }).role).toBe("auxiliary") + }) + + it("reads custom primary and subagent modes from OpenCode config", () => { + const modes = readAgentModes({ + agent: { + captain: { mode: "primary" }, + reviewer: { mode: "subagent" }, + flexible: { mode: "all" } + } + } as never) + + expect(classifyAgentExecutionFallback({ agentName: "captain", configuredModes: modes }).role).toBe("root") + expect(classifyAgentExecutionFallback({ agentName: "reviewer", configuredModes: modes }).role).toBe("child") + expect(classifyAgentExecutionFallback({ agentName: "flexible", configuredModes: modes }).role).toBe("child") + }) + + it("uses session lineage to distinguish mode:all root and child invocations", async () => { + const get = vi + .fn() + .mockResolvedValueOnce({ data: { id: "root" } }) + .mockResolvedValueOnce({ data: { id: "child", parentID: "root" } }) + const resolver = createAgentExecutionResolver({ client: { session: { get } } }) + resolver.updateConfig({ agent: { flexible: { mode: "all" } } } as never) + + await expect(resolver.resolve({ sessionID: "root", agentName: "flexible" })).resolves.toMatchObject({ + role: "root", + reason: "session_root" + }) + await expect(resolver.resolve({ sessionID: "child", agentName: "flexible" })).resolves.toMatchObject({ + role: "child", + reason: "session_parent" + }) + }) + + it("caches lineage and invalidates it when a session is deleted", async () => { + const get = vi.fn(async () => ({ data: { id: "child", parentID: "root" } })) + const resolver = createAgentExecutionResolver({ client: { session: { get } } }) + + await resolver.resolve({ sessionID: "child", agentName: "custom" }) + await resolver.resolve({ sessionID: "child", agentName: "custom" }) + expect(get).toHaveBeenCalledOnce() + + resolver.deleteSession("child") + await resolver.resolve({ sessionID: "child", agentName: "custom" }) + expect(get).toHaveBeenCalledTimes(2) + }) + + it("shares concurrent lineage lookups for the same session", async () => { + let release: ((value: { data: { id: string } }) => void) | undefined + const get = vi.fn( + () => + new Promise<{ data: { id: string } }>((resolve) => { + release = resolve + }) + ) + const resolver = createAgentExecutionResolver({ client: { session: { get } } }) + + const first = resolver.resolve({ sessionID: "root", agentName: "custom" }) + const second = resolver.resolve({ sessionID: "root", agentName: "custom" }) + expect(get).toHaveBeenCalledOnce() + release?.({ data: { id: "root" } }) + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ role: "root", reason: "session_root" }), + expect.objectContaining({ role: "root", reason: "session_root" }) + ]) + }) + + it("does not restore deleted lineage from an in-flight lookup", async () => { + let release: ((value: { data: { id: string } }) => void) | undefined + const get = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ data: { id: string } }>((resolve) => { + release = resolve + }) + ) + .mockResolvedValue({ data: { id: "session", parentID: "parent" } }) + const resolver = createAgentExecutionResolver({ client: { session: { get } } }) + + const stale = resolver.resolve({ sessionID: "session", agentName: "custom" }) + resolver.deleteSession("session") + release?.({ data: { id: "session" } }) + await stale + + await expect(resolver.resolve({ sessionID: "session", agentName: "custom" })).resolves.toMatchObject({ + role: "child", + reason: "session_parent" + }) + expect(get).toHaveBeenCalledTimes(2) + }) + + it("fails closed to child policy when lineage lookup is unavailable", async () => { + const resolver = createAgentExecutionResolver({ + client: { session: { get: vi.fn(async () => ({ data: undefined, error: new Error("offline") })) } } + }) + + await expect(resolver.resolve({ sessionID: "unknown", agentName: "custom" })).resolves.toMatchObject({ + role: "child", + reason: "conservative_fallback" + }) + }) + + it("fails closed when session data is malformed or belongs to another session", async () => { + const get = vi + .fn() + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ data: { id: "different" } }) + const resolver = createAgentExecutionResolver({ client: { session: { get } } }) + + await expect(resolver.resolve({ sessionID: "missing-id", agentName: "custom" })).resolves.toMatchObject({ + role: "child", + reason: "conservative_fallback" + }) + await expect(resolver.resolve({ sessionID: "wrong-id", agentName: "custom" })).resolves.toMatchObject({ + role: "child", + reason: "conservative_fallback" + }) + }) + + it("extracts deleted session IDs from current and compatibility event shapes", () => { + expect(deletedSessionIDFromEvent({ type: "session.deleted", properties: { info: { id: "current" } } })).toBe( + "current" + ) + expect(deletedSessionIDFromEvent({ type: "session.deleted", properties: { id: "legacy" } })).toBe("legacy") + expect( + deletedSessionIDFromEvent({ type: "session.updated", properties: { info: { id: "ignored" } } }) + ).toBeUndefined() + }) + + it("accepts OpenCode session lifecycle events through the plugin hook", async () => { + const hooks = await CodexAuthPlugin({} as never) + expect(hooks.event).toBeTypeOf("function") + await hooks.event?.({ event: { type: "session.updated", properties: {} } as never }) + await hooks.event?.({ event: { type: "session.deleted", properties: { info: { id: "session-1" } } } as never }) + }) + + it("resolves lineage through the plugin chat hook for Ultra turns", async () => { + const get = vi.fn(async () => ({ data: { id: "root" } })) + const hooks = await CodexAuthPlugin({ client: { session: { get } } } as never, { mode: "codex" }) + const output = { temperature: 0, topP: 1, topK: 0, options: {} as Record } + + await hooks["chat.params"]?.( + { + sessionID: "root", + agent: "build", + provider: {}, + message: {}, + model: { + id: "gpt-5.6-sol", + providerID: "openai", + capabilities: { toolcall: true }, + options: { + codexCatalogModel: { + slug: "gpt-5.6-sol", + multi_agent_version: "v2", + supported_in_api: true, + visibility: "list", + supported_reasoning_levels: [{ effort: "ultra" }] + }, + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + } + } as never, + output as never + ) + + expect(get).toHaveBeenCalledWith({ path: { id: "root" } }) + expect(output.options.instructions).toContain("Proactive multi-agent delegation") + }) +}) diff --git a/test/ultra.test.ts b/test/ultra.test.ts index 5a096ae..2aa4157 100644 --- a/test/ultra.test.ts +++ b/test/ultra.test.ts @@ -30,6 +30,30 @@ function chatOutput(): { temperature: number; topP: number; topK: number; option } describe("GPT-5.6 Ultra contract", () => { + it("resolves session lineage only for Ultra requests", async () => { + let calls = 0 + const output = chatOutput() + output.options.reasoningEffort = "high" + await handleChatParamsHook({ + hookInput: { + model: { id: "gpt-5.6-sol", providerID: "openai", options: {} }, + agent: "build", + message: {} + }, + output, + lastCatalogModels: [eligibleModel()], + spoofMode: "codex", + collaborationProfileEnabled: false, + orchestratorSubagentsEnabled: false, + resolveAgentExecution: async () => { + calls += 1 + return { role: "root", reason: "session_root" } + } + }) + + expect(calls).toBe(0) + }) + it("requires Ultra, V2, visible status, and explicit API support", () => { expect(isUltraEligible(eligibleModel())).toBe(true) expect(isUltraEligible(eligibleModel({ multi_agent_version: "v1" }))).toBe(false) @@ -69,7 +93,8 @@ describe("GPT-5.6 Ultra contract", () => { lastCatalogModels: [eligibleModel()], spoofMode: "codex", collaborationProfileEnabled: false, - orchestratorSubagentsEnabled: false + orchestratorSubagentsEnabled: false, + agentExecution: { role: "root", reason: "session_root", agentName: "build" } }) expect(output.options.reasoningEffort).toBe("ultra") @@ -123,11 +148,42 @@ describe("GPT-5.6 Ultra contract", () => { lastCatalogModels: [eligibleModel()], spoofMode: "codex", collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true + orchestratorSubagentsEnabled: true, + agentExecution: { role: "child", reason: "session_parent", agentName: "general" } }) expect(childOutput.options.instructions).toContain(ULTRA_EXPLICIT_ONLY_INSTRUCTIONS) expect(childOutput.options.instructions).not.toContain(ULTRA_PROACTIVE_INSTRUCTIONS) expect(childResult.ultra?.delegationPolicy).toBe("explicit_request_only") + expect(childResult.ultra?.agentRole).toBe("child") + }) + + it("does not inject delegation policy into OpenCode auxiliary turns", async () => { + for (const agentName of ["title", "summary", "compaction"]) { + const output = chatOutput() + const result = await handleChatParamsHook({ + hookInput: { + model: { + id: "gpt-5.6-sol", + providerID: "openai", + options: { + codexCatalogModel: eligibleModel(), + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + }, + agent: agentName, + message: {} + }, + output, + lastCatalogModels: [eligibleModel()], + spoofMode: "codex", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true, + agentExecution: { role: "auxiliary", reason: "builtin_auxiliary", agentName } + }) + + expect(output.options.instructions).toBeUndefined() + expect(result.ultra).toMatchObject({ agentRole: "auxiliary", delegationPolicy: "disabled" }) + } }) it("normalizes logical Ultra to wire Max at the final request boundary", async () => { @@ -224,6 +280,30 @@ describe("GPT-5.6 Ultra contract", () => { expect(transformed.ultra?.delegationPolicy).toBe("explicit_request_only") }) + it("retains disabled auxiliary policy across retries", async () => { + const state = resolveUltraSelection({ + reasoningEffort: "ultra", + model: eligibleModel(), + agentExecution: { role: "auxiliary", reason: "builtin_auxiliary", agentName: "summary" } + }) + const request = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "max" } }) + }) + const transformed = await transformOutboundRequestPayload({ + request, + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + catalogModels: [eligibleModel()], + ultraState: state + }) + + expect(transformed.ultra).toMatchObject({ agentRole: "auxiliary", delegationPolicy: "disabled" }) + }) + it("degrades an Ultra selection without authoritative V2 metadata to wire Max only", async () => { const request = new Request("https://chatgpt.com/backend-api/codex/responses", { method: "POST", From a18059a6b0bbcc87ccddd56ed3c64dea079ee0b3 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 10 Jul 2026 22:00:18 -0400 Subject: [PATCH 5/6] feat: gate Ultra WIP and retire orchestrator prototype --- CHANGELOG.md | 6 +- docs/configuration.md | 39 +-- docs/development/ARCHITECTURE.md | 22 +- docs/development/CONFIG_FIELDS.md | 9 +- docs/development/OPENCODE_V1_17_18_SYNC.md | 2 +- docs/development/TESTING.md | 4 +- docs/development/ULTRA.md | 16 +- docs/development/UPSTREAM_SYNC.md | 2 +- docs/development/upstream-watch.json | 38 +- docs/examples/codex-config.jsonc | 6 +- docs/getting-started.md | 3 +- docs/index.md | 2 +- docs/privacy.md | 5 - index.ts | 31 +- lib/codex-cache-layout.ts | 11 - lib/codex-native.ts | 150 +++++--- lib/codex-native/agent-execution.ts | 17 +- lib/codex-native/chat-hooks.ts | 109 ++---- lib/codex-native/collaboration.ts | 251 -------------- lib/codex-native/instruction-utils.ts | 57 +++ lib/codex-native/openai-loader-fetch.ts | 48 +-- lib/codex-native/request-transform-payload.ts | 54 ++- lib/codex-native/ultra.ts | 23 ++ lib/codex-prompts-cache.ts | 240 ------------- lib/config.ts | 3 +- lib/config/file.ts | 15 +- lib/config/resolve.ts | 24 +- lib/config/types.ts | 17 +- lib/installer-cli.ts | 29 +- lib/legacy-orchestrator-cleanup.ts | 59 ++++ lib/model-catalog/provider.ts | 36 +- lib/model-catalog/shared.ts | 1 + lib/orchestrator-agent.ts | 327 ------------------ schemas/codex-config.schema.json | 11 +- scripts/coverage-ratchet.baseline.json | 38 +- scripts/perf-profile.ts | 2 +- test/agent-execution.test.ts | 10 +- test/codex-native-chat-hooks.test.ts | 12 +- ...codex-native-collaboration-runtime.test.ts | 319 ----------------- test/codex-native-collaboration.test.ts | 138 -------- test/codex-native-config-variants.test.ts | 7 +- .../codex-native-in-vivo-instructions.test.ts | 54 +-- test/codex-native-session-affinity.test.ts | 15 +- test/codex-native-snapshots.test.ts | 18 +- test/codex-prompts-cache.test.ts | 293 ---------------- test/config-file-loading.test.ts | 9 +- test/config-getters.test.ts | 26 +- test/config-loading-resolve.test.ts | 36 +- test/config-validation.test.ts | 6 +- test/helpers/codex-in-vivo.ts | 31 +- test/installer-cli.test.ts | 41 +-- test/instruction-utils.test.ts | 29 ++ test/legacy-orchestrator-cleanup.test.ts | 44 +++ test/model-catalog.provider-models.test.ts | 26 +- ...h.prompt-cache-key.catalog-refresh.test.ts | 5 - ...tch.prompt-cache-key.core-behavior.test.ts | 23 +- ...prompt-cache-key.header-forwarding.test.ts | 5 - ...prompt-cache-key.project-and-quota.test.ts | 4 - ...tch.prompt-cache-key.quota-retries.test.ts | 2 - ...penai-loader-fetch.shareable-debug.test.ts | 5 - test/orchestrator-agent.test.ts | 214 ------------ test/ultra.test.ts | 242 ++++++++++++- test/upstream-watch-config.test.ts | 7 +- 63 files changed, 903 insertions(+), 2425 deletions(-) delete mode 100644 lib/codex-native/collaboration.ts create mode 100644 lib/codex-native/instruction-utils.ts delete mode 100644 lib/codex-prompts-cache.ts create mode 100644 lib/legacy-orchestrator-cleanup.ts delete mode 100644 lib/orchestrator-agent.ts delete mode 100644 test/codex-native-collaboration-runtime.test.ts delete mode 100644 test/codex-native-collaboration.test.ts delete mode 100644 test/codex-prompts-cache.test.ts create mode 100644 test/instruction-utils.test.ts create mode 100644 test/legacy-orchestrator-cleanup.test.ts delete mode 100644 test/orchestrator-agent.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6704f..d412a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,8 @@ All notable changes to this project will be documented in this file. - Simplified request payload transform wrappers to route through one shared aggregate transform pipeline. - Consolidated account action messaging with shared builders and tightened auth-menu wording consistency. - Consolidated model-catalog stale-cache fallback emission flow and removed small dead helper modules. -- Added experimental Codex collaboration profile gates (`runtime.collaborationProfile`, `runtime.orchestratorSubagents`) for plan/orchestrator parity. -- Collaboration features now auto-enable by default in `runtime.mode="codex"` and can be explicitly enabled/disabled in any mode. -- Added managed `orchestrator` agent template sync under `~/.config/opencode/agents`, with visibility gated by effective collaboration profile (mode-derived by default, explicitly overridable). -- Synced pinned upstream Codex orchestrator + plan templates into a local prompt cache (ETag/304-aware, TTL refreshed) and used the cached plan prompt to populate plan-mode collaboration instructions. +- Retired the earlier collaboration-profile/orchestrator WIP, including managed prompt sync, collaboration headers, and generated `orchestrator.md` agents. +- Added the replacement GPT-5.6 Ultra agent mode as a WIP behind `runtime.ultra`, defaulting to `false` with provider variants and delegation policy hidden until explicitly enabled. - Added configurable `runtime.promptCacheKeyStrategy` (`default` | `project`) for session-based or project-path-based prompt cache keying. - Added quota threshold warnings at `25%`, `20%`, `10%`, `5%`, `2.5%`, `0%` and automatic cooldown/switch when `5h` or `weekly` quota is exhausted. - Added account selection tracing and per-attempt failover reason codes for snapshot/debug observability. diff --git a/docs/configuration.md b/docs/configuration.md index 471a30b..d732502 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,6 +62,7 @@ Known-field type validation is applied on load. If a known field has an invalid "headerSnapshots": false, "headerSnapshotBodies": false, "headerTransformDebug": false, + "ultra": false, "pidOffset": false }, "global": { @@ -77,8 +78,7 @@ Known-field type validation is applied on load. If a known field has an invalid Mode-derived runtime defaults when omitted: - `runtime.codexCompactionOverride`: `true` in `codex`, `false` in `native` -- `runtime.collaborationProfile`: `true` in `codex`, `false` in `native` -- `runtime.orchestratorSubagents`: inherits effective `runtime.collaborationProfile` +- `runtime.ultra`: `false` in every mode ## Settings reference @@ -134,14 +134,10 @@ Mode-derived runtime defaults when omitted: - Adds explicit `before-header-transform` and `after-header-transform` request snapshots for message fetches. - `runtime.pidOffset: boolean` - Enables session-aware offset behavior for account selection. -- `runtime.collaborationProfile: boolean` - - Experimental: enables Codex-style collaboration mode mapping from agent names (`plan` -> plan mode, `orchestrator` -> code mode profile). - - If omitted, defaults to `true` in `runtime.mode="codex"` and `false` otherwise. - - Explicit `true`/`false` works in any mode. -- `runtime.orchestratorSubagents: boolean` - - Experimental: enables Codex-style subagent header hints for helper agents under collaboration profile mode. - - If omitted, inherits `runtime.collaborationProfile` effective value. - - Explicit `true`/`false` works in any mode. +- `runtime.ultra: boolean` + - Work in progress. Enables the catalog-gated Ultra agent mode. + - Defaults to `false`; it must be explicitly enabled in any runtime mode. + - When disabled, the `ultra` picker variant is hidden and no delegation policy is injected. Stale literal `ultra` inputs still degrade safely to wire effort `max`. ### Model behavior @@ -159,10 +155,10 @@ Mode-derived runtime defaults when omitted: - When omitted, the selected model's live catalog `default_reasoning_level` is used, typically `"medium"`. - User config can still override reasoning effort globally, per model, or per variant. - `ultra` reasoning variant - - Catalog-derived and available only when the active model advertises `ultra` with `multi_agent_version: "v2"`. + - Work in progress and available only when `runtime.ultra=true` and the active model advertises `ultra` with `multi_agent_version: "v2"`. - `codex` mode adds best-effort proactive delegation guidance; `native` mode preserves OpenCode-native prompt identity. - Literal configured `ultra` values remain safe on unsupported or stale catalogs: the backend request sends wire effort `max`, without proactive delegation. - - No separate Ultra feature flag or concurrency setting is public; existing collaboration and subagent controls remain authoritative. + - There is no public concurrency setting; OpenCode remains responsible for agent execution and lifecycle. - `global.reasoningMode: "standard" | "pro"` (optional) - GPT-5.6 reasoning mode, emitted as `reasoning.mode` independently of `reasoning.effort`. - An explicit request value is preserved. The same per-model and per-variant precedence applies. @@ -336,8 +332,7 @@ Advanced path: - `OPENCODE_OPENAI_MULTI_HEADER_SNAPSHOTS`: `1|0|true|false`. - `OPENCODE_OPENAI_MULTI_HEADER_SNAPSHOT_BODIES`: `1|0|true|false`. - `OPENCODE_OPENAI_MULTI_HEADER_TRANSFORM_DEBUG`: `1|0|true|false`. -- `OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE`: `1|0|true|false`. -- `OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS`: `1|0|true|false`. +- `OPENCODE_OPENAI_MULTI_ULTRA`: `1|0|true|false` (WIP; defaults to false). ### Debug/OAuth controls @@ -367,18 +362,6 @@ Legacy behavior keys are no longer parsed from `codex-config.jsonc`. Use canonical `global` and `perModel` keys only. -## Managed prompts and orchestrator agent +## Legacy orchestrator cleanup -The plugin synchronizes a pinned upstream Codex orchestrator prompt and plan-mode prompt into a local cache under the resolved config cache root (`$XDG_CONFIG_HOME/opencode/cache/` when `XDG_CONFIG_HOME` is set, otherwise `~/.config/opencode/cache/`): - -- `codex-prompts-cache.json` -- `codex-prompts-cache-meta.json` (stores URLs, `lastChecked`, and ETags) - -Fetch behavior: - -- TTL-based refresh (best-effort; normal requests continue if refresh fails) -- ETag-based revalidation (`If-None-Match` + `304 Not Modified`) - -The plan prompt from this cache is used to populate plan-mode collaboration instructions. - -When `runtime.collaborationProfile` is enabled, the installer and plugin startup also manage the visibility of an `orchestrator.md` agent template under the resolved config root (`$XDG_CONFIG_HOME/opencode/agents/` when `XDG_CONFIG_HOME` is set, otherwise `~/.config/opencode/agents/`). +The removed orchestrator WIP no longer downloads prompts, injects collaboration headers, or manages an `orchestrator.md` agent. On startup and installer runs, the plugin removes its legacy prompt-cache files and removes legacy agent files only when they contain the plugin-managed orchestrator marker; user-authored files are preserved. diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 857960d..4430701 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -33,8 +33,10 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en - request/body transform ownership split by model defaults, service-tier resolution, payload rewrites, and chat hook behavior - `lib/codex-native/catalog-sync.ts` - model-catalog bootstrap, auth selection for bootstrap, and per-auth refresh wiring -- `lib/codex-native/collaboration.ts` - - plan-mode, orchestrator, and subagent collaboration instruction injection +- `lib/codex-native/instruction-utils.ts` + - runtime-safe instruction merging and Codex/OpenCode tool-name adaptation +- `lib/codex-native/ultra.ts`, `lib/codex-native/agent-execution.ts` + - WIP Ultra policy, catalog eligibility, and fail-closed root/child/auxiliary classification - `lib/codex-native/originator.ts` - originator header resolution (mode-aware `opencode` vs `codex_cli_rs`/`codex_exec`) - `lib/codex-native/browser.ts` @@ -71,10 +73,8 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en - tool handler logic for `codex-status`, `codex-switch-accounts`, `codex-toggle-account`, `codex-remove-account` - `lib/codex-status-tool.ts`, `lib/codex-status-storage.ts`, `lib/codex-status-ui.ts` - account status/usage tracking, persistence, and display formatting -- `lib/codex-prompts-cache.ts` - - pinned upstream prompt fetch/sync (orchestrator + plan templates) with ETag/TTL refresh -- `lib/orchestrator-agent.ts` - - managed `orchestrator.md` agent template sync and visibility gating +- `lib/legacy-orchestrator-cleanup.ts` + - removal of prompt caches and plugin-managed agent files from the retired orchestrator WIP while preserving user-authored agents - `lib/quarantine.ts` - corrupted auth file detection and recovery - `lib/quota-threshold-alerts.ts` @@ -107,16 +107,6 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en - plugin-primary account-scoped server catalog cache - Existing instruction caches (for example `codex-instructions.md` + `codex-instructions-meta.json`) remain separate artifacts under the same cache root. -## Cache files (pinned prompt sync) - -- `/cache/codex-prompts-cache.json` - - pinned upstream prompt text for: - - Codex orchestrator agent template - - Codex plan-mode collaboration prompt -- `/cache/codex-prompts-cache-meta.json` - - prompt-cache metadata (`lastChecked`, URLs, ETags) - -Fetch behavior is best-effort and uses ETag/304 revalidation plus a TTL to limit network traffic. Successful live catalog fetches are source-faithful: the account-scoped `/backend-api/codex/models` payload is cached and handed to provider shaping without field-level merging against the GitHub fallback snapshot. The shared GitHub cache is used only when live catalog data is unavailable. ## Invariants diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index c547a47..e82c209 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -30,8 +30,7 @@ Top-level: - Sensitive headers/tokens and selected metadata/query keys are redacted, but prompt/tool payload content may still be present. - `runtime.headerTransformDebug: boolean` - `runtime.pidOffset: boolean` -- `runtime.collaborationProfile: boolean` -- `runtime.orchestratorSubagents: boolean` +- `runtime.ultra: boolean` (WIP, default `false`) - `global.personality: string` - `global.reasoningEffort: string` (optional) - When omitted, the selected model's live catalog `default_reasoning_level` is used, typically `"medium"`. @@ -108,8 +107,7 @@ Default generated values: - `runtime.headerSnapshotBodies: false` - `runtime.headerTransformDebug: false` - `runtime.pidOffset: false` -- `runtime.collaborationProfile`: mode-derived when unset (`true` in `codex`, `false` in `native`) -- `runtime.orchestratorSubagents`: inherits `runtime.collaborationProfile` effective value when unset +- `runtime.ultra: false` - `global.personality: "pragmatic"` - `global.reasoningSummary: "auto"` - `global.textVerbosity: "default"` @@ -158,8 +156,7 @@ Resolved by `resolveConfig`: - `OPENCODE_OPENAI_MULTI_SERVICE_TIER` - `OPENCODE_OPENAI_MULTI_PROACTIVE_REFRESH` - `OPENCODE_OPENAI_MULTI_PROACTIVE_REFRESH_BUFFER_MS` -- `OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE` -- `OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS` +- `OPENCODE_OPENAI_MULTI_ULTRA` Resolved by auth/runtime code (`lib/codex-native.ts` + helper modules under `lib/codex-native/`): diff --git a/docs/development/OPENCODE_V1_17_18_SYNC.md b/docs/development/OPENCODE_V1_17_18_SYNC.md index 199bcbf..bc1232a 100644 --- a/docs/development/OPENCODE_V1_17_18_SYNC.md +++ b/docs/development/OPENCODE_V1_17_18_SYNC.md @@ -10,7 +10,7 @@ Most upstream churn is architectural, but five behavior changes matter here: 2. **OAuth model selection became catalog-driven:** the plugin no longer invents a hard-coded GPT-5.3 model. It filters the provider catalog through the `provider.models` hook, zeroes subscription costs, corrects GPT-5.5 limits, explicitly hides `gpt-5.5-pro`, and admits future `gpt-X.Y` models when the version is greater than 5.4. [Removal of synthesized model](https://github.com/anomalyco/opencode/commit/2929774acb2eb694800bccfc6a9f84ec691eb999), [models-hook migration](https://github.com/anomalyco/opencode/commit/b80f52f8ad3173acee143e1355a2ab4585443db1), and [current filter](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/codex.ts#L279-L309). 3. **Concurrent refresh is deduplicated:** one in-flight refresh promise is shared by requests using the native OpenCode credential. This repo has richer per-account acquisition/rotation, so it must preserve deduplication per strict account identity rather than adopt upstream's single global promise literally. [Fix](https://github.com/anomalyco/opencode/commit/c64ac905e19cd881e4d3c8af6449f228941a2674) and [tagged code](https://github.com/anomalyco/opencode/blob/v1.17.18/packages/opencode/src/plugin/openai/codex.ts#L327-L396). 4. **OpenAI Responses WebSockets were added as an experimental transport.** This caused the file move and adds pooling, lifecycle cleanup, HTTP fallback for title requests, custom base-URL support, and stream retry/idle fixes. It is optional for this plugin unless it intentionally exposes the transport; HTTP remains supported. [Transport commit and rename](https://github.com/anomalyco/opencode/commit/62da1e76826276b493ce7f8a9581d482cd7c16ee), [custom URL fix](https://github.com/anomalyco/opencode/commit/ec26d7845005d5db3166b9d181f802b04e99d864), [stream retry fix](https://github.com/anomalyco/opencode/commit/14e0b9b17f886c9157c92e1b98caca5a40d21797), and [idle-state fix](https://github.com/anomalyco/opencode/commit/7f8412ec3e8b964ae3794e5c38e67dbe100c4cc7). -5. **OpenCode does not implement a special GPT-5.6 Ultra wire effort.** At v1.17.18 it accepts future post-5.4 model IDs through the dynamic filter, while reasoning variants come from provider/catalog capabilities. Therefore this repo's logical `ultra` → wire `max` policy remains a Codex-runtime extension, not an OpenCode-native parity item; keep it catalog-gated and keep native mode free of the Codex collaboration overlay. +5. **OpenCode does not implement a special GPT-5.6 Ultra wire effort.** At v1.17.18 it accepts future post-5.4 model IDs through the dynamic filter, while reasoning variants come from provider/catalog capabilities. Therefore this repo's logical `ultra` → wire `max` policy remains a default-off WIP extension, not an OpenCode-native parity item; keep it behind `runtime.ultra`, catalog-gated, and keep native mode free of the Ultra delegation overlay. The current [OpenCode plugin documentation](https://opencode.ai/docs/plugins/) confirms sequential hook execution and lifecycle event delivery, including `session.deleted`. The v1.17.18 package contract additionally exposes `dispose`, plugin option tuples, and a v1 default module object. This plugin now composes `dispose` to stop its proactive-refresh scheduler. It intentionally keeps the function export for older-loader compatibility and keeps runtime settings in `codex-config.jsonc`; plugin option tuples are therefore not used as a second configuration surface. diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index e14504b..82fcb36 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -76,12 +76,12 @@ npx vitest run test/config-loading-resolve.test.ts npx vitest run test/config-validation.test.ts npx vitest run test/config-getters.test.ts npx vitest run test/installer-cli.test.ts -npx vitest run test/codex-prompts-cache.test.ts +npx vitest run test/legacy-orchestrator-cleanup.test.ts npx vitest run test/remote-cache-fetch.test.ts npx vitest run test/cache-io.test.ts npx vitest run test/codex-native-oauth-callback-flow.test.ts npx vitest run test/acquire-auth-locking.test.ts -npx vitest run test/codex-native-collaboration.test.ts +npx vitest run test/ultra.test.ts npx vitest run test/prompt-cache-key.test.ts npx vitest run test/codex-native-oauth-debug-gating.test.ts npx vitest run test/request-snapshots.test.ts diff --git a/docs/development/ULTRA.md b/docs/development/ULTRA.md index e32445e..0021eb3 100644 --- a/docs/development/ULTRA.md +++ b/docs/development/ULTRA.md @@ -1,22 +1,23 @@ -# GPT-5.6 Ultra +# GPT-5.6 Ultra (WIP) -This plugin treats Ultra as a logical model variant, not as a new inference effort. +Ultra is a work-in-progress feature behind `runtime.ultra`, which defaults to `false`. The plugin treats enabled Ultra as a logical model variant, not as a new inference effort. | State | Contract | | --- | --- | -| Catalog and picker | `ultra` remains distinct from `max` and is exposed only when the selected catalog model advertises the `ultra` effort, `multi_agent_version: "v2"`, visible status, and API support. | +| Feature gate | `runtime.ultra` must be explicitly set to `true`. When disabled, Ultra is absent from provider/config variants and no agent policy is injected. | +| Catalog and picker | With the gate enabled, `ultra` remains distinct from `max` and is exposed only when the selected catalog model advertises the `ultra` effort, `multi_agent_version: "v2"`, visible status, and API support. | | Root turn | An eligible Ultra turn in `codex` mode receives proactive delegation instructions: parallelize independent sidecar work, assign explicit ownership, avoid duplicate work, wait for required children, verify results, and synthesize them. Native mode preserves the OpenCode-native identity and does not add a Codex delegation overlay. | | Child turn | A child inherits maximum reasoning but receives explicit-request-only delegation guidance to avoid uncontrolled recursive fan-out and duplicate parent or sibling work. | | Auxiliary turn | OpenCode title, summary, and compaction turns retain wire normalization but receive no delegation instructions. | | Backend request | Every literal `reasoning.effort: "ultra"` is normalized to `"max"` at the last-mile request transform. Explicit `max` never receives Ultra policy. | | Missing or stale metadata | Ultra is disabled when catalog metadata cannot prove eligibility. A manually configured literal `ultra` is safe-degraded to wire `max` without proactive instructions. | -| Failure | Missing task tools, disabled collaboration, spawn failure, cancellation, or partial completion do not fail the root turn. The agent continues locally and must not claim delegation that did not happen. | +| Failure | Missing task tools, spawn failure, cancellation, or partial completion do not fail the root turn. The agent continues locally and must not claim delegation that did not happen. | The live account-scoped catalog is authoritative. GitHub fallback data is parsed through the same schema and remains usable for ordinary model defaults when the live source is unavailable, but it cannot prove Ultra eligibility. The plugin does not recreate account entitlement or minimum-client enforcement from catalog metadata. ## State lifecycle -1. `config` records each custom agent's OpenCode mode. `chat.params` resolves session lineage through OpenCode's session API and classifies the execution as root, child, or auxiliary. Session lineage is authoritative for `mode: all`; built-in and configured modes provide a fail-closed fallback. +1. When `runtime.ultra=true`, `config` records each custom agent's OpenCode mode. `chat.params` resolves session lineage through OpenCode's session API and classifies the execution as root, child, or auxiliary. Session lineage is authoritative for `mode: all`; built-in and configured modes provide a fail-closed fallback. 2. The logical state is retained as `ultra`; eligible `codex`-mode root turns merge the proactive instruction idempotently, while `codex`-mode child turns merge the explicit-only instruction. Native mode keeps the logical state without prompt adaptation. 3. `chat.headers` records a redacted internal Ultra state marker alongside the existing catalog scope and selected-model markers. 4. Each retry resolves the current catalog scope again and applies the same last-mile normalization. Request snapshots include logical effort, wire effort, eligibility, policy, and the reason for any degradation. @@ -26,13 +27,14 @@ The live account-scoped catalog is authoritative. GitHub fallback data is parsed Ultra supplies the complete agent-mode policy available at the OpenCode plugin boundary. OpenCode remains the execution host for task tools, concurrency, steering, cancellation, and child lifecycle. A missing task tool or failed child spawn is observable in the host's normal tool/error path, but it is not a reason to reject the root request. Unknown agents and failed lineage lookups fail closed to child policy rather than enabling recursive fan-out. -No new public concurrency or feature flag is required. Existing collaboration-profile and subagent controls remain authoritative, and no private catalog/runtime default is added to public configuration. +No public concurrency setting is exposed. `runtime.ultra` is the only feature gate, and OpenCode remains authoritative for task tools and child lifecycle. The retired collaboration-profile/orchestrator WIP is not part of this flow. ## Verification matrix The minimum release evidence covers: - parser retention for effort descriptions, `multi_agent_version`, `minimal_client_version`, visibility, and API support; +- default-off config, schema, environment override, provider picker hiding, and explicit opt-in behavior; - eligible Sol/Terra variants, ineligible V1/hidden/non-API variants, fallback catalogs, custom aliases, and effort suffixes; - session-lineage classification for root, child, custom `mode: all`, built-in agents, and fail-closed lookup errors; - root proactive, child explicit-only, and auxiliary-disabled instruction composition, including idempotent merges and preserved user/orchestrator instructions; @@ -43,6 +45,6 @@ The minimum release evidence covers: ## Rollout and rollback -Ultra follows the existing catalog-driven release path. It is visible only when authoritative metadata proves eligibility; there is no launch-time allowlist for Sol or Terra and no package release in this change. Before publication, run the full verification gate and a manual smoke using an eligible catalog response. +Ultra follows the existing catalog-driven release path but remains marked WIP and default-off. It is visible only when the flag is enabled and authoritative metadata proves eligibility; there is no launch-time allowlist for Sol or Terra. Before publication or enabling it by default, run the full verification gate and a manual smoke using an eligible catalog response. Rollback is the smallest code/config rollback that removes the Ultra instruction and variant eligibility predicate while leaving account storage and catalog caches intact. Existing literal `reasoningEffort: "ultra"` values remain safe because the request transform continues to send wire `max`. Upstream changes are tracked through `docs/development/UPSTREAM_SYNC.md` and the repository's upstream-watch configuration; a changed Ultra contract requires a new compatibility decision before behavior is broadened. diff --git a/docs/development/UPSTREAM_SYNC.md b/docs/development/UPSTREAM_SYNC.md index 5c4dc90..673d1a6 100644 --- a/docs/development/UPSTREAM_SYNC.md +++ b/docs/development/UPSTREAM_SYNC.md @@ -59,7 +59,7 @@ Tracked upstream surfaces include: - Provider core: `packages/opencode/src/provider/provider.ts`, `packages/opencode/src/provider/auth.ts` - Provider transforms/schema/error handling: `packages/opencode/src/provider/transform.ts`, `packages/core/src/models-dev.ts`, `packages/opencode/src/provider/error.ts` - Session-side OpenAI stream error handling: `packages/opencode/src/session/message-v2.ts` -- Codex upstream model/auth/runtime files: `codex-rs/models-manager/models.json`, `codex-rs/core/src/auth.rs`, `codex-rs/core/src/client.rs`, `codex-rs/core/src/codex.rs`, `codex-rs/core/src/compact.rs` +- Codex upstream model/auth/runtime files: `codex-rs/models-manager/models.json`, `codex-rs/login/src/auth/manager.rs`, `codex-rs/login/src/server.rs`, `codex-rs/core/src/client.rs`, `codex-rs/core/src/session/multi_agents.rs`, `codex-rs/core/src/codex_thread.rs`, `codex-rs/core/src/codex_delegate.rs`, `codex-rs/core/src/compact.rs` All automated upstream checks fetch directly from GitHub release tags (`api.github.com` and `raw.githubusercontent.com`). No local upstream clones are required for drift detection. diff --git a/docs/development/upstream-watch.json b/docs/development/upstream-watch.json index e2e3e0e..d039aca 100644 --- a/docs/development/upstream-watch.json +++ b/docs/development/upstream-watch.json @@ -72,7 +72,7 @@ "id": "codex-rs", "repo": "openai/codex", "baselineTag": "rust-v0.144.1", - "updatedAt": "2026-07-10T21:25:00.000Z", + "updatedAt": "2026-07-11T00:50:04.000Z", "files": [ { "path": "codex-rs/models-manager/models.json", @@ -81,26 +81,44 @@ "reason": "Model catalog defaults and capabilities parity" }, { - "path": "codex-rs/core/src/auth.rs", - "sha256": "4778717d5c53fc65db59e88ae45ba40d1d64478cd3e4fd5bfa2781028916bcd0", + "path": "codex-rs/login/src/auth/manager.rs", + "sha256": "699404f55e8dc3bb2b1cd6329384db48f4c06b4ba99b2ce773f28657eca25265", "localArea": "lib/codex-native/oauth-*.ts", - "reason": "OAuth flow constants and auth semantics parity" + "reason": "OAuth token refresh, account identity, and auth-manager semantics parity" + }, + { + "path": "codex-rs/login/src/server.rs", + "sha256": "4a90bc0658a7f80d5836d4755d533872b2fe21b8a4fce3d44e909a4daf12d241", + "localArea": "lib/codex-native/oauth-*.ts", + "reason": "OAuth authorize, callback, PKCE, and token-exchange parity" }, { "path": "codex-rs/core/src/client.rs", - "sha256": "928da12079d3c0aa3200212ea33be6f950230bab38326eb15f7cc290499d03d1", + "sha256": "e3788e78e4c702d31ebb31b1e96ce5dfec5eb19d96920e8bba58f64073cdd43e", "localArea": "lib/codex-native/chat-hooks.ts", "reason": "Request header/user-agent and endpoint behavior parity" }, { - "path": "codex-rs/core/src/codex.rs", - "sha256": "00b1bf975735d776fbd5b684e8271108cd78df38130add972ae7d50f3901ca62", - "localArea": "lib/codex-native/openai-loader-fetch.ts", - "reason": "Core Codex request/runtime behavior parity" + "path": "codex-rs/core/src/session/multi_agents.rs", + "sha256": "a5106a3cf6c6e03000f76fab9e267e9c50b1aaeac08c06b676e73fbe991a4a93", + "localArea": "lib/codex-native/ultra.ts", + "reason": "Ultra root, child, auxiliary, and multi-agent V2 policy parity" + }, + { + "path": "codex-rs/core/src/codex_thread.rs", + "sha256": "1e3d528db1bd2cf1f3f5ac29a7ece5adf19b5c3f5d57377d7b191df852e2ee1f", + "localArea": "lib/codex-native/agent-execution.ts", + "reason": "Thread lineage and turn lifecycle parity" + }, + { + "path": "codex-rs/core/src/codex_delegate.rs", + "sha256": "ab83c10d56ae78d59d029afc008b6f835a9bd676461b7920df1d940ab806bf53", + "localArea": "lib/codex-native/ultra.ts", + "reason": "Delegated child lifecycle and recursion-boundary parity" }, { "path": "codex-rs/core/src/compact.rs", - "sha256": "bb4d0787c7a841b4c99e0deed9256dd1f92e7de5db0b383e8fa14b4a91c92dc1", + "sha256": "232caf15a419a4dfd5815dee7bcc27b0f1f2dea5bb845fcceaea59492ff4e2fd", "localArea": "lib/codex-native.ts", "reason": "Compaction prompt and handoff semantics parity" } diff --git a/docs/examples/codex-config.jsonc b/docs/examples/codex-config.jsonc index 6bee653..f5b1d4e 100644 --- a/docs/examples/codex-config.jsonc +++ b/docs/examples/codex-config.jsonc @@ -35,9 +35,9 @@ // Optional codex-rs compaction/profile override. // "codexCompactionOverride": true, - // Optional collaboration controls. - // "collaborationProfile": true, - // "orchestratorSubagents": true, + // Work in progress: catalog-gated Ultra agent mode. + // Defaults to false. + "ultra": false, // Debug request snapshots. "headerSnapshots": false, diff --git a/docs/getting-started.md b/docs/getting-started.md index 2afe074..2e833d7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -155,8 +155,7 @@ Managed templates are synchronized at plugin startup: - `/create-personality` command is refreshed to the managed latest template - `personality-builder` skill bundle is refreshed to the managed latest template -- pinned Codex prompts cache is refreshed best-effort (`codex-prompts-cache*.json`) -- orchestrator agent visibility is reconciled based on effective collaboration profile +- legacy orchestrator prompt caches and plugin-managed agent files are removed; user-authored agents are preserved ## Local development install diff --git a/docs/index.md b/docs/index.md index 0058d1e..55813c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ Use this page as the fast entrypoint for humans and agents. - `docs/development/CONFIG_FLOW.md` - `docs/development/TESTING.md` - `docs/development/UPSTREAM_SYNC.md` -- `docs/development/ULTRA.md` +- `docs/development/ULTRA.md` (WIP, default-off) - `docs/DOCUMENTATION.md` ## Examples diff --git a/docs/privacy.md b/docs/privacy.md index b819bce..d4e92e7 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -32,10 +32,6 @@ - account-scoped server model catalog mirror - `/cache/codex-auth-models-.json` - plugin-primary account-scoped model catalog cache -- `/cache/codex-prompts-cache.json` - - pinned upstream orchestrator/plan prompt cache -- `/cache/codex-prompts-cache-meta.json` - - pinned prompt cache metadata (`lastChecked`, URLs, ETags) - `/logs/codex-plugin/` (optional) - request/response snapshot logs when enabled - `/logs/codex-plugin/shareable-debug.jsonl` (optional) @@ -61,7 +57,6 @@ Recommended additional local ignore patterns (not auto-managed by plugin): - `cache/codex-client-version.json` - `cache/codex-models-cache*.json` - `cache/codex-auth-models-*.json` -- `cache/codex-prompts-cache*.json` - `logs/codex-plugin/oauth-lifecycle.log*` ## Related compatibility caches diff --git a/index.ts b/index.ts index 407e795..f115db0 100644 --- a/index.ts +++ b/index.ts @@ -15,13 +15,11 @@ import { getCodexCompactionOverrideEnabled, getBehaviorSettings, getCustomModels, - getCollaborationProfileEnabled, getDebugEnabled, getHeaderSnapshotBodiesEnabled, getHeaderTransformDebugEnabled, getHeaderSnapshotsEnabled, getShareableDebugEnabled, - getOrchestratorSubagentsEnabled, getMode, getModelAliasSettings, getRemapDeveloperMessagesToUserEnabled, @@ -32,6 +30,7 @@ import { getProactiveRefreshBufferMs, getProactiveRefreshEnabled, getSpoofMode, + getUltraEnabled, getQuietMode, loadConfigFile, resolveConfig @@ -41,13 +40,11 @@ import { generatePersonaSpec } from "./lib/persona-tool.js" import { createPersonalityFile } from "./lib/personality-create.js" import { installCreatePersonalityCommand } from "./lib/personality-command.js" import { installPersonalityBuilderSkill } from "./lib/personality-skill.js" -import { reconcileOrchestratorAgentVisibility } from "./lib/orchestrator-agent.js" import { runOneProactiveRefreshTick } from "./lib/proactive-refresh.js" import { createRefreshScheduler, ProactiveRefreshQueue } from "./lib/refresh-queue.js" import { toolOutputForStatus } from "./lib/codex-status-tool.js" import { requireOpenAIMultiOauthAuth, saveAuthStorage } from "./lib/storage.js" -import { refreshCachedCodexPrompts } from "./lib/codex-prompts-cache.js" -import { setCodexPlanModeInstructions } from "./lib/codex-native/collaboration.js" +import { removeLegacyOrchestratorArtifacts } from "./lib/legacy-orchestrator-cleanup.js" import { composePluginDispose } from "./lib/plugin-lifecycle.js" let scheduler: { stop: () => void } | undefined @@ -74,30 +71,19 @@ export const OpenAIMultiAuthPlugin: Plugin = async (input) => { } }) - await refreshCachedCodexPrompts() - .then((prompts) => { - setCodexPlanModeInstructions(prompts.plan) - }) - .catch((error) => { - if (error instanceof Error) { - console.warn(`[opencode-codex-auth] bootstrap: refreshCachedCodexPrompts failed: ${error.message}`) - } - }) + await removeLegacyOrchestratorArtifacts().catch((error) => { + if (error instanceof Error) { + console.warn(`[opencode-codex-auth] bootstrap: legacy orchestrator cleanup failed: ${error.message}`) + } + }) const cfg = resolveConfig({ env: process.env, file: loadConfigFile({ env: process.env }) }) const runtimeMode = getMode(cfg) - const collaborationProfileEnabled = getCollaborationProfileEnabled(cfg) const log = createLogger({ debug: getDebugEnabled(cfg) }) - await reconcileOrchestratorAgentVisibility({ visible: collaborationProfileEnabled }).catch((error) => { - if (error instanceof Error) { - console.warn(`[opencode-codex-auth] bootstrap: reconcileOrchestratorAgentVisibility failed: ${error.message}`) - } - }) - if (getProactiveRefreshEnabled(cfg)) { const bufferMs = getProactiveRefreshBufferMs(cfg) const intervalMs = 60_000 @@ -166,8 +152,7 @@ export const OpenAIMultiAuthPlugin: Plugin = async (input) => { headerSnapshots: getHeaderSnapshotsEnabled(cfg), headerSnapshotBodies: getHeaderSnapshotBodiesEnabled(cfg), headerTransformDebug: getHeaderTransformDebugEnabled(cfg), - collaborationProfileEnabled, - orchestratorSubagentsEnabled: getOrchestratorSubagentsEnabled(cfg), + ultraEnabled: getUltraEnabled(cfg), behaviorSettings: getBehaviorSettings(cfg), customModels: getCustomModels(cfg), modelAliases: getModelAliasSettings(cfg) diff --git a/lib/codex-cache-layout.ts b/lib/codex-cache-layout.ts index 0fc2d37..77dcf89 100644 --- a/lib/codex-cache-layout.ts +++ b/lib/codex-cache-layout.ts @@ -3,9 +3,6 @@ import path from "node:path" import { defaultOpencodeCachePath } from "./paths.js" -export const CODEX_PROMPTS_CACHE_FILE = "codex-prompts-cache.json" -export const CODEX_PROMPTS_CACHE_META_FILE = "codex-prompts-cache-meta.json" - export const OPENCODE_MODELS_CACHE_PREFIX = "codex-models-cache" export const CODEX_AUTH_MODELS_CACHE_PREFIX = "codex-auth-models-" export const OPENCODE_MODELS_META_FILE = "codex-models-cache-meta.json" @@ -53,11 +50,3 @@ export function isCodexModelsCacheFileName(fileName: string): boolean { if (!fileName.endsWith(".json")) return false return fileName.startsWith(OPENCODE_MODELS_CACHE_PREFIX) || fileName.startsWith(CODEX_AUTH_MODELS_CACHE_PREFIX) } - -export function codexPromptsCachePath(cacheDir: string): string { - return path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE) -} - -export function codexPromptsCacheMetaPath(cacheDir: string): string { - return path.join(cacheDir, CODEX_PROMPTS_CACHE_META_FILE) -} diff --git a/lib/codex-native.ts b/lib/codex-native.ts index 29ca3d8..d6cc8e4 100644 --- a/lib/codex-native.ts +++ b/lib/codex-native.ts @@ -1,4 +1,4 @@ -import type { Config, Hooks, PluginInput } from "@opencode-ai/plugin" +import type { Hooks, PluginInput } from "@opencode-ai/plugin" import process from "node:process" import { loadAuthStorage, setAccountCooldown } from "./storage.js" @@ -70,8 +70,6 @@ export { browserOpenInvocationFor } from "./codex-native/browser.js" export { upsertAccount } from "./codex-native/accounts.js" export { extractAccountId, extractAccountIdFromClaims, refreshAccessToken } from "./codex-native/oauth-utils.js" -const INTERNAL_COLLABORATION_MODE_HEADER = "x-opencode-collaboration-mode-kind" -const INTERNAL_COLLABORATION_AGENT_HEADER = "x-opencode-collaboration-agent-kind" const INTERNAL_CATALOG_SCOPE_HEADER = "x-opencode-catalog-scope-key" const INTERNAL_CATALOG_DEFAULTS_HEADER = "x-opencode-catalog-default-fields" const INTERNAL_SELECTED_MODEL_HEADER = "x-opencode-selected-model-slug" @@ -176,11 +174,12 @@ export type CodexAuthPluginOptions = { headerSnapshots?: boolean headerSnapshotBodies?: boolean headerTransformDebug?: boolean - collaborationProfileEnabled?: boolean - orchestratorSubagentsEnabled?: boolean + ultraEnabled?: boolean } -type ConfigWithProviderVariants = Config & { +type OpenCodeConfig = Parameters>[0] + +type ConfigWithProviderVariants = OpenCodeConfig & { provider?: Record< string, { @@ -216,14 +215,17 @@ function getSupportedReasoningEfforts(model: CodexModelInfo): string[] { ) } -function buildVariantConfigOverrides(model: CodexModelInfo): Record> | undefined { +function buildVariantConfigOverrides( + model: CodexModelInfo, + ultraEnabled: boolean +): Record> | undefined { const supportedEfforts = getSupportedReasoningEfforts(model) if (supportedEfforts.length === 0) return undefined const variants = new Set([...REASONING_VARIANT_KEYS, ...supportedEfforts]) return Object.fromEntries( Array.from(variants).map((variant) => { - if (!supportedEfforts.includes(variant) || (variant === "ultra" && !isUltraEligible(model))) { + if (!supportedEfforts.includes(variant) || (variant === "ultra" && (!ultraEnabled || !isUltraEligible(model)))) { return [variant, { disabled: true }] } return [ @@ -238,7 +240,11 @@ function buildVariantConfigOverrides(model: CodexModelInfo): Record>): void { + for (const model of Object.values(providerModels)) { + if (!model.variants || typeof model.variants !== "object" || Array.isArray(model.variants)) continue + const variants = model.variants as Record + delete variants.ultra + if (Object.keys(variants).length === 0) delete model.variants + } +} + function applyGeneratedModelAliasesToConfig( - config: Config, + config: OpenCodeConfig, catalogModels: CodexModelInfo[] | undefined, settings: { fast: boolean; extendedContext: boolean; pro: boolean } ): void { @@ -274,7 +298,7 @@ function applyGeneratedModelAliasesToConfig( } function applyCustomModelsToConfig( - config: Config, + config: OpenCodeConfig, customModels: Record | undefined, warn?: (message: string) => void ): void { @@ -354,12 +378,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO const remapDeveloperMessagesToUserEnabled = runtimeMode === "codex" && opts.remapDeveloperMessagesToUser !== false const codexCompactionOverrideEnabled = opts.codexCompactionOverride !== undefined ? opts.codexCompactionOverride : runtimeMode === "codex" - const collaborationProfileEnabled = - typeof opts.collaborationProfileEnabled === "boolean" ? opts.collaborationProfileEnabled : runtimeMode === "codex" - const orchestratorSubagentsEnabled = - typeof opts.orchestratorSubagentsEnabled === "boolean" - ? opts.orchestratorSubagentsEnabled - : collaborationProfileEnabled + const ultraEnabled = opts.ultraEnabled === true void refreshCodexClientVersionFromGitHub(opts.log).catch((error) => { if (error instanceof Error) { // best-effort background refresh @@ -399,14 +418,35 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO log: opts.log }) const catalogModelsByScope = new Map() + type CatalogRequestMetadata = { + catalogScopeKey?: string + injectedCatalogDefaultFields: string[] + ultra?: UltraResolution + } const catalogRequestMetadataBySession = new Map< string, - Array<{ - catalogScopeKey?: string - injectedCatalogDefaultFields: string[] - ultra?: UltraResolution - }> + { + byMessageID: Map + unkeyed: CatalogRequestMetadata[] + } >() + const requestMessageID = (hookInput: { message?: unknown }): string | undefined => { + if (!hookInput.message || typeof hookInput.message !== "object") return undefined + const id = (hookInput.message as { id?: unknown }).id + return typeof id === "string" && id.trim() ? id.trim() : undefined + } + const deleteCatalogRequestMetadata = (sessionID: string, messageID?: string): void => { + const metadata = catalogRequestMetadataBySession.get(sessionID) + if (!metadata) return + if (messageID) { + metadata.byMessageID.delete(messageID) + } else { + metadata.unkeyed.length = 0 + } + if (metadata.byMessageID.size === 0 && metadata.unkeyed.length === 0) { + catalogRequestMetadataBySession.delete(sessionID) + } + } let activeCatalogScopeKey: string | undefined let activeCatalogModels: CodexModelInfo[] | undefined let providerModelsForCatalogSync: Record> | undefined @@ -429,7 +469,8 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO projectRoot: typeof input.worktree === "string" && input.worktree.trim() ? input.worktree : process.cwd(), customModels: opts.customModels, warn: (message) => console.warn(message), - aliasSettings: aliasSettingsFor("oauth") + aliasSettings: aliasSettingsFor("oauth"), + ultraEnabled }) } const setCatalogModels = (scopeKey: string | undefined, models: CodexModelInfo[] | undefined): void => { @@ -451,7 +492,8 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO projectRoot: typeof input.worktree === "string" && input.worktree.trim() ? input.worktree : process.cwd(), customModels: opts.customModels, warn: (message) => console.warn(message), - aliasSettings: aliasSettingsFor("oauth") + aliasSettings: aliasSettingsFor("oauth"), + ultraEnabled }) } const getCatalogModels = (scopeKey?: string): CodexModelInfo[] | undefined => { @@ -507,6 +549,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO }, async config(config) { agentExecutionResolver.updateConfig(config) + if (!ultraEnabled) hideUltraVariantsInConfig(config) try { const catalogAuth = await selectCatalogAuthCandidate( authMode, @@ -519,9 +562,10 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO ...resolveCatalogHeaders(), onEvent: (event) => opts.log?.debug("codex model catalog", event) }) - applyCatalogVariantOverridesToConfig(config, catalogModels) + applyCatalogVariantOverridesToConfig(config, catalogModels, ultraEnabled) applyCustomModelsToConfig(config, opts.customModels, (message) => console.warn(message)) applyGeneratedModelAliasesToConfig(config, catalogModels, aliasSettingsFor("oauth")) + if (!ultraEnabled) hideUltraVariantsInConfig(config) } catch (error) { if (error instanceof Error) { opts.log?.debug("config variant override failed", { error: error.message }) @@ -532,6 +576,8 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO provider: "openai", async loader(getAuth, provider) { const auth = await getAuth() + const providerModels = provider.models as Record> + if (!ultraEnabled) hideUltraVariantsInProviderModels(providerModels) let hasOAuth = auth.type === "oauth" if (!hasOAuth) { try { @@ -547,7 +593,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO if (!hasOAuth) { if (auth.type === "api") { applyGeneratedAliasesToProviderModels({ - providerModels: provider.models as Record>, + providerModels, settings: aliasSettingsFor("api") }) } @@ -561,7 +607,6 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO missingGraceMs: SESSION_AFFINITY_MISSING_GRACE_MS, log: opts.log }) - const providerModels = provider.models as Record> providerModelsForCatalogSync = providerModels const syncCatalogFromAuth = await initializeCatalogSync({ @@ -592,8 +637,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO shareableDebug, internalCatalogScopeHeader: INTERNAL_CATALOG_SCOPE_HEADER, internalSelectedModelHeader: INTERNAL_SELECTED_MODEL_HEADER, - internalCollaborationModeHeader: INTERNAL_COLLABORATION_MODE_HEADER, - internalCollaborationAgentHeader: INTERNAL_COLLABORATION_AGENT_HEADER, + ultraEnabled, requestSnapshots, sessionAffinityState: { orchestratorState, @@ -660,8 +704,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO fallbackPersonality: opts.personality, projectRoot: typeof input.worktree === "string" && input.worktree.trim() ? input.worktree : process.cwd(), spoofMode, - collaborationProfileEnabled, - orchestratorSubagentsEnabled, + ultraEnabled, resolveAgentExecution: () => agentExecutionResolver.resolve({ sessionID: typeof hookInput.sessionID === "string" ? hookInput.sessionID : undefined, @@ -671,24 +714,41 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO const sessionID = typeof (hookInput as { sessionID?: unknown }).sessionID === "string" ? hookInput.sessionID : "" if (!sessionID) return + const messageID = requestMessageID(hookInput) if (hookInput.model.providerID !== "openai") { - catalogRequestMetadataBySession.delete(sessionID) + deleteCatalogRequestMetadata(sessionID, messageID) return } - const queue = catalogRequestMetadataBySession.get(sessionID) ?? [] - queue.push({ + const metadata = catalogRequestMetadataBySession.get(sessionID) ?? { + byMessageID: new Map(), + unkeyed: [] + } + const requestMetadata: CatalogRequestMetadata = { catalogScopeKey: requestCatalogScopeKey, injectedCatalogDefaultFields: paramsResult.injectedCatalogDefaultFields, ultra: paramsResult.ultra - }) - catalogRequestMetadataBySession.set(sessionID, queue) + } + if (messageID) { + const pendingForMessage = metadata.byMessageID.get(messageID) ?? [] + pendingForMessage.push(requestMetadata) + metadata.byMessageID.set(messageID, pendingForMessage) + } else { + metadata.unkeyed.push(requestMetadata) + } + catalogRequestMetadataBySession.set(sessionID, metadata) }, "chat.headers": async (hookInput, output) => { - const queue = catalogRequestMetadataBySession.get(hookInput.sessionID) - const queuedMetadata = queue?.shift() - if (queue && queue.length === 0) { - catalogRequestMetadataBySession.delete(hookInput.sessionID) - } + const metadata = catalogRequestMetadataBySession.get(hookInput.sessionID) + const messageID = requestMessageID(hookInput) + const keyedMetadata = messageID ? metadata?.byMessageID.get(messageID) : undefined + const queuedMetadata = messageID + ? keyedMetadata?.length === 1 + ? keyedMetadata[0] + : undefined + : metadata?.unkeyed.length === 1 + ? metadata.unkeyed[0] + : undefined + deleteCatalogRequestMetadata(hookInput.sessionID, messageID) const requestCatalogScopeKey = queuedMetadata?.catalogScopeKey ?? activeCatalogScopeKey await handleChatHeadersHook({ hookInput, @@ -700,11 +760,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO internalUltraStateHeader: INTERNAL_ULTRA_STATE_HEADER, internalCatalogScopeHeader: INTERNAL_CATALOG_SCOPE_HEADER, internalCatalogDefaultsHeader: INTERNAL_CATALOG_DEFAULTS_HEADER, - internalSelectedModelHeader: INTERNAL_SELECTED_MODEL_HEADER, - internalCollaborationModeHeader: INTERNAL_COLLABORATION_MODE_HEADER, - internalCollaborationAgentHeader: INTERNAL_COLLABORATION_AGENT_HEADER, - collaborationProfileEnabled, - orchestratorSubagentsEnabled + internalSelectedModelHeader: INTERNAL_SELECTED_MODEL_HEADER }) }, "experimental.session.compacting": async (hookInput, output) => { diff --git a/lib/codex-native/agent-execution.ts b/lib/codex-native/agent-execution.ts index fba77e6..3dd8b46 100644 --- a/lib/codex-native/agent-execution.ts +++ b/lib/codex-native/agent-execution.ts @@ -1,4 +1,6 @@ -import type { Config } from "@opencode-ai/plugin" +import type { Hooks } from "@opencode-ai/plugin" + +type OpenCodeConfig = Parameters>[0] export type OpenCodeAgentMode = "primary" | "subagent" | "all" export type AgentExecutionRole = "root" | "child" | "auxiliary" @@ -28,7 +30,7 @@ type SessionClient = { } } -const BUILTIN_PRIMARY_AGENTS = new Set(["build", "plan", "orchestrator"]) +const BUILTIN_PRIMARY_AGENTS = new Set(["build", "plan"]) const BUILTIN_SUBAGENTS = new Set(["general", "explore", "scout"]) const BUILTIN_AUXILIARY_AGENTS = new Set(["title", "summary", "compaction", "compact"]) @@ -42,9 +44,9 @@ function normalizeMode(value: unknown): OpenCodeAgentMode | undefined { return value === "primary" || value === "subagent" || value === "all" ? value : undefined } -export function readAgentModes(config: Config): Map { +export function readAgentModes(config: OpenCodeConfig): Map { const modes = new Map() - const agents = (config as Config & { agent?: Record }).agent + const agents = (config as OpenCodeConfig & { agent?: Record }).agent if (!agents) return modes for (const [name, value] of Object.entries(agents)) { @@ -107,7 +109,7 @@ export function createAgentExecutionResolver(input: { client?: SessionClient }) } return { - updateConfig(config: Config): void { + updateConfig(config: OpenCodeConfig): void { configuredModes = readAgentModes(config) }, deleteSession(sessionID: string): void { @@ -125,6 +127,8 @@ export function createAgentExecutionResolver(input: { client?: SessionClient }) const sessionID = options.sessionID?.trim() if (!sessionID || !input.client?.session?.get) return fallback + const generation = sessionGenerations.get(sessionID) ?? 0 + const cached = sessionRoles.get(sessionID) if (cached) { return { ...fallback, role: cached, reason: cached === "child" ? "session_parent" : "session_root" } @@ -139,6 +143,9 @@ export function createAgentExecutionResolver(input: { client?: SessionClient }) }) } const role = await pending + if ((sessionGenerations.get(sessionID) ?? 0) !== generation) { + return { ...fallback, role: "child", reason: "conservative_fallback" } + } return role ? { ...fallback, role, reason: role === "child" ? "session_parent" : "session_root" } : fallback } } diff --git a/lib/codex-native/chat-hooks.ts b/lib/codex-native/chat-hooks.ts index a021e7b..38bb36b 100644 --- a/lib/codex-native/chat-hooks.ts +++ b/lib/codex-native/chat-hooks.ts @@ -35,15 +35,7 @@ import { readSessionMessageInfo, sessionUsesOpenAIProvider } from "./session-messages.js" -import { - getCodexPlanModeInstructions, - isOrchestratorInstructions, - mergeInstructions, - replaceCodexToolCallsForOpenCode, - resolveHookAgentName, - resolveCollaborationProfile, - resolveSubagentHeaderValue -} from "./collaboration.js" +import { mergeInstructions, replaceCodexToolCallsForOpenCode, resolveHookAgentName } from "./instruction-utils.js" import { ULTRA_EXPLICIT_ONLY_INSTRUCTIONS, ULTRA_PROACTIVE_INSTRUCTIONS, @@ -66,6 +58,21 @@ function normalizeVerbositySetting(value: unknown): "default" | "low" | "medium" return undefined } +function disableUltraRuntimeDefaults(modelOptions: Record): void { + const defaults = isRecord(modelOptions.codexRuntimeDefaults) ? modelOptions.codexRuntimeDefaults : undefined + if (!defaults) return + const next = { ...defaults } + if (asString(next.defaultReasoningEffort)?.toLowerCase() === "ultra") { + next.defaultReasoningEffort = "max" + } + if (Array.isArray(next.supportedReasoningEfforts)) { + next.supportedReasoningEfforts = next.supportedReasoningEfforts.filter( + (effort) => typeof effort !== "string" || effort.trim().toLowerCase() !== "ultra" + ) + } + modelOptions.codexRuntimeDefaults = next +} + export async function handleChatMessageHook(input: { hookInput: { model?: { providerID?: string }; sessionID: string } output: { parts: unknown[] } @@ -97,8 +104,7 @@ export async function handleChatParamsHook(input: { fallbackPersonality?: PersonalityOption projectRoot?: string spoofMode: CodexSpoofMode - collaborationProfileEnabled: boolean - orchestratorSubagentsEnabled: boolean + ultraEnabled?: boolean agentExecution?: AgentExecution resolveAgentExecution?: () => Promise }): Promise<{ injectedCatalogDefaultFields: string[]; ultra?: UltraResolution }> { @@ -207,6 +213,10 @@ export async function handleChatParamsHook(input: { } } + if (!input.ultraEnabled) { + disableUltraRuntimeDefaults(modelOptions) + } + if (asString(input.output.options.serviceTier) === undefined) { const resolvedServiceTier = resolveServiceTierForModel({ behaviorSettings: input.behaviorSettings, @@ -218,10 +228,6 @@ export async function handleChatParamsHook(input: { input.output.options.serviceTier = resolvedServiceTier } } - const profile = resolveCollaborationProfile(input.hookInput.agent) - const preserveOrchestratorInstructions = - profile.isOrchestrator === true && isOrchestratorInstructions(asString(input.output.options.instructions)) - const runtimeDefaultsResult = applyCodexRuntimeDefaultsToParams({ modelOptions, modelToolCallCapable: input.hookInput.model.capabilities?.toolcall, @@ -237,24 +243,30 @@ export async function handleChatParamsHook(input: { parallelToolCalls: modelParallelToolCallsOverride ?? customModelParallelToolCallsOverride ?? globalBehavior?.parallelToolCalls }, - preferCodexInstructions: input.spoofMode === "codex" && !preserveOrchestratorInstructions, + preferCodexInstructions: input.spoofMode === "codex", modelId: input.hookInput.model.id, output: input.output }) - const ultraSelected = asString(input.output.options.reasoningEffort)?.trim().toLowerCase() === "ultra" + if (!input.ultraEnabled && asString(input.output.options.reasoningEffort)?.toLowerCase() === "ultra") { + input.output.options.reasoningEffort = "max" + } + + const ultraSelected = + input.ultraEnabled && asString(input.output.options.reasoningEffort)?.trim().toLowerCase() === "ultra" const agentExecution = input.agentExecution ?? (ultraSelected && input.resolveAgentExecution ? await input.resolveAgentExecution() : undefined) - const ultraResolution = resolveUltraSelection({ - reasoningEffort: input.output.options.reasoningEffort, - model: catalogModelFromOptions ?? catalogModelFallback, - agentExecution, - childTask: agentExecution ? undefined : resolveSubagentHeaderValue(input.hookInput.agent) !== undefined - }) + const ultraResolution = input.ultraEnabled + ? resolveUltraSelection({ + reasoningEffort: input.output.options.reasoningEffort, + model: catalogModelFromOptions ?? catalogModelFallback, + agentExecution + }) + : undefined if ( input.spoofMode === "codex" && - ultraResolution.selected && + ultraResolution?.selected && ultraResolution.eligible && ultraResolution.delegationPolicy !== "disabled" ) { @@ -267,7 +279,7 @@ export async function handleChatParamsHook(input: { } const result = (): { injectedCatalogDefaultFields: string[]; ultra?: UltraResolution } => ({ injectedCatalogDefaultFields: runtimeDefaultsResult.injectedFields, - ...(ultraResolution.selected ? { ultra: ultraResolution } : {}) + ...(ultraResolution?.selected ? { ultra: ultraResolution } : {}) }) if (input.spoofMode !== "codex") { @@ -284,21 +296,6 @@ export async function handleChatParamsHook(input: { return result() } - if (!input.collaborationProfileEnabled) { - return result() - } - - if (!profile.enabled || !profile.kind) { - return result() - } - - if (profile.instructionPreset === "plan") { - const replacedPlan = - replaceCodexToolCallsForOpenCode(getCodexPlanModeInstructions()) ?? getCodexPlanModeInstructions() - input.output.options.instructions = mergeInstructions(asString(input.output.options.instructions), replacedPlan) - return result() - } - return result() } @@ -313,10 +310,6 @@ export async function handleChatHeadersHook(input: { internalCatalogScopeHeader: string internalCatalogDefaultsHeader: string internalSelectedModelHeader: string - internalCollaborationModeHeader: string - internalCollaborationAgentHeader: string - collaborationProfileEnabled: boolean - orchestratorSubagentsEnabled: boolean }): Promise { if (input.hookInput.model.providerID !== "openai") return const originator = resolveCodexOriginator(input.spoofMode) @@ -348,34 +341,6 @@ export async function handleChatHeadersHook(input: { delete input.output.headers[internalUltraStateHeader] } - if (!input.collaborationProfileEnabled) { - delete input.output.headers["x-openai-subagent"] - delete input.output.headers[input.internalCollaborationModeHeader] - delete input.output.headers[input.internalCollaborationAgentHeader] - return - } - - const profile = resolveCollaborationProfile(input.hookInput.agent) - if (!profile.enabled || !profile.kind) { - delete input.output.headers["x-openai-subagent"] - delete input.output.headers[input.internalCollaborationModeHeader] - delete input.output.headers[input.internalCollaborationAgentHeader] - return - } - - input.output.headers[input.internalCollaborationModeHeader] = profile.kind - input.output.headers[input.internalCollaborationAgentHeader] = profile.isOrchestrator ? "orchestrator" : profile.kind - - if (input.orchestratorSubagentsEnabled) { - const subagentHeader = resolveSubagentHeaderValue(input.hookInput.agent) - if (subagentHeader) { - input.output.headers["x-openai-subagent"] = subagentHeader - } else { - delete input.output.headers["x-openai-subagent"] - } - return - } - delete input.output.headers["x-openai-subagent"] } diff --git a/lib/codex-native/collaboration.ts b/lib/codex-native/collaboration.ts deleted file mode 100644 index c295606..0000000 --- a/lib/codex-native/collaboration.ts +++ /dev/null @@ -1,251 +0,0 @@ -export type CodexCollaborationModeKind = "plan" | "code" - -export type CodexCollaborationProfile = { - enabled: boolean - kind?: CodexCollaborationModeKind - normalizedAgentName?: string - isOrchestrator?: boolean - instructionPreset?: "plan" -} - -export type CollaborationInstructionsByKind = { - plan: string - code: string -} - -export const CODEX_PLAN_MODE_INSTRUCTIONS_FALLBACK = `# Plan Mode - -You are in planning mode. - -Focus on clarifying requirements and producing a concrete, decision-complete implementation plan. - -Use concise sections that cover: -- scope and goals -- implementation steps -- edge cases and failure handling -- tests and acceptance criteria - -Do not claim changes were implemented unless execution mode is explicitly enabled.` - -let codexPlanModeInstructions = CODEX_PLAN_MODE_INSTRUCTIONS_FALLBACK - -export function getCodexPlanModeInstructions(): string { - return codexPlanModeInstructions -} - -export function setCodexPlanModeInstructions(next: string | undefined): void { - const trimmed = next?.trim() - const source = trimmed ? trimmed : CODEX_PLAN_MODE_INSTRUCTIONS_FALLBACK - codexPlanModeInstructions = replaceCodexToolCallsForOpenCode(source) ?? source -} - -export const CODEX_CODE_MODE_INSTRUCTIONS = "you are now in code mode." - -export const CODEX_ORCHESTRATOR_INSTRUCTIONS = `# Sub-agents - -If subagent tools are unavailable, proceed solo and ignore subagent-specific guidance. - -When subagents are available, delegate independent work in parallel, coordinate them with wait/send_input-style flow, and synthesize results before finalizing. - -When subagents are active, your primary role is coordination and synthesis; avoid doing worker implementation in parallel with active workers unless needed for unblock/fallback.` - -const CODEX_TOOL_NAME_REGEX = - /\b(exec_command|read_file|search_files|list_dir|write_stdin|spawn_agent|send_input|close_agent|edit_file|apply_patch)\b/i - -const TOOL_CALL_REPLACEMENTS: Array<{ pattern: RegExp; replacement: string }> = [ - { pattern: /\bexec_command\b/gi, replacement: "bash" }, - { pattern: /\bread_file\b/gi, replacement: "read" }, - { pattern: /\bsearch_files\b/gi, replacement: "grep" }, - { pattern: /\blist_dir\b/gi, replacement: "glob" }, - { pattern: /\bwrite_stdin\b/gi, replacement: "task" }, - { pattern: /\bspawn_agent\b/gi, replacement: "task" }, - { pattern: /\bsend_input\b/gi, replacement: "task" }, - { pattern: /\bclose_agent\b/gi, replacement: "skip_task_reuse" }, - { pattern: /\bedit_file\b/gi, replacement: "apply_patch" } -] - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function asString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined - const trimmed = value.trim() - return trimmed ? trimmed : undefined -} - -export function resolveHookAgentName(agent: unknown): string | undefined { - const direct = asString(agent) - if (direct) return direct - if (!isRecord(agent)) return undefined - return asString(agent.name) ?? asString(agent.agent) -} - -function normalizeAgentName(agentName: string): string { - return agentName.trim().toLowerCase().replace(/\s+/g, "-") -} - -function tokenizeAgentName(normalizedAgentName: string): string[] { - return normalizedAgentName - .split(/[-./:_]+/) - .map((token) => token.trim()) - .filter((token) => token.length > 0) -} - -function isCodexFamily(tokens: string[]): boolean { - return tokens[0] === "codex" -} - -function isPlanPrimary(tokens: string[]): boolean { - return tokens.length === 1 && tokens[0] === "plan" -} - -function isOrchestratorPrimary(tokens: string[]): boolean { - return tokens.length === 1 && tokens[0] === "orchestrator" -} - -export function resolveCollaborationProfile(agent: unknown): CodexCollaborationProfile { - const name = resolveHookAgentName(agent) - if (!name) return { enabled: false } - - const normalizedAgentName = normalizeAgentName(name) - const tokens = tokenizeAgentName(normalizedAgentName) - if (tokens.length === 0) return { enabled: false, normalizedAgentName } - - const codexFamily = isCodexFamily(tokens) - const hasPlanToken = tokens.includes("plan") || tokens.includes("planner") - const hasOrchestratorToken = tokens.includes("orchestrator") - - if ((isPlanPrimary(tokens) || (codexFamily && hasPlanToken)) && !hasOrchestratorToken) { - return { - enabled: true, - normalizedAgentName, - kind: "plan", - isOrchestrator: false, - instructionPreset: "plan" - } - } - - if (isOrchestratorPrimary(tokens) || (codexFamily && hasOrchestratorToken)) { - return { - enabled: true, - normalizedAgentName, - kind: "code", - isOrchestrator: true - } - } - - if ( - codexFamily && - tokens.some((token) => - ["default", "code", "review", "compact", "compaction", "execute", "pair", "pairprogramming"].includes(token) - ) - ) { - return { - enabled: true, - normalizedAgentName, - kind: "code", - isOrchestrator: false - } - } - - return { enabled: false, normalizedAgentName } -} - -export function resolveCollaborationInstructions( - kind: CodexCollaborationModeKind, - instructions: CollaborationInstructionsByKind -): string { - if (kind === "plan") return instructions.plan - return instructions.code -} - -export function hasCodexToolNameMarkers(instructions: string | undefined): boolean { - if (!instructions) return false - return CODEX_TOOL_NAME_REGEX.test(instructions) -} - -export function replaceCodexToolCallsForOpenCode(instructions: string | undefined): string | undefined { - const normalized = instructions?.trim() - if (!normalized) return instructions - if (!hasCodexToolNameMarkers(normalized)) return instructions - - let out = normalized - for (const replacement of TOOL_CALL_REPLACEMENTS) { - out = out.replace(replacement.pattern, replacement.replacement) - } - return out -} - -export function mergeInstructions(base: string | undefined, extra: string): string { - const normalizedExtra = extra.trim() - if (!normalizedExtra) return base?.trim() ?? "" - const normalizedBase = base?.trim() - if (!normalizedBase) return normalizedExtra - if (normalizedBase.includes(normalizedExtra)) return normalizedBase - return `${normalizedBase}\n\n${normalizedExtra}` -} - -export function isOrchestratorInstructions(instructions: string | undefined): boolean { - if (!instructions) return false - const normalized = instructions.trim() - if (!normalized) return false - if (normalized.includes("description: Codex-style orchestration profile for parallel delegation and synthesis.")) { - return true - } - if (!normalized.includes("# Sub-agents")) return false - - const lower = normalized.toLowerCase() - if (/\bspawn_agent\b/.test(lower)) return true - - const legacyMarkers = [ - "you are codex, a coding agent based on gpt-5.", - "you and the user share the same workspace and collaborate to achieve the user's goals." - ] - if (legacyMarkers.some((marker) => lower.includes(marker))) return true - - const strongMarkers = [ - "if subagent tools are unavailable, proceed solo and ignore subagent-specific guidance.", - "when subagents are available, delegate independent work in parallel, coordinate them with wait/send_input-style flow, and synthesize results before finalizing.", - "when subagents are active, your primary role is coordination and synthesis; avoid doing worker implementation in parallel with active workers unless needed for unblock/fallback.", - "coordinate them via wait / send_input", - "sub-agents are their to make you go fast", - "ask the user before shutting sub-agents down unless you need to because you reached the agent limit" - ] - if (strongMarkers.some((marker) => lower.includes(marker))) return true - - return ( - lower.includes("delegate independent work in parallel") && - (lower.includes("wait/send_input-style flow") || lower.includes("wait / send_input")) && - lower.includes("synthesize") - ) -} - -export function resolveSubagentHeaderValue(agent: unknown): string | undefined { - const profile = resolveCollaborationProfile(agent) - const normalized = profile.normalizedAgentName - if (!profile.enabled || !normalized) { - return undefined - } - - const tokens = tokenizeAgentName(normalized) - const isPrimary = - isPlanPrimary(tokens) || - isOrchestratorPrimary(tokens) || - (tokens[0] === "codex" && - (tokens.includes("orchestrator") || - tokens.includes("default") || - tokens.includes("code") || - tokens.includes("plan") || - tokens.includes("planner") || - tokens.includes("execute") || - tokens.includes("pair") || - tokens.includes("pairprogramming"))) - - if (isPrimary) return undefined - if (tokens.includes("review")) return "review" - if (tokens.includes("compact") || tokens.includes("compaction") || normalized === "compaction") { - return "compact" - } - return "collab_spawn" -} diff --git a/lib/codex-native/instruction-utils.ts b/lib/codex-native/instruction-utils.ts new file mode 100644 index 0000000..3b9f643 --- /dev/null +++ b/lib/codex-native/instruction-utils.ts @@ -0,0 +1,57 @@ +const CODEX_TOOL_NAME_REGEX = + /\b(exec_command|read_file|search_files|list_dir|write_stdin|spawn_agent|send_input|close_agent|edit_file|apply_patch)\b/i + +const TOOL_CALL_REPLACEMENTS: Array<{ pattern: RegExp; replacement: string }> = [ + { pattern: /\bexec_command\b/gi, replacement: "bash" }, + { pattern: /\bread_file\b/gi, replacement: "read" }, + { pattern: /\bsearch_files\b/gi, replacement: "grep" }, + { pattern: /\blist_dir\b/gi, replacement: "glob" }, + { pattern: /\bwrite_stdin\b/gi, replacement: "task" }, + { pattern: /\bspawn_agent\b/gi, replacement: "task" }, + { pattern: /\bsend_input\b/gi, replacement: "task" }, + { pattern: /\bclose_agent\b/gi, replacement: "skip_task_reuse" }, + { pattern: /\bedit_file\b/gi, replacement: "apply_patch" } +] + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +export function resolveHookAgentName(agent: unknown): string | undefined { + const direct = asString(agent) + if (direct) return direct + if (!isRecord(agent)) return undefined + return asString(agent.name) ?? asString(agent.agent) +} + +export function hasCodexToolNameMarkers(instructions: string | undefined): boolean { + if (!instructions) return false + return CODEX_TOOL_NAME_REGEX.test(instructions) +} + +export function replaceCodexToolCallsForOpenCode(instructions: string | undefined): string | undefined { + const normalized = instructions?.trim() + if (!normalized) return instructions + if (!hasCodexToolNameMarkers(normalized)) return instructions + + let out = normalized + for (const replacement of TOOL_CALL_REPLACEMENTS) { + out = out.replace(replacement.pattern, replacement.replacement) + } + return out +} + +export function mergeInstructions(base: string | undefined, extra: string): string { + const normalizedExtra = extra.trim() + if (!normalizedExtra) return base?.trim() ?? "" + const normalizedBase = base?.trim() + if (!normalizedBase) return normalizedExtra + if (normalizedBase.includes(normalizedExtra)) return normalizedBase + return `${normalizedBase}\n\n${normalizedExtra}` +} diff --git a/lib/codex-native/openai-loader-fetch.ts b/lib/codex-native/openai-loader-fetch.ts index a09a050..445c48d 100644 --- a/lib/codex-native/openai-loader-fetch.ts +++ b/lib/codex-native/openai-loader-fetch.ts @@ -26,7 +26,7 @@ import { toReasoningSummaryPluginFatalError } from "./reasoning-summary.js" import type { SessionAffinityRuntimeState } from "./session-affinity-state.js" import { scheduleQuotaRefresh } from "./openai-loader-fetch-quota.js" import type { ShareableDebugLogger } from "../shareable-debug.js" -import { retainUltraState, type UltraResolution } from "./ultra.js" +import { parseUltraState, retainUltraState, type UltraResolution } from "./ultra.js" import { CATALOG_REFRESH_FAILURE_RETRY_MS, CATALOG_REFRESH_TTL_MS, @@ -58,8 +58,7 @@ export type CreateOpenAIFetchHandlerInput = { shareableDebug?: ShareableDebugLogger internalCatalogScopeHeader?: string internalSelectedModelHeader?: string - internalCollaborationModeHeader: string - internalCollaborationAgentHeader?: string + ultraEnabled?: boolean requestSnapshots: SnapshotRecorder sessionAffinityState: SessionAffinityRuntimeState getCatalogModels: (scopeKey?: string) => CodexModelInfo[] | undefined @@ -82,9 +81,6 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { const internalCatalogDefaultsHeader = "x-opencode-catalog-default-fields" const internalSelectedModelHeader = input.internalSelectedModelHeader ?? "x-opencode-selected-model-slug" const internalUltraStateHeader = "x-opencode-ultra-state" - const internalCollaborationAgentHeader = - input.internalCollaborationAgentHeader ?? "x-opencode-collaboration-agent-kind" - const trustedSubagentValues = new Set(["review", "compact", "memory_consolidation", "collab_spawn"]) const quotaTrackerByIdentity = new Map() const quotaRefreshAtByIdentity = new Map() const catalogSyncByScope = new Map() @@ -170,36 +166,17 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { outbound.headers.set("user-agent", resolveRequestUserAgent(input.spoofMode, outboundOriginator)) } - const collaborationModeHeader = outbound.headers.get(input.internalCollaborationModeHeader)?.trim() - const collaborationAgentHeader = outbound.headers.get(internalCollaborationAgentHeader)?.trim() - const subagentHeader = outbound.headers.get("x-openai-subagent")?.trim() - const trustedSubagentHeader = - typeof subagentHeader === "string" && trustedSubagentValues.has(subagentHeader.toLowerCase()) - ? subagentHeader - : undefined - const isSubagentRequest = - internalCollaborationAgentHeader === "x-openai-subagent" - ? Boolean(subagentHeader) - : Boolean(trustedSubagentHeader) - if (outbound.headers.has(input.internalCollaborationModeHeader)) { - outbound.headers.delete(input.internalCollaborationModeHeader) - } - if (outbound.headers.has(internalCollaborationAgentHeader)) { - outbound.headers.delete(internalCollaborationAgentHeader) - } - const shouldForwardSubagentHeader = - internalCollaborationAgentHeader === "x-openai-subagent" - ? Boolean(subagentHeader) - : Boolean(collaborationModeHeader && collaborationAgentHeader && trustedSubagentHeader) - if (!shouldForwardSubagentHeader && outbound.headers.has("x-openai-subagent")) { - outbound.headers.delete("x-openai-subagent") - } + const initialUltraState = input.ultraEnabled + ? parseUltraState(outbound.headers.get(internalUltraStateHeader)) + : undefined + const isSubagentRequest = initialUltraState?.agentRole === "child" || initialUltraState?.agentRole === "auxiliary" + outbound.headers.delete("x-openai-subagent") let selectedIdentityKey: string | undefined let selectedAuthForQuota: { access: string; accountId?: string; identityKey?: string } | undefined let selectedCatalogModels: CodexModelInfo[] | undefined let selectedPreviousCatalogScopeKey: string | undefined - let ultraStateForRequest: UltraResolution | undefined + let ultraStateForRequest: UltraResolution | undefined = initialUltraState const promptCacheKeyStrategy = input.promptCacheKeyStrategy ?? "default" const promptCacheKeyOverride = promptCacheKeyStrategy === "project" @@ -305,7 +282,9 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { maxRedirects: 3, showToast: input.showToast, onAttemptRequest: async ({ attempt, maxAttempts, attemptReasonCode, request, auth, sessionKey }) => { - ultraStateForRequest = retainUltraState(ultraStateForRequest, request.headers.get(internalUltraStateHeader)) + ultraStateForRequest = input.ultraEnabled + ? retainUltraState(ultraStateForRequest, request.headers.get(internalUltraStateHeader)) + : undefined await input.shareableDebug?.emitFetchAttemptRequest({ authMode: input.authMode, rotationStrategy: auth.selectionTrace?.strategy ?? input.configuredRotationStrategy, @@ -371,7 +350,8 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { behaviorSettings: input.behaviorSettings, customModels: input.customModels, ultraChildTask: isSubagentRequest, - ultraState: ultraStateForRequest + ultraState: ultraStateForRequest, + ultraEnabled: input.ultraEnabled }) ultraStateForRequest = payloadTransform.ultra ?? ultraStateForRequest @@ -393,7 +373,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { developerMessageRemapCount: payloadTransform.developerRoleRemap.remappedCount, developerMessagePreservedCount: payloadTransform.developerRoleRemap.preservedCount, ultra: payloadTransform.ultra, - ...(isSubagentRequest ? { subagent: subagentHeader } : {}) + ...(isSubagentRequest && payloadTransform.ultra ? { ultraAgentRole: payloadTransform.ultra.agentRole } : {}) }) } diff --git a/lib/codex-native/request-transform-payload.ts b/lib/codex-native/request-transform-payload.ts index 52d3996..e1d50f8 100644 --- a/lib/codex-native/request-transform-payload.ts +++ b/lib/codex-native/request-transform-payload.ts @@ -15,7 +15,12 @@ import { } from "./request-transform-model.js" import { type ReasoningSummaryValidationDiagnostic, resolveReasoningSummaryValue } from "./reasoning-summary.js" import { getRequestBodyVariantCandidates } from "./request-transform-model-service-tier.js" -import { normalizeUltraWireEffort, resolveUltraSelection, type UltraResolution } from "./ultra.js" +import { + normalizeUltraWireEffort, + resolveUltraSelection, + stripUltraDelegationInstructions, + type UltraResolution +} from "./ultra.js" import { type CompatSanitizerTransformResult, type DeveloperRoleRemapTransformResult, @@ -152,6 +157,7 @@ type OutboundRequestPayloadTransformInput = { customModels?: Record ultraChildTask?: boolean ultraState?: UltraResolution + ultraEnabled?: boolean } export type OutboundRequestPayloadTransformResult = { @@ -360,22 +366,29 @@ export async function transformOutboundRequestPayload( if (!customEntry) return undefined return findCatalogModelForCandidates(input.catalogModels, [customEntry.targetModel]) })() - const ultra = resolveUltraSelection({ - reasoningEffort: input.ultraState?.selected - ? input.ultraState.logicalEffort - : (existingReasoning?.effort ?? input.ultraState?.logicalEffort), - model: selectedCatalogModel, - agentExecution: input.ultraState - ? { - role: input.ultraState.agentRole, - reason: input.ultraState.agentReason, - ...(input.ultraState.agentName ? { agentName: input.ultraState.agentName } : {}) - } - : undefined, - childTask: input.ultraState ? undefined : input.ultraChildTask === true - }) + const ultra = + input.ultraEnabled === true + ? resolveUltraSelection({ + reasoningEffort: input.ultraState?.selected + ? input.ultraState.logicalEffort + : (existingReasoning?.effort ?? input.ultraState?.logicalEffort), + model: selectedCatalogModel, + agentExecution: input.ultraState + ? { + role: input.ultraState.agentRole, + reason: input.ultraState.agentReason, + ...(input.ultraState.agentName ? { agentName: input.ultraState.agentName } : {}) + } + : undefined, + childTask: input.ultraState ? undefined : input.ultraChildTask === true + }) + : undefined + const logicalUltraSelected = + ultra?.selected === true || + input.ultraState?.selected === true || + asString(existingReasoning?.effort)?.trim().toLowerCase() === "ultra" let ultraChanged = false - if (ultra.selected && existingReasoning) { + if (logicalUltraSelected && existingReasoning) { const normalizedWireEffort = normalizeUltraWireEffort(existingReasoning.effort) if (normalizedWireEffort.changed && normalizedWireEffort.value) { existingReasoning.effort = normalizedWireEffort.value @@ -414,6 +427,10 @@ export async function transformOutboundRequestPayload( behaviorSettings: input.behaviorSettings, fallbackPersonality: input.fallbackPersonality }) + const ultraInstructionsChanged = + input.ultraEnabled !== true || (logicalUltraSelected && ultra?.eligible !== true) + ? stripUltraDelegationInstructions(finalPayload) + : false const gpt54LongContextClampChanged = input.gpt54LongContextClampEnabled !== false ? applyGpt54LongContextClampsToPayloadWithContext({ @@ -434,6 +451,7 @@ export async function transformOutboundRequestPayload( changed || compatSanitizer.changed || selectedCatalogScopeSyncChanged || + ultraInstructionsChanged || gpt54LongContextClampChanged || ultraChanged || serviceTier.changed @@ -448,7 +466,7 @@ export async function transformOutboundRequestPayload( compatSanitizer, serviceTier: { ...serviceTier, request: input.request }, reasoningSummaryValidation, - ultra: ultra.selected ? ultra : undefined + ultra: ultra?.selected ? ultra : undefined } } @@ -464,7 +482,7 @@ export async function transformOutboundRequestPayload( request: input.request }, reasoningSummaryValidation, - ultra: ultra.selected ? ultra : undefined + ultra: ultra?.selected ? ultra : undefined } } diff --git a/lib/codex-native/ultra.ts b/lib/codex-native/ultra.ts index be23eae..e04ae47 100644 --- a/lib/codex-native/ultra.ts +++ b/lib/codex-native/ultra.ts @@ -197,3 +197,26 @@ export function retainUltraState( ): UltraResolution | undefined { return parseUltraState(encoded) ?? current } + +export function stripUltraDelegationInstructions(payload: Record): boolean { + if (typeof payload.instructions !== "string") return false + + const current = payload.instructions + let next = current + let overlayRemoved = false + for (const overlay of [ULTRA_PROACTIVE_INSTRUCTIONS, ULTRA_EXPLICIT_ONLY_INSTRUCTIONS]) { + if (!next.includes(overlay)) continue + next = next.replaceAll(overlay, "") + overlayRemoved = true + } + if (!overlayRemoved) return false + + next = next.replace(/\n{3,}/g, "\n\n").trim() + + if (next) { + payload.instructions = next + } else { + delete payload.instructions + } + return true +} diff --git a/lib/codex-prompts-cache.ts b/lib/codex-prompts-cache.ts deleted file mode 100644 index ae844a2..0000000 --- a/lib/codex-prompts-cache.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { fetchRemoteTextBatch, type RemoteTextFetchResult } from "./remote-cache-fetch.js" -import { readJsonFileBestEffort, writeJsonFileBestEffort } from "./cache-io.js" -import { withLockedDirectory } from "./cache-lock.js" -import { codexPromptsCacheMetaPath, codexPromptsCachePath, resolveCodexCacheDir } from "./codex-cache-layout.js" - -export { CODEX_PROMPTS_CACHE_FILE, CODEX_PROMPTS_CACHE_META_FILE } from "./codex-cache-layout.js" - -export const CODEX_ORCHESTRATOR_PROMPT_URL = - "https://raw.githubusercontent.com/openai/codex/4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476/codex-rs/core/templates/agents/orchestrator.md" -export const CODEX_PLAN_PROMPT_URL = - "https://raw.githubusercontent.com/openai/codex/4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476/codex-rs/core/templates/collaboration_mode/plan.md" - -const CACHE_TTL_MS = 24 * 60 * 60 * 1000 -const FETCH_TIMEOUT_MS = 5000 -const PROMPT_ALLOWED_HOSTS = ["raw.githubusercontent.com"] - -type CodexPromptsCache = { - fetchedAt: number - source: "github" - prompts: { - orchestrator: string - plan: string - } -} - -type CodexPromptsCacheMeta = { - lastChecked: number - urls: { - orchestrator: string - plan: string - } - etags?: { - orchestrator?: string - plan?: string - } -} - -type RefreshResult = { - orchestrator?: string - plan?: string -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function normalizePrompt(value: unknown): string | undefined { - if (typeof value !== "string") return undefined - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -async function readPromptsCache(cacheDir: string): Promise { - const parsed = await readJsonFileBestEffort(codexPromptsCachePath(cacheDir)) - if (!isRecord(parsed)) return undefined - const prompts = isRecord(parsed.prompts) ? parsed.prompts : undefined - const orchestrator = normalizePrompt(prompts?.orchestrator) - const plan = normalizePrompt(prompts?.plan) - if (!orchestrator || !plan) return undefined - const fetchedAt = typeof parsed.fetchedAt === "number" && Number.isFinite(parsed.fetchedAt) ? parsed.fetchedAt : 0 - return { - fetchedAt, - source: "github", - prompts: { - orchestrator, - plan - } - } -} - -async function writePromptsCache(cacheDir: string, cache: CodexPromptsCache): Promise { - await writeJsonFileBestEffort(codexPromptsCachePath(cacheDir), cache) -} - -async function readPromptsCacheMeta(cacheDir: string): Promise { - const parsed = await readJsonFileBestEffort(codexPromptsCacheMetaPath(cacheDir)) - if (!isRecord(parsed)) return undefined - const urls = isRecord(parsed.urls) ? parsed.urls : undefined - const orchestrator = normalizePrompt(urls?.orchestrator) - const plan = normalizePrompt(urls?.plan) - if (!orchestrator || !plan) return undefined - const lastChecked = - typeof parsed.lastChecked === "number" && Number.isFinite(parsed.lastChecked) ? parsed.lastChecked : 0 - const etagsRecord = isRecord(parsed.etags) ? parsed.etags : undefined - const etags = etagsRecord - ? { - orchestrator: normalizePrompt(etagsRecord.orchestrator), - plan: normalizePrompt(etagsRecord.plan) - } - : undefined - return { - lastChecked, - urls: { orchestrator, plan }, - etags - } -} - -async function writePromptsCacheMeta(cacheDir: string, meta: CodexPromptsCacheMeta): Promise { - await writeJsonFileBestEffort(codexPromptsCacheMetaPath(cacheDir), meta) -} - -const inFlightRefreshByCacheDir = new Map>() - -function resolvePrompt(result: RemoteTextFetchResult | undefined, existing: string | undefined): string | undefined { - if (!result) return undefined - if (result.status === "ok") { - return normalizePrompt(result.text) - } - if (result.status === "not_modified") { - return existing - } - return undefined -} - -function resolveEtag(result: RemoteTextFetchResult | undefined, existing: string | undefined): string | undefined { - if (!result) return existing - if (result.status === "ok") return result.etag ?? existing - if (result.status === "not_modified") return result.etag ?? existing - return existing -} - -export async function readCachedCodexPrompts(input: { cacheDir?: string } = {}): Promise { - const cacheDir = resolveCodexCacheDir(input.cacheDir) - const cache = await readPromptsCache(cacheDir) - if (!cache) return {} - return { - orchestrator: cache.prompts.orchestrator, - plan: cache.prompts.plan - } -} - -export async function refreshCachedCodexPrompts( - input: { cacheDir?: string; now?: () => number; fetchImpl?: typeof fetch; forceRefresh?: boolean } = {} -): Promise { - const cacheDir = resolveCodexCacheDir(input.cacheDir) - const now = (input.now ?? Date.now)() - const fetchImpl = input.fetchImpl ?? fetch - - const run = async (): Promise => - withLockedDirectory( - cacheDir, - async () => { - const existingCache = await readPromptsCache(cacheDir) - const existingMeta = await readPromptsCacheMeta(cacheDir) - - const cacheIsFresh = - existingCache && - existingMeta && - existingMeta.urls.orchestrator === CODEX_ORCHESTRATOR_PROMPT_URL && - existingMeta.urls.plan === CODEX_PLAN_PROMPT_URL && - now - existingMeta.lastChecked < CACHE_TTL_MS - - if (cacheIsFresh && input.forceRefresh !== true) { - return { - orchestrator: existingCache.prompts.orchestrator, - plan: existingCache.prompts.plan - } - } - - const results = await fetchRemoteTextBatch( - { - requests: [ - { - key: "orchestrator", - url: CODEX_ORCHESTRATOR_PROMPT_URL, - etag: - existingMeta?.urls.orchestrator === CODEX_ORCHESTRATOR_PROMPT_URL - ? existingMeta?.etags?.orchestrator - : undefined - }, - { - key: "plan", - url: CODEX_PLAN_PROMPT_URL, - etag: existingMeta?.urls.plan === CODEX_PLAN_PROMPT_URL ? existingMeta?.etags?.plan : undefined - } - ] - }, - { - fetchImpl, - timeoutMs: FETCH_TIMEOUT_MS, - allowedHosts: PROMPT_ALLOWED_HOSTS - } - ) - const orchestratorResult = results.find((result) => result.key === "orchestrator") - const planResult = results.find((result) => result.key === "plan") - - const orchestrator = resolvePrompt(orchestratorResult, existingCache?.prompts.orchestrator) - const plan = resolvePrompt(planResult, existingCache?.prompts.plan) - - if (!orchestrator || !plan) { - return { - orchestrator: existingCache?.prompts.orchestrator, - plan: existingCache?.prompts.plan - } - } - - const nextCache: CodexPromptsCache = { - fetchedAt: now, - source: "github", - prompts: { - orchestrator, - plan - } - } - await writePromptsCache(cacheDir, nextCache) - await writePromptsCacheMeta(cacheDir, { - lastChecked: now, - urls: { - orchestrator: CODEX_ORCHESTRATOR_PROMPT_URL, - plan: CODEX_PLAN_PROMPT_URL - }, - etags: { - orchestrator: resolveEtag(orchestratorResult, existingMeta?.etags?.orchestrator), - plan: resolveEtag(planResult, existingMeta?.etags?.plan) - } - }) - - return { - orchestrator, - plan - } - }, - { staleMs: 10_000 } - ) - - if (input.forceRefresh === true) { - return run() - } - - const existingInFlight = inFlightRefreshByCacheDir.get(cacheDir) - if (existingInFlight) return existingInFlight - - const inFlight = run().finally(() => { - if (inFlightRefreshByCacheDir.get(cacheDir) === inFlight) { - inFlightRefreshByCacheDir.delete(cacheDir) - } - }) - inFlightRefreshByCacheDir.set(cacheDir, inFlight) - return inFlight -} diff --git a/lib/config.ts b/lib/config.ts index f46f7f1..5317873 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -46,7 +46,6 @@ export { cloneBehaviorSettings, getBehaviorSettings, getCodexCompactionOverrideEnabled, - getCollaborationProfileEnabled, getCompatInputSanitizerEnabled, getCustomModels, getDebugEnabled, @@ -56,7 +55,6 @@ export { getHeaderTransformDebugEnabled, getMode, getModelAliasSettings, - getOrchestratorSubagentsEnabled, getPersonality, getPidOffsetEnabled, getProactiveRefreshBufferMs, @@ -69,5 +67,6 @@ export { getRotationStrategy, getSpoofMode, getThinkingSummariesOverride, + getUltraEnabled, resolveConfig } from "./config/resolve.js" diff --git a/lib/config/file.ts b/lib/config/file.ts index df7d1ae..9ef9252 100644 --- a/lib/config/file.ts +++ b/lib/config/file.ts @@ -710,9 +710,8 @@ export function validateConfigFileObject(raw: unknown): ConfigValidationResult { "headerSnapshots", "headerSnapshotBodies", "headerTransformDebug", - "pidOffset", - "collaborationProfile", - "orchestratorSubagents" + "ultra", + "pidOffset" ] for (const field of boolFields) { if (field in runtime && typeof runtime[field] !== "boolean") { @@ -897,10 +896,7 @@ function parseConfigFileObjectWithMetadata(raw: unknown): ParsedConfigFile { const headerTransformDebug = typeof runtime?.headerTransformDebug === "boolean" ? runtime.headerTransformDebug : undefined const pidOffsetEnabled = typeof runtime?.pidOffset === "boolean" ? runtime.pidOffset : undefined - const collaborationProfileEnabled = - typeof runtime?.collaborationProfile === "boolean" ? runtime.collaborationProfile : undefined - const orchestratorSubagentsEnabled = - typeof runtime?.orchestratorSubagents === "boolean" ? runtime.orchestratorSubagents : undefined + const ultraEnabled = typeof runtime?.ultra === "boolean" ? runtime.ultra : undefined return { config: { @@ -922,10 +918,7 @@ function parseConfigFileObjectWithMetadata(raw: unknown): ParsedConfigFile { headerSnapshots, headerSnapshotBodies, headerTransformDebug, - collaborationProfile: collaborationProfileEnabled, - collaborationProfileEnabled, - orchestratorSubagents: orchestratorSubagentsEnabled, - orchestratorSubagentsEnabled, + ultraEnabled, behaviorSettings, customModels, modelAliases diff --git a/lib/config/resolve.ts b/lib/config/resolve.ts index 96002bc..44d7467 100644 --- a/lib/config/resolve.ts +++ b/lib/config/resolve.ts @@ -229,14 +229,7 @@ export function resolveConfig(input: { parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_HEADER_SNAPSHOT_BODIES) ?? file.headerSnapshotBodies const headerTransformDebug = parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_HEADER_TRANSFORM_DEBUG) ?? file.headerTransformDebug - const collaborationProfileEnabled = - parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE) ?? - file.collaborationProfileEnabled ?? - file.collaborationProfile - const orchestratorSubagentsEnabled = - parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS) ?? - file.orchestratorSubagentsEnabled ?? - file.orchestratorSubagents + const ultraEnabled = parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_ULTRA) ?? file.ultraEnabled ?? false return { ...file, @@ -257,8 +250,7 @@ export function resolveConfig(input: { headerSnapshots, headerSnapshotBodies, headerTransformDebug, - collaborationProfileEnabled, - orchestratorSubagentsEnabled, + ultraEnabled, behaviorSettings: resolvedBehaviorSettings } } @@ -337,16 +329,8 @@ export function getHeaderSnapshotBodiesEnabled(cfg: PluginConfig): boolean { return cfg.headerSnapshotBodies === true } -export function getCollaborationProfileEnabled(cfg: PluginConfig): boolean { - if (cfg.collaborationProfileEnabled === true) return true - if (cfg.collaborationProfileEnabled === false) return false - return getMode(cfg) === "codex" -} - -export function getOrchestratorSubagentsEnabled(cfg: PluginConfig): boolean { - if (cfg.orchestratorSubagentsEnabled === true) return true - if (cfg.orchestratorSubagentsEnabled === false) return false - return getCollaborationProfileEnabled(cfg) +export function getUltraEnabled(cfg: PluginConfig): boolean { + return cfg.ultraEnabled === true } export function getBehaviorSettings(cfg: PluginConfig): BehaviorSettings | undefined { diff --git a/lib/config/types.ts b/lib/config/types.ts index c2e0831..faa7fd7 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -59,10 +59,7 @@ export type PluginConfig = { headerSnapshotBodies?: boolean headerTransformDebug?: boolean promptCacheKeyStrategy?: PromptCacheKeyStrategy - collaborationProfile?: boolean - collaborationProfileEnabled?: boolean - orchestratorSubagents?: boolean - orchestratorSubagentsEnabled?: boolean + ultraEnabled?: boolean behaviorSettings?: BehaviorSettings customModels?: Record modelAliases?: { fast?: boolean; extendedContext?: boolean; pro?: boolean } @@ -89,6 +86,7 @@ export const DEFAULT_CODEX_CONFIG = { headerSnapshots: false, headerSnapshotBodies: false, headerTransformDebug: false, + ultra: false, pidOffset: false }, global: { @@ -180,15 +178,10 @@ export const DEFAULT_CODEX_CONFIG_TEMPLATE = `{ // default: false "headerTransformDebug": false, - // Collaboration profile toggles. + // Work in progress: enable the catalog-gated Ultra agent mode. // options: true | false - // mode default: false in "native", true in "codex" - // "collaborationProfile": true, - - // Subagent header hints. - // options: true | false - // default: inherits collaborationProfile - // "orchestratorSubagents": true, + // default: false + "ultra": false, // Session-aware offset for account selection. // options: true | false diff --git a/lib/installer-cli.ts b/lib/installer-cli.ts index 483560f..6ae17b1 100644 --- a/lib/installer-cli.ts +++ b/lib/installer-cli.ts @@ -2,16 +2,9 @@ import path from "node:path" import { installCreatePersonalityCommand } from "./personality-command.js" import { installPersonalityBuilderSkill } from "./personality-skill.js" -import { - ensureDefaultConfigFile, - getCollaborationProfileEnabled, - getMode, - loadConfigFile, - resolveConfig -} from "./config.js" -import { reconcileOrchestratorAgentVisibility } from "./orchestrator-agent.js" +import { ensureDefaultConfigFile } from "./config.js" +import { removeLegacyOrchestratorArtifacts } from "./legacy-orchestrator-cleanup.js" import { DEFAULT_PLUGIN_SPECIFIER, defaultOpencodeConfigPath, ensurePluginInstalled } from "./opencode-install.js" -import { refreshCachedCodexPrompts } from "./codex-prompts-cache.js" type InstallerIo = { out: (message: string) => void @@ -151,22 +144,8 @@ export async function runInstallerCli(args: string[], io: InstallerIo = DEFAULT_ }` ) - const promptsResult = await refreshCachedCodexPrompts({ forceRefresh: true }) - io.out(`Codex prompts cache synchronized: ${promptsResult.orchestrator && promptsResult.plan ? "yes" : "fallback"}`) - - const resolvedConfig = resolveConfig({ - env: process.env, - file: loadConfigFile({ env: process.env }) - }) - const runtimeMode = getMode(resolvedConfig) - const collaborationProfileEnabled = getCollaborationProfileEnabled(resolvedConfig) - const orchestratorResult = await reconcileOrchestratorAgentVisibility({ visible: collaborationProfileEnabled }) - io.out(`Orchestrator agent file: ${orchestratorResult.filePath}`) - io.out( - `Orchestrator agent visible in current mode (${runtimeMode}, collaboration=${collaborationProfileEnabled ? "on" : "off"}): ${ - orchestratorResult.visible ? "yes" : "no" - }` - ) + const cleanup = await removeLegacyOrchestratorArtifacts() + io.out(`Legacy orchestrator artifacts removed: ${cleanup.removed.length}`) return 0 } diff --git a/lib/legacy-orchestrator-cleanup.ts b/lib/legacy-orchestrator-cleanup.ts new file mode 100644 index 0000000..541bf71 --- /dev/null +++ b/lib/legacy-orchestrator-cleanup.ts @@ -0,0 +1,59 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { defaultOpencodeCachePath } from "./paths.js" + +const LEGACY_AGENT_FILES = ["orchestrator.md", "orchestrator.md.disabled"] as const +const LEGACY_CACHE_FILES = ["codex-prompts-cache.json", "codex-prompts-cache-meta.json"] as const +const MANAGED_MARKER = "description: Codex-style orchestration profile for parallel delegation and synthesis." + +export type LegacyOrchestratorCleanupResult = { + removed: string[] + preserved: string[] +} + +export function defaultOpencodeAgentsDir(env: Record = process.env): string { + const xdgRoot = env.XDG_CONFIG_HOME?.trim() + if (xdgRoot) return path.join(xdgRoot, "opencode", "agents") + return path.join(os.homedir(), ".config", "opencode", "agents") +} + +export async function removeLegacyOrchestratorArtifacts( + input: { agentsDir?: string; cacheDir?: string } = {} +): Promise { + const agentsDir = input.agentsDir ?? defaultOpencodeAgentsDir() + const cacheDir = input.cacheDir ?? defaultOpencodeCachePath() + const removed: string[] = [] + const preserved: string[] = [] + + for (const fileName of LEGACY_AGENT_FILES) { + const filePath = path.join(agentsDir, fileName) + let content: string + try { + content = await fs.readFile(filePath, "utf8") + } catch { + continue + } + + if (!content.includes(MANAGED_MARKER)) { + preserved.push(filePath) + continue + } + + await fs.unlink(filePath) + removed.push(filePath) + } + + for (const fileName of LEGACY_CACHE_FILES) { + const filePath = path.join(cacheDir, fileName) + try { + await fs.unlink(filePath) + removed.push(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + } + + return { removed, preserved } +} diff --git a/lib/model-catalog/provider.ts b/lib/model-catalog/provider.ts index c3dd39b..c1c2b2a 100644 --- a/lib/model-catalog/provider.ts +++ b/lib/model-catalog/provider.ts @@ -154,8 +154,10 @@ function getSupportedReasoningEfforts(model: CodexModelInfo): string[] { ) } -function buildVariants(model: CodexModelInfo): Record> { - const efforts = getSupportedReasoningEfforts(model).filter((effort) => effort !== "ultra" || isUltraEligible(model)) +function buildVariants(model: CodexModelInfo, ultraEnabled: boolean): Record> { + const efforts = getSupportedReasoningEfforts(model).filter( + (effort) => effort !== "ultra" || (ultraEnabled && isUltraEligible(model)) + ) return Object.fromEntries( efforts.map((effort) => { @@ -210,6 +212,7 @@ function mergeVariantMaps( function buildProviderModelFromCatalog( model: CodexModelInfo, providerModels: Record>, + ultraEnabled: boolean, existingModel?: Record ): Record | undefined { const display = resolveDisplayName(model.slug, model.display_name) @@ -225,7 +228,7 @@ function buildProviderModelFromCatalog( const contextWindow = asFiniteNumber(model.context_window) if (contextWindow === undefined) return undefined const outputLimit = DEFAULT_OUTPUT_TOKEN_LIMIT - const variants = buildVariants(model) + const variants = buildVariants(model, ultraEnabled) return { id: model.slug, @@ -587,7 +590,12 @@ export function applyCodexCatalogToProviderModels(input: ApplyCodexCatalogInput) if (!catalogModel) continue const existingModel = input.providerModels[slug] - const nextModel = buildProviderModelFromCatalog(catalogModel, input.providerModels, existingModel) + const nextModel = buildProviderModelFromCatalog( + catalogModel, + input.providerModels, + input.ultraEnabled === true, + existingModel + ) if (!nextModel) { delete input.providerModels[slug] continue @@ -607,7 +615,16 @@ export function applyCodexCatalogToProviderModels(input: ApplyCodexCatalogInput) clearCatalogInstructionState(input.providerModels[slug], options) } - const runtimeDefaults = getRuntimeDefaultsForSlug(slug, catalogModels) + const resolvedRuntimeDefaults = getRuntimeDefaultsForSlug(slug, catalogModels) + const runtimeDefaults = resolvedRuntimeDefaults ? { ...resolvedRuntimeDefaults } : undefined + if (runtimeDefaults && input.ultraEnabled !== true) { + if (runtimeDefaults.defaultReasoningEffort?.trim().toLowerCase() === "ultra") { + runtimeDefaults.defaultReasoningEffort = "max" + } + runtimeDefaults.supportedReasoningEfforts = runtimeDefaults.supportedReasoningEfforts?.filter( + (effort) => effort.trim().toLowerCase() !== "ultra" + ) + } if (runtimeDefaults) { input.providerModels[slug].codexRuntimeDefaults = runtimeDefaults options.codexRuntimeDefaults = runtimeDefaults @@ -646,6 +663,15 @@ export function applyCodexCatalogToProviderModels(input: ApplyCodexCatalogInput) }) } + if (input.ultraEnabled !== true) { + for (const model of Object.values(input.providerModels)) { + const variants = asRecord(model.variants) + if (!variants || !("ultra" in variants)) continue + delete variants.ultra + if (Object.keys(variants).length === 0) delete model.variants + } + } + for (const modelId of Object.keys(input.providerModels)) { if (!allowed.has(modelId)) { delete input.providerModels[modelId] diff --git a/lib/model-catalog/shared.ts b/lib/model-catalog/shared.ts index a95ed3b..bab42a9 100644 --- a/lib/model-catalog/shared.ts +++ b/lib/model-catalog/shared.ts @@ -138,6 +138,7 @@ export type ApplyCodexCatalogInput = { customModels?: Record warn?: (message: string) => void aliasSettings?: { fast: boolean; extendedContext: boolean; pro: boolean } + ultraEnabled?: boolean } export const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models" diff --git a/lib/orchestrator-agent.ts b/lib/orchestrator-agent.ts deleted file mode 100644 index 46e62cd..0000000 --- a/lib/orchestrator-agent.ts +++ /dev/null @@ -1,327 +0,0 @@ -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" - -import { refreshCachedCodexPrompts } from "./codex-prompts-cache.js" -import { replaceCodexToolCallsForOpenCode } from "./codex-native/collaboration.js" - -export const CODEX_ORCHESTRATOR_AGENT_FILE = "orchestrator.md" -export const CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED = `${CODEX_ORCHESTRATOR_AGENT_FILE}.disabled` - -const ORCHESTRATOR_FRONTMATTER = `--- -description: Codex-style orchestration profile for parallel delegation and synthesis. -mode: primary ---- -` - -const CODEX_ORCHESTRATOR_AGENT_TEMPLATE = `--- -description: Codex-style orchestration profile for parallel delegation and synthesis. -mode: primary ---- - -You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals. - -# Personality -You are a collaborative, highly capable pair-programmer AI. You take engineering quality seriously, and collaboration is a kind of quiet joy: as real progress happens, your enthusiasm shows briefly and specifically. Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work. - -## Tone and style -- Anything you say outside of tool use is shown to the user. Do not narrate abstractly; explain what you are doing and why, using plain language. -- Output will be rendered in a command line interface or minimal UI so keep responses tight, scannable, and low-noise. Generally avoid the use of emojis. You may format with GitHub-flavored Markdown. -- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the \`1. 2. 3.\` style markers (with a period), never \`1)\`. -- When writing a final assistant response, state the solution first before explaining your answer. The complexity of the answer should match the task. If the task is simple, your answer should be short. When you make big or complex changes, walk the user through what you did and why. -- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **...**. Don't add a blank line. -- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible. -- Never output the content of large files, just provide references. Use inline code to make file paths clickable; each reference should have a stand alone path, even if it's the same file. Paths may be absolute, workspace-relative, a//b/ diff-prefixed, or bare filename/suffix; locations may be :line[:column] or #Lline[Ccolumn] (1-based; column defaults to 1). Do not use file://, vscode://, or https://, and do not provide line ranges. Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5 -- The user does not see command execution outputs. When asked to show the output of a command (e.g. \`git show\`), relay the important details in your answer or summarize the key lines so the user understands the result. -- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have. -- If you weren't able to do something, for example run tests, tell the user. -- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. - -## Responsiveness - -### Collaboration posture: -- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as \`date\`), you should do so. -- Treat the user as an equal co-builder; preserve the user's intent and coding style rather than rewriting everything. -- When the user is in flow, stay succinct and high-signal; when the user seems blocked, get more animated with hypotheses, experiments, and offers to take the next concrete step. -- Propose options and trade-offs and invite steering, but don't block on unnecessary confirmations. -- Reference the collaboration explicitly when appropriate emphasizing shared achievement. - -### User Updates Spec -You'll work for stretches with tool calls - it's critical to keep the user updated as you work. - -Tone: -- Friendly, confident, senior-engineer energy. Positive, collaborative, humble; fix mistakes quickly. - -Frequency and Length: -- Send short updates (1-2 sentences) whenever there is a meaningful, important insight you need to share with the user to keep them informed. -- If you expect a longer heads-down stretch, post a brief heads-down note with why and when you'll report back; when you resume, summarize what you learned. -- Only the initial plan, plan updates, and final recap can be longer, with multiple bullets and paragraphs - -Content: -- Before you begin, give a quick plan with goal, constraints, next steps. -- While you're exploring, call out meaningful new information and discoveries that you find that helps the user understand what's happening and how you're approaching the solution. -- If you change the plan (e.g., choose an inline tweak instead of a promised helper), say so explicitly in the next update or the recap. -- Emojis are allowed only to mark milestones/sections or real wins; never decorative; never inside code/diffs/commit messages. - -# Code style - -- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below. -- Use language-appropriate best practices. -- Optimize for clarity, readability, and maintainability. -- Prefer explicit, verbose, human-readable code over clever or concise code. -- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. -- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. - -# Reviews - -When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps. - -# Your environment - -## Using GIT - -- You may be working in a dirty git worktree. - * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. - * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. - * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. - * If the changes are in unrelated files, just ignore them and don't revert them. -- Do not amend a commit unless explicitly requested to do so. -- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. -- Be cautious when using git. **NEVER** use destructive commands like \`git reset --hard\` or \`git checkout --\` unless specifically requested or approved by the user. -- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands. - -## Agents.md - -- If the directory you are in has an AGENTS.md file, it is provided to you at the top, and you don't have to search for it. -- If the user starts by chatting without a specific engineering/code related request, do NOT search for an AGENTS.md. Only do so once there is a relevant request. - -# Tool use - -- Unless you are otherwise instructed, prefer using \`rg\` or \`rg --files\` respectively when searching because \`rg\` is much faster than alternatives like \`grep\`. If the \`rg\` command is not found, then use alternatives. -- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). - -- Use the plan tool to explain to the user what you are going to do - - Only use it for more complex tasks, do not use it for straightforward tasks (roughly the easiest 40%). - - Do not make single-step plans. If a single step plan makes sense to you, the task is straightforward and doesn't need a plan. - - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. - -# Sub-agents -If \`spawn_agent\` is unavailable or fails, ignore this section and proceed solo. - -## Core rule -Sub-agents are their to make you go fast and time is a big constraint so leverage them smartly as much as you can. - -## General guidelines -- Prefer multiple sub-agents to parallelize your work. Time is a constraint so parallelism resolve the task faster. -- If sub-agents are running, **wait for them before yielding**, unless the user asks an explicit question. - - If the user asks a question, answer it first, then continue coordinating sub-agents. -- When you ask sub-agent to do the work for you, your only role becomes to coordinate them. Do not perform the actual work while they are working. -- When you have plan with multiple step, process them in parallel by spawning one agent per step when this is possible. -- Choose the correct agent type. - -## Flow -1. Understand the task. -2. Spawn the optimal necessary sub-agents. -3. Coordinate them via wait / send_input. -4. Iterate on this. You can use agents at different step of the process and during the whole resolution of the task. Never forget to use them. -5. Ask the user before shutting sub-agents down unless you need to because you reached the agent limit. -` - -const CODEX_ORCHESTRATOR_AGENT_PROMPT_FALLBACK = stripLeadingFrontmatter(CODEX_ORCHESTRATOR_AGENT_TEMPLATE) - -function stripLeadingFrontmatter(content: string): string { - const trimmed = content.trimStart() - if (!trimmed.startsWith("---\n")) return content.trim() - const closing = trimmed.indexOf("\n---\n", 4) - if (closing < 0) return content.trim() - return trimmed.slice(closing + "\n---\n".length).trim() -} - -function composeTemplateFromPrompt(prompt: string): string { - const normalizedPrompt = stripLeadingFrontmatter(prompt) - const replacedPrompt = replaceCodexToolCallsForOpenCode(normalizedPrompt) ?? normalizedPrompt - return `${ORCHESTRATOR_FRONTMATTER}\n${replacedPrompt}\n` -} - -async function resolveOrchestratorAgentTemplate(cacheDir?: string): Promise { - const prompts = await refreshCachedCodexPrompts({ cacheDir }) - const upstream = prompts.orchestrator?.trim() - if (upstream) return composeTemplateFromPrompt(upstream) - return composeTemplateFromPrompt(CODEX_ORCHESTRATOR_AGENT_PROMPT_FALLBACK) -} - -export type InstallOrchestratorAgentInput = { - agentsDir?: string - force?: boolean - cacheDir?: string -} - -export type InstallOrchestratorAgentResult = { - agentsDir: string - filePath: string - created: boolean - updated: boolean -} - -export type ReconcileOrchestratorAgentVisibilityInput = { - agentsDir?: string - visible: boolean - force?: boolean - cacheDir?: string -} - -export type ReconcileOrchestratorAgentVisibilityResult = { - agentsDir: string - filePath: string - visible: boolean - created: boolean - updated: boolean - moved: boolean -} - -export function defaultOpencodeAgentsDir(env: Record = process.env): string { - const xdgRoot = env.XDG_CONFIG_HOME?.trim() - if (xdgRoot) { - return path.join(xdgRoot, "opencode", "agents") - } - return path.join(os.homedir(), ".config", "opencode", "agents") -} - -async function readIfExists(filePath: string): Promise { - try { - return await fs.readFile(filePath, "utf8") - } catch (error) { - if (error instanceof Error) { - // treat unreadable file as missing - } - return undefined - } -} - -async function exists(filePath: string): Promise { - return (await readIfExists(filePath)) !== undefined -} - -async function ensureTemplateFile( - filePath: string, - force: boolean, - cacheDir?: string -): Promise<{ created: boolean; updated: boolean }> { - const existingContent = await readIfExists(filePath) - if (existingContent !== undefined && !force) { - return { created: false, updated: false } - } - - const template = await resolveOrchestratorAgentTemplate(cacheDir) - if (existingContent === template) { - return { created: false, updated: false } - } - - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, template, { encoding: "utf8", mode: 0o600 }) - - return { - created: existingContent === undefined, - updated: existingContent !== undefined - } -} - -export async function installOrchestratorAgent( - input: InstallOrchestratorAgentInput = {} -): Promise { - const agentsDir = input.agentsDir ?? defaultOpencodeAgentsDir() - const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) - const ensured = await ensureTemplateFile(filePath, input.force === true, input.cacheDir) - - return { - agentsDir, - filePath, - created: ensured.created, - updated: ensured.updated - } -} - -export async function reconcileOrchestratorAgentVisibility( - input: ReconcileOrchestratorAgentVisibilityInput -): Promise { - const agentsDir = input.agentsDir ?? defaultOpencodeAgentsDir() - const enabledPath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) - const disabledPath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED) - const force = input.force === true - const enabledExists = await exists(enabledPath) - const disabledExists = await exists(disabledPath) - - if (input.visible) { - if (enabledExists) { - const ensured = await ensureTemplateFile(enabledPath, force, input.cacheDir) - return { - agentsDir, - filePath: enabledPath, - visible: true, - created: ensured.created, - updated: ensured.updated, - moved: false - } - } - - if (disabledExists) { - await fs.mkdir(path.dirname(enabledPath), { recursive: true }) - await fs.rename(disabledPath, enabledPath) - const ensured = await ensureTemplateFile(enabledPath, force, input.cacheDir) - return { - agentsDir, - filePath: enabledPath, - visible: true, - created: ensured.created, - updated: ensured.updated, - moved: true - } - } - - const ensured = await ensureTemplateFile(enabledPath, force, input.cacheDir) - return { - agentsDir, - filePath: enabledPath, - visible: true, - created: ensured.created, - updated: ensured.updated, - moved: false - } - } - - if (disabledExists) { - const ensured = await ensureTemplateFile(disabledPath, force, input.cacheDir) - return { - agentsDir, - filePath: disabledPath, - visible: false, - created: ensured.created, - updated: ensured.updated, - moved: false - } - } - - if (enabledExists) { - await fs.mkdir(path.dirname(disabledPath), { recursive: true }) - await fs.rename(enabledPath, disabledPath) - const ensured = await ensureTemplateFile(disabledPath, force, input.cacheDir) - return { - agentsDir, - filePath: disabledPath, - visible: false, - created: ensured.created, - updated: ensured.updated, - moved: true - } - } - - const ensured = await ensureTemplateFile(disabledPath, force, input.cacheDir) - return { - agentsDir, - filePath: disabledPath, - visible: false, - created: ensured.created, - updated: ensured.updated, - moved: false - } -} diff --git a/schemas/codex-config.schema.json b/schemas/codex-config.schema.json index c32d8b2..6b8f493 100644 --- a/schemas/codex-config.schema.json +++ b/schemas/codex-config.schema.json @@ -65,13 +65,12 @@ "headerTransformDebug": { "type": "boolean" }, - "pidOffset": { - "type": "boolean" - }, - "collaborationProfile": { - "type": "boolean" + "ultra": { + "type": "boolean", + "default": false, + "description": "Work in progress. Enables catalog-gated Ultra agent mode." }, - "orchestratorSubagents": { + "pidOffset": { "type": "boolean" } } diff --git a/scripts/coverage-ratchet.baseline.json b/scripts/coverage-ratchet.baseline.json index 7137898..cec2cb8 100644 --- a/scripts/coverage-ratchet.baseline.json +++ b/scripts/coverage-ratchet.baseline.json @@ -49,10 +49,10 @@ "statements": 83.87 }, "lib/codex-cache-layout.ts": { - "lines": 93.87, - "branches": 94.44, - "functions": 90.9, - "statements": 93.87 + "lines": 92.68, + "branches": 93.75, + "functions": 88.88, + "statements": 92.68 }, "lib/codex-native.ts": { "lines": 94.46, @@ -60,12 +60,6 @@ "functions": 88.57, "statements": 94.46 }, - "lib/codex-prompts-cache.ts": { - "lines": 98.94, - "branches": 86.95, - "functions": 100, - "statements": 98.94 - }, "lib/codex-quota-fetch.ts": { "lines": 80.89, "branches": 71.08, @@ -354,12 +348,6 @@ "functions": 91.3, "statements": 67.86 }, - "lib/codex-native/collaboration.ts": { - "lines": 95.05, - "branches": 80.61, - "functions": 100, - "statements": 95.05 - }, "lib/codex-native/oauth-auth-methods.ts": { "lines": 87.81, "branches": 68.51, @@ -397,10 +385,10 @@ "statements": 72.22 }, "lib/codex-native/openai-loader-fetch.ts": { - "lines": 90.43, - "branches": 88.37, + "lines": 91.64, + "branches": 86.8, "functions": 83.33, - "statements": 90.43 + "statements": 91.64 }, "lib/codex-native/originator.ts": { "lines": 100, @@ -463,16 +451,16 @@ "statements": 93.15 }, "lib/config/file.ts": { - "lines": 83.08, - "branches": 78.26, + "lines": 81.87, + "branches": 75.34, "functions": 100, - "statements": 83.08 + "statements": 81.87 }, "lib/config/resolve.ts": { - "lines": 96.83, - "branches": 90.9, + "lines": 96.44, + "branches": 89.78, "functions": 100, - "statements": 96.83 + "statements": 96.44 }, "lib/config/types.ts": { "lines": 100, diff --git a/scripts/perf-profile.ts b/scripts/perf-profile.ts index 5d69c6a..91b86f8 100644 --- a/scripts/perf-profile.ts +++ b/scripts/perf-profile.ts @@ -265,7 +265,7 @@ async function benchmarkQuotaBlocking(root: string): Promise<{ latencyMs: number headerTransformDebug: false, compatInputSanitizerEnabled: false, internalCatalogScopeHeader: "x-opencode-catalog-scope-key", - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", + ultraEnabled: false, requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} diff --git a/test/agent-execution.test.ts b/test/agent-execution.test.ts index c88c99f..339b984 100644 --- a/test/agent-execution.test.ts +++ b/test/agent-execution.test.ts @@ -101,7 +101,10 @@ describe("Ultra agent execution classification", () => { const stale = resolver.resolve({ sessionID: "session", agentName: "custom" }) resolver.deleteSession("session") release?.({ data: { id: "session" } }) - await stale + await expect(stale).resolves.toMatchObject({ + role: "child", + reason: "conservative_fallback" + }) await expect(resolver.resolve({ sessionID: "session", agentName: "custom" })).resolves.toMatchObject({ role: "child", @@ -157,7 +160,10 @@ describe("Ultra agent execution classification", () => { it("resolves lineage through the plugin chat hook for Ultra turns", async () => { const get = vi.fn(async () => ({ data: { id: "root" } })) - const hooks = await CodexAuthPlugin({ client: { session: { get } } } as never, { mode: "codex" }) + const hooks = await CodexAuthPlugin({ client: { session: { get } } } as never, { + mode: "codex", + ultraEnabled: true + }) const output = { temperature: 0, topP: 1, topK: 0, options: {} as Record } await hooks["chat.params"]?.( diff --git a/test/codex-native-chat-hooks.test.ts b/test/codex-native-chat-hooks.test.ts index e806461..f478645 100644 --- a/test/codex-native-chat-hooks.test.ts +++ b/test/codex-native-chat-hooks.test.ts @@ -49,9 +49,7 @@ describe("codex-native chat hooks instruction source order", () => { } } ], - spoofMode: "codex", - collaborationProfileEnabled: false, - orchestratorSubagentsEnabled: false + spoofMode: "codex" }) expect(output.options.instructions).toBe("Cached template instructions") @@ -94,9 +92,7 @@ describe("codex-native chat hooks instruction source order", () => { } } ], - spoofMode: "codex", - collaborationProfileEnabled: false, - orchestratorSubagentsEnabled: false + spoofMode: "codex" }) expect(output.options.instructions).toBe("Cached template instructions") @@ -127,9 +123,7 @@ describe("codex-native chat hooks instruction source order", () => { output: output as any, lastCatalogModels: undefined, behaviorSettings: { global: { reasoningMode: "pro" } }, - spoofMode: "codex", - collaborationProfileEnabled: false, - orchestratorSubagentsEnabled: false + spoofMode: "codex" }) expect(output.options.reasoningMode).toBeUndefined() diff --git a/test/codex-native-collaboration-runtime.test.ts b/test/codex-native-collaboration-runtime.test.ts deleted file mode 100644 index 77d5a86..0000000 --- a/test/codex-native-collaboration-runtime.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { CodexAuthPlugin } from "../lib/codex-native" - -async function runChatParams(input: { - pluginOptions?: Record - agent: string - sessionID: string - modelOptions?: Record -}) { - const hooks = await CodexAuthPlugin({} as never, input.pluginOptions as never) - const chatParams = hooks["chat.params"] - expect(chatParams).toBeTypeOf("function") - - const output: any = { - temperature: 0, - topP: 1, - topK: 0, - options: {} - } - - await chatParams?.( - { - sessionID: input.sessionID, - agent: input.agent, - provider: {}, - message: {}, - model: { - providerID: "openai", - capabilities: { toolcall: true }, - options: input.modelOptions ?? {} - } - } as never, - output - ) - - return output -} - -async function runChatHeaders(input: { pluginOptions?: Record; agent: string; sessionID: string }) { - const hooks = await CodexAuthPlugin({} as never, input.pluginOptions as never) - const chatHeaders = hooks["chat.headers"] - expect(chatHeaders).toBeTypeOf("function") - - const output: any = { headers: {} as Record } - await chatHeaders?.( - { - sessionID: input.sessionID, - agent: input.agent, - model: { providerID: "openai", options: {} } - } as never, - output - ) - return output.headers -} - -describe("codex-native collaboration runtime", () => { - it("injects collaboration instructions for Codex agents by default in codex mode", async () => { - const output = await runChatParams({ - pluginOptions: { spoofMode: "codex", mode: "codex" }, - sessionID: "ses_codex_mode_no_collab", - agent: "Codex Plan", - modelOptions: { - codexInstructions: "Catalog instructions", - codexRuntimeDefaults: { - defaultReasoningEffort: "high" - } - } - }) - - expect(output.options.instructions).toContain("Catalog instructions") - expect(output.options.instructions).toContain("# Plan Mode") - expect(output.options.instructions).not.toContain("request_user_input") - expect(output.options.instructions).not.toContain("Tooling Compatibility (OpenCode)") - }) - - it("replaces build agent instructions in codex mode without execute preset", async () => { - const output = await runChatParams({ - pluginOptions: { spoofMode: "codex" }, - sessionID: "ses_native_agent_passthrough", - agent: "build", - modelOptions: { - codexInstructions: "Catalog instructions", - codexRuntimeDefaults: { - defaultReasoningEffort: "high" - } - } - }) - - expect(output.options.instructions).toContain("Catalog instructions") - expect(output.options.instructions).not.toContain("# Collaboration Style: Execute") - expect(output.options.instructions).not.toContain("# Plan Mode") - expect(output.options.reasoningEffort).toBe("high") - }) - - it("keeps build-agent instruction replacement active when collaboration profile is disabled", async () => { - const output = await runChatParams({ - pluginOptions: { - spoofMode: "codex", - collaborationProfileEnabled: false - }, - sessionID: "ses_build_no_collab", - agent: "build", - modelOptions: { - codexInstructions: "Use spawn_agent and send_input with write_stdin", - codexRuntimeDefaults: { - defaultReasoningEffort: "high" - } - } - }) - - expect(output.options.instructions).toContain("task") - expect(output.options.instructions).not.toContain("spawn_agent") - expect(output.options.instructions).not.toContain("send_input") - expect(output.options.instructions).not.toContain("write_stdin") - }) - - it("sets collaboration headers for Codex agents by default in codex mode", async () => { - const headers = await runChatHeaders({ - pluginOptions: { spoofMode: "codex", mode: "codex" }, - sessionID: "ses_codex_headers_no_collab", - agent: "Codex Review" - }) - - expect(headers["x-openai-subagent"]).toBe("review") - expect(headers["x-opencode-collaboration-mode-kind"]).toBe("code") - }) - - it("does not set codex collaboration headers for native OpenCode agents", async () => { - const headers = await runChatHeaders({ - pluginOptions: { spoofMode: "codex" }, - sessionID: "ses_native_headers_passthrough", - agent: "explore" - }) - - expect(headers["x-openai-subagent"]).toBeUndefined() - expect(headers["x-opencode-collaboration-mode-kind"]).toBeUndefined() - }) - - it("allows collaboration headers in native mode without runtime instruction injection", async () => { - const params = await runChatParams({ - pluginOptions: { - spoofMode: "native", - mode: "native", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }, - sessionID: "ses_native_collab_params", - agent: "orchestrator", - modelOptions: { - codexInstructions: "Catalog instructions" - } - }) - - expect(params.options.instructions).toContain("Catalog instructions") - expect(params.options.instructions).not.toContain("# Sub-agents") - expect(params.options.instructions).not.toContain("# Collaboration Style: Execute") - - const headers = await runChatHeaders({ - pluginOptions: { - spoofMode: "native", - mode: "native", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }, - sessionID: "ses_native_collab_headers", - agent: "Codex Review" - }) - expect(headers["x-opencode-collaboration-mode-kind"]).toBe("code") - expect(headers["x-openai-subagent"]).toBe("review") - }) - - it("injects plan-mode collaboration instructions when collaboration profile is enabled", async () => { - const output = await runChatParams({ - pluginOptions: { - spoofMode: "codex", - mode: "codex", - collaborationProfileEnabled: true - }, - sessionID: "ses_plan_collab_enabled", - agent: "plan", - modelOptions: { - codexInstructions: "Catalog instructions" - } - }) - - expect(output.options.instructions).toContain("Catalog instructions") - expect(output.options.instructions).toContain("# Plan Mode") - }) - - it("does not append orchestrator profile instructions at runtime", async () => { - const output = await runChatParams({ - pluginOptions: { - spoofMode: "codex", - mode: "codex", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }, - sessionID: "ses_orchestrator_collab_enabled", - agent: "orchestrator", - modelOptions: { - codexInstructions: "Catalog instructions" - } - }) - - expect(output.options.instructions).toContain("Catalog instructions") - }) - - it("preserves orchestrator instructions instead of replacing them with model base instructions", async () => { - const hooks = await CodexAuthPlugin( - {} as never, - { - spoofMode: "codex", - mode: "codex", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - } as never - ) - const chatParams = hooks["chat.params"] - expect(chatParams).toBeTypeOf("function") - - const output: any = { - temperature: 0, - topP: 1, - topK: 0, - options: { - instructions: [ - "You are Codex, a coding agent based on GPT-5.", - "", - "# Sub-agents", - "If `spawn_agent` is unavailable or fails, ignore this section and proceed solo." - ].join("\n") - } - } - - await chatParams?.( - { - sessionID: "ses_orchestrator_preserve", - agent: "orchestrator", - provider: {}, - message: {}, - model: { - providerID: "openai", - capabilities: { toolcall: true }, - options: { - codexInstructions: "Catalog instructions" - } - } - } as never, - output - ) - - expect(output.options.instructions).toContain("You are Codex, a coding agent based on GPT-5.") - expect(output.options.instructions).toContain("# Sub-agents") - expect(output.options.instructions).toContain("spawn_agent") - expect(output.options.instructions).not.toContain("Tooling Compatibility (OpenCode)") - expect(output.options.instructions).not.toContain("Catalog instructions") - }) - - it("does not set collaboration headers for legacy Orchestrator agent names", async () => { - const headers = await runChatHeaders({ - pluginOptions: { spoofMode: "codex" }, - sessionID: "ses_legacy_orchestrator_passthrough", - agent: "Orchestrator-Plan" - }) - - expect(headers["x-openai-subagent"]).toBeUndefined() - expect(headers["x-opencode-collaboration-mode-kind"]).toBeUndefined() - }) - - it("sets plan-mode collaboration headers when collaboration profile is enabled", async () => { - const headers = await runChatHeaders({ - pluginOptions: { - spoofMode: "codex", - mode: "codex", - collaborationProfileEnabled: true - }, - sessionID: "ses_plan_collab_headers", - agent: "plan" - }) - - expect(headers["x-opencode-collaboration-mode-kind"]).toBe("plan") - expect(headers["x-opencode-collaboration-agent-kind"]).toBe("plan") - expect(headers["x-openai-subagent"]).toBeUndefined() - }) - - it("sets subagent and collaboration headers for codex review helpers", async () => { - const headers = await runChatHeaders({ - pluginOptions: { - spoofMode: "codex", - mode: "codex", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }, - sessionID: "ses_review_collab_headers", - agent: "Codex Review" - }) - - expect(headers["x-opencode-collaboration-mode-kind"]).toBe("code") - expect(headers["x-opencode-collaboration-agent-kind"]).toBe("code") - expect(headers["x-openai-subagent"]).toBe("review") - }) - - it("sets orchestrator collaboration agent header for orchestrator profile", async () => { - const headers = await runChatHeaders({ - pluginOptions: { - spoofMode: "codex", - mode: "codex", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }, - sessionID: "ses_orchestrator_collab_headers", - agent: "orchestrator" - }) - - expect(headers["x-opencode-collaboration-mode-kind"]).toBe("code") - expect(headers["x-opencode-collaboration-agent-kind"]).toBe("orchestrator") - }) -}) diff --git a/test/codex-native-collaboration.test.ts b/test/codex-native-collaboration.test.ts deleted file mode 100644 index f4040bc..0000000 --- a/test/codex-native-collaboration.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { - CODEX_PLAN_MODE_INSTRUCTIONS_FALLBACK, - hasCodexToolNameMarkers, - isOrchestratorInstructions, - mergeInstructions, - replaceCodexToolCallsForOpenCode, - resolveCollaborationInstructions, - resolveCollaborationProfile, - resolveSubagentHeaderValue -} from "../lib/codex-native/collaboration" - -describe("codex collaboration profile", () => { - it("keeps fallback plan instructions runtime-safe", () => { - expect(CODEX_PLAN_MODE_INSTRUCTIONS_FALLBACK).toContain("# Plan Mode") - expect(CODEX_PLAN_MODE_INSTRUCTIONS_FALLBACK).not.toContain("request_user_input") - expect(CODEX_PLAN_MODE_INSTRUCTIONS_FALLBACK).not.toContain("") - }) - - it("maps plan agent to plan mode", () => { - const profile = resolveCollaborationProfile("plan") - expect(profile.enabled).toBe(true) - expect(profile.kind).toBe("plan") - }) - - it("maps orchestrator agent to code mode", () => { - const profile = resolveCollaborationProfile("Orchestrator") - expect(profile.enabled).toBe(true) - expect(profile.kind).toBe("code") - }) - - it("does not map build agent to plan preset", () => { - const profile = resolveCollaborationProfile("build") - expect(profile.instructionPreset).toBeUndefined() - }) - - it("maps codex review helper to review subagent header", () => { - expect(resolveSubagentHeaderValue("Codex Review")).toBe("review") - }) - - it("does not emit subagent header for plan/orchestrator primaries", () => { - expect(resolveSubagentHeaderValue("plan")).toBeUndefined() - expect(resolveSubagentHeaderValue("orchestrator")).toBeUndefined() - }) - - it("selects mode instructions by collaboration kind", () => { - const instructions = { - plan: "PLAN", - code: "CODE" - } - expect(resolveCollaborationInstructions("plan", instructions)).toBe("PLAN") - expect(resolveCollaborationInstructions("code", instructions)).toBe("CODE") - }) - - it("merges instructions once without duplicating", () => { - const merged = mergeInstructions("base", "extra") - expect(merged).toBe("base\n\nextra") - expect(mergeInstructions(merged, "extra")).toBe("base\n\nextra") - }) - - it("detects codex tool names and replaces with OpenCode tool names", () => { - const codexInstructions = "Use spawn_agent and send_input to coordinate workers." - expect(hasCodexToolNameMarkers(codexInstructions)).toBe(true) - expect(replaceCodexToolCallsForOpenCode(codexInstructions)).toContain("task") - expect(replaceCodexToolCallsForOpenCode(codexInstructions)).not.toContain("spawn_agent") - - const replaced = replaceCodexToolCallsForOpenCode(codexInstructions) - expect(replaced).toContain("task") - - const writeStdin = "If needed, call write_stdin to continue the worker session." - expect(hasCodexToolNameMarkers(writeStdin)).toBe(true) - expect(replaceCodexToolCallsForOpenCode(writeStdin)).toContain("task") - - const plainInstructions = "Use available tools in this runtime." - expect(hasCodexToolNameMarkers(plainInstructions)).toBe(false) - expect(replaceCodexToolCallsForOpenCode(plainInstructions)).toBe(plainInstructions) - }) - - it("detects orchestrator-style upstream instructions", () => { - expect( - isOrchestratorInstructions( - [ - "You are Codex, a coding agent based on GPT-5.", - "", - "# Sub-agents", - "If `spawn_agent` is unavailable or fails, ignore this section and proceed solo." - ].join("\n") - ) - ).toBe(true) - expect( - isOrchestratorInstructions( - [ - "---", - "description: Codex-style orchestration profile for parallel delegation and synthesis.", - "mode: primary", - "---" - ].join("\n") - ) - ).toBe(true) - expect(isOrchestratorInstructions("Catalog instructions\n\n# Plan Mode (Conversational)")).toBe(false) - expect( - isOrchestratorInstructions( - [ - "Any lead-in", - "# Sub-agents", - "If spawn_agent fails, proceed solo.", - "Coordinate subagent workers and synthesize output." - ].join("\n") - ) - ).toBe(true) - expect( - isOrchestratorInstructions( - ["Any lead-in", "# Sub-agents", "Coordinate them via wait / send_input.", "General guidance only."].join("\n") - ) - ).toBe(true) - expect( - isOrchestratorInstructions( - [ - "Team playbook", - "# Sub-agents", - "Ask a subagent to summarize docs for context.", - "An orchestrator can help planning discussions." - ].join("\n") - ) - ).toBe(false) - expect( - isOrchestratorInstructions( - [ - "Operations note", - "# Sub-agents", - "Please wait for approval before deploy.", - "Then send_input from the release form." - ].join("\n") - ) - ).toBe(false) - }) -}) diff --git a/test/codex-native-config-variants.test.ts b/test/codex-native-config-variants.test.ts index 92ec210..7916f25 100644 --- a/test/codex-native-config-variants.test.ts +++ b/test/codex-native-config-variants.test.ts @@ -142,7 +142,7 @@ describe("codex-native config variants", () => { ) const { CodexAuthPlugin } = await import("../lib/codex-native") - const hooks = await CodexAuthPlugin({} as never) + const hooks = await CodexAuthPlugin({} as never, { ultraEnabled: true }) const config = makeConfig() await hooks.config?.(config as never) @@ -168,6 +168,11 @@ describe("codex-native config variants", () => { include: ["reasoning.encrypted_content"] }) expect(config.provider.openai.models["gpt-5-codex-mini"].variants.low).toEqual({ disabled: true }) + + const disabledHooks = await CodexAuthPlugin({} as never) + const disabledConfig = makeConfig() + await disabledHooks.config?.(disabledConfig as never) + expect(disabledConfig.provider.openai.models["gpt-5.4"].variants.ultra).toEqual({ disabled: true }) expect(config.provider.openai.models["gpt-5-codex-mini"].variants.medium).toEqual({ reasoningEffort: "medium", reasoningSummary: "auto", diff --git a/test/codex-native-in-vivo-instructions.test.ts b/test/codex-native-in-vivo-instructions.test.ts index e88feb2..1b43d18 100644 --- a/test/codex-native-in-vivo-instructions.test.ts +++ b/test/codex-native-in-vivo-instructions.test.ts @@ -18,64 +18,12 @@ describe("codex-native in-vivo instruction replacement", () => { expect(result.outboundUserAgent).toMatch(/^codex_/) }) - it("keeps catalog instructions for orchestrator requests in runtime transforms", async () => { - const result = await runCodexInVivoInstructionProbe({ - hostInstructions: "OpenCode Host Instructions", - personalityKey: "vivo_persona", - personalityText: "Vivo Persona Voice", - agent: "orchestrator", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }) - - expect(result.outboundInstructions).toContain("Base Vivo Persona Voice") - }) - - it("keeps orchestrator agent instructions instead of replacing them with model base instructions", async () => { - const result = await runCodexInVivoInstructionProbe({ - hostInstructions: [ - "You are Codex, a coding agent based on GPT-5.", - "", - "# Sub-agents", - "If `spawn_agent` is unavailable or fails, ignore this section and proceed solo." - ].join("\n"), - personalityKey: "vivo_persona", - personalityText: "Vivo Persona Voice", - agent: "orchestrator", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }) - - expect(result.preflightInstructions).toContain("You are Codex, a coding agent based on GPT-5.") - expect(result.outboundInstructions).toContain("You are Codex, a coding agent based on GPT-5.") - expect(result.outboundInstructions).toContain("# Sub-agents") - expect(result.outboundInstructions).not.toContain("Base Vivo Persona Voice") - }) - - it("uses plan mode instructions from codex source with tool replacements", async () => { - const result = await runCodexInVivoInstructionProbe({ - hostInstructions: "OpenCode Host Instructions", - personalityKey: "vivo_persona", - personalityText: "Vivo Persona Voice", - agent: "plan", - collaborationProfileEnabled: true - }) - - expect(result.outboundInstructions).toContain("# Plan Mode (Conversational)") - expect(result.outboundInstructions).toContain("You may explore and execute **non-mutating** actions") - expect(result.outboundInstructions).toContain( - "Before asking the user any question, perform at least one targeted non-mutating exploration pass" - ) - expect(result.outboundInstructions).toContain("request_user_input") - }) - it("replaces build agent instructions in codex mode", async () => { const result = await runCodexInVivoInstructionProbe({ hostInstructions: "OpenCode Host Instructions", personalityKey: "vivo_persona", personalityText: "Vivo Persona Voice", - agent: "build", - collaborationProfileEnabled: true + agent: "build" }) expect(result.outboundInstructions).toContain("Base Vivo Persona Voice") diff --git a/test/codex-native-session-affinity.test.ts b/test/codex-native-session-affinity.test.ts index dc9354d..7e75b35 100644 --- a/test/codex-native-session-affinity.test.ts +++ b/test/codex-native-session-affinity.test.ts @@ -6,7 +6,7 @@ afterEach(() => { }) describe("codex-native session affinity persistence", () => { - it("skips affinity persistence for subagent requests", async () => { + it("skips affinity persistence for Ultra child requests", async () => { vi.resetModules() const auth = { @@ -108,7 +108,7 @@ describe("codex-native session affinity persistence", () => { ) const { CodexAuthPlugin } = await import("../lib/codex-native") - const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex" }) + const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex", ultraEnabled: true }) const loader = hooks.auth?.loader if (!loader) throw new Error("Missing auth loader") @@ -120,7 +120,16 @@ describe("codex-native session affinity persistence", () => { method: "POST", headers: { "content-type": "application/json", - "x-openai-subagent": "review", + "x-opencode-ultra-state": JSON.stringify({ + selected: true, + logicalEffort: "ultra", + wireEffort: "max", + eligible: true, + delegationPolicy: "explicit_request_only", + agentRole: "child", + agentReason: "conservative_fallback", + reason: "eligible" + }), session_id: "ses_subagent_1" }, body: JSON.stringify({ diff --git a/test/codex-native-snapshots.test.ts b/test/codex-native-snapshots.test.ts index 29eb166..f029724 100644 --- a/test/codex-native-snapshots.test.ts +++ b/test/codex-native-snapshots.test.ts @@ -358,7 +358,7 @@ describe("codex-native snapshots", () => { expect(capturedSessionId).toBe("ses_native_fetch_1") }) - it("strips internal collaboration header before outbound fetch and omits snapshot metadata", async () => { + it("strips internal Ultra state before outbound fetch", async () => { vi.resetModules() const auth = { @@ -446,7 +446,7 @@ describe("codex-native snapshots", () => { "fetch", vi.fn(async (input: RequestInfo | URL) => { const request = input as Request - seenInternalHeader = request.headers.get("x-opencode-collaboration-mode-kind") ?? "" + seenInternalHeader = request.headers.get("x-opencode-ultra-state") ?? "" return new Response("ok", { status: 200 }) }) ) @@ -454,6 +454,7 @@ describe("codex-native snapshots", () => { const { CodexAuthPlugin } = await import("../lib/codex-native") const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex", + ultraEnabled: true, headerTransformDebug: true }) const loader = hooks.auth?.loader @@ -475,7 +476,16 @@ describe("codex-native snapshots", () => { headers: { "content-type": "application/json", originator: "codex_cli_rs", - "x-opencode-collaboration-mode-kind": "plan" + "x-opencode-ultra-state": JSON.stringify({ + selected: true, + logicalEffort: "ultra", + wireEffort: "max", + eligible: true, + delegationPolicy: "explicit_request_only", + agentRole: "child", + agentReason: "conservative_fallback", + reason: "eligible" + }) }, body: JSON.stringify({ model: "gpt-5.2-codex", input: "hi" }) }) @@ -492,8 +502,6 @@ describe("codex-native snapshots", () => { expect(beforeTransformCall).toBeDefined() expect(afterTransformCall).toBeDefined() expect(beforeAuthCall).toBeDefined() - expect(afterTransformCall?.[2]?.collaborationModeKind).toBeUndefined() - expect(beforeAuthCall?.[2]?.collaborationModeKind).toBeUndefined() expect(seenInternalHeader).toBe("") }) diff --git a/test/codex-prompts-cache.test.ts b/test/codex-prompts-cache.test.ts deleted file mode 100644 index 52e3910..0000000 --- a/test/codex-prompts-cache.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" - -import { describe, expect, it, vi } from "vitest" - -import { - CODEX_ORCHESTRATOR_PROMPT_URL, - CODEX_PLAN_PROMPT_URL, - CODEX_PROMPTS_CACHE_FILE, - CODEX_PROMPTS_CACHE_META_FILE, - readCachedCodexPrompts, - refreshCachedCodexPrompts -} from "../lib/codex-prompts-cache" - -async function makeCacheDir(): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-prompts-cache-")) -} - -describe("codex prompts cache", () => { - it("refreshes and writes cache plus meta files", async () => { - const cacheDir = await makeCacheDir() - const fetchImpl = vi.fn(async (url: string | URL | Request) => { - const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url - if (endpoint === CODEX_ORCHESTRATOR_PROMPT_URL) { - return new Response("You are Codex, a coding agent based on GPT-5.", { - status: 200, - headers: { etag: 'W/"orch-etag"' } - }) - } - if (endpoint === CODEX_PLAN_PROMPT_URL) { - return new Response("# Plan Mode (Conversational)", { - status: 200, - headers: { etag: 'W/"plan-etag"' } - }) - } - throw new Error(`unexpected URL: ${endpoint}`) - }) - - const prompts = await refreshCachedCodexPrompts({ - cacheDir, - now: () => 1234, - fetchImpl, - forceRefresh: true - }) - expect(prompts.orchestrator).toContain("You are Codex") - expect(prompts.plan).toContain("# Plan Mode (Conversational)") - - const cacheRaw = await fs.readFile(path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE), "utf8") - const cache = JSON.parse(cacheRaw) as { - fetchedAt?: number - source?: string - prompts?: { orchestrator?: string; plan?: string } - } - expect(cache.fetchedAt).toBe(1234) - expect(cache.source).toBe("github") - expect(cache.prompts?.orchestrator).toContain("You are Codex") - expect(cache.prompts?.plan).toContain("# Plan Mode (Conversational)") - - const metaRaw = await fs.readFile(path.join(cacheDir, CODEX_PROMPTS_CACHE_META_FILE), "utf8") - const meta = JSON.parse(metaRaw) as { - lastChecked?: number - urls?: { orchestrator?: string; plan?: string } - etags?: { orchestrator?: string; plan?: string } - } - expect(meta.lastChecked).toBe(1234) - expect(meta.urls?.orchestrator).toBe(CODEX_ORCHESTRATOR_PROMPT_URL) - expect(meta.urls?.plan).toBe(CODEX_PLAN_PROMPT_URL) - expect(meta.etags?.orchestrator).toBe('W/"orch-etag"') - expect(meta.etags?.plan).toBe('W/"plan-etag"') - }) - - it("serves fresh cache without refetch", async () => { - const cacheDir = await makeCacheDir() - await fs.writeFile( - path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE), - `${JSON.stringify( - { - fetchedAt: 500, - source: "github", - prompts: { - orchestrator: "orch cached", - plan: "plan cached" - } - }, - null, - 2 - )}\n`, - "utf8" - ) - await fs.writeFile( - path.join(cacheDir, CODEX_PROMPTS_CACHE_META_FILE), - `${JSON.stringify( - { - lastChecked: 1_000, - urls: { - orchestrator: CODEX_ORCHESTRATOR_PROMPT_URL, - plan: CODEX_PLAN_PROMPT_URL - } - }, - null, - 2 - )}\n`, - "utf8" - ) - - const fetchImpl = vi.fn(async () => { - throw new Error("should not fetch") - }) - const prompts = await refreshCachedCodexPrompts({ - cacheDir, - now: () => 1_000 + 1000, - fetchImpl - }) - expect(prompts.orchestrator).toBe("orch cached") - expect(prompts.plan).toBe("plan cached") - expect(fetchImpl).not.toHaveBeenCalled() - }) - - it("returns cached prompts when refresh fails", async () => { - const cacheDir = await makeCacheDir() - await fs.writeFile( - path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE), - `${JSON.stringify( - { - fetchedAt: 500, - source: "github", - prompts: { - orchestrator: "orch cached", - plan: "plan cached" - } - }, - null, - 2 - )}\n`, - "utf8" - ) - await fs.writeFile( - path.join(cacheDir, CODEX_PROMPTS_CACHE_META_FILE), - `${JSON.stringify( - { - lastChecked: 1, - urls: { - orchestrator: CODEX_ORCHESTRATOR_PROMPT_URL, - plan: CODEX_PLAN_PROMPT_URL - } - }, - null, - 2 - )}\n`, - "utf8" - ) - - const fetchImpl = vi.fn(async () => { - throw new Error("network unavailable") - }) - - const prompts = await refreshCachedCodexPrompts({ - cacheDir, - now: () => 1 + 48 * 60 * 60 * 1000, - fetchImpl - }) - expect(prompts.orchestrator).toBe("orch cached") - expect(prompts.plan).toBe("plan cached") - }) - - it("reads cache with readCachedCodexPrompts", async () => { - const cacheDir = await makeCacheDir() - await fs.writeFile( - path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE), - `${JSON.stringify( - { - fetchedAt: 500, - source: "github", - prompts: { - orchestrator: "orch cached", - plan: "plan cached" - } - }, - null, - 2 - )}\n`, - "utf8" - ) - - const prompts = await readCachedCodexPrompts({ cacheDir }) - expect(prompts.orchestrator).toBe("orch cached") - expect(prompts.plan).toBe("plan cached") - }) - - it("updates lastChecked and keeps cached body on 304", async () => { - const cacheDir = await makeCacheDir() - await fs.writeFile( - path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE), - `${JSON.stringify( - { - fetchedAt: 500, - source: "github", - prompts: { - orchestrator: "orch cached", - plan: "plan cached" - } - }, - null, - 2 - )}\n`, - "utf8" - ) - await fs.writeFile( - path.join(cacheDir, CODEX_PROMPTS_CACHE_META_FILE), - `${JSON.stringify( - { - lastChecked: 1, - urls: { - orchestrator: CODEX_ORCHESTRATOR_PROMPT_URL, - plan: CODEX_PLAN_PROMPT_URL - }, - etags: { - orchestrator: 'W/"orch-prev"', - plan: 'W/"plan-prev"' - } - }, - null, - 2 - )}\n`, - "utf8" - ) - - const fetchImpl = vi.fn(async () => new Response(null, { status: 304 })) - const prompts = await refreshCachedCodexPrompts({ - cacheDir, - now: () => 2, - fetchImpl, - forceRefresh: true - }) - - expect(prompts.orchestrator).toBe("orch cached") - expect(prompts.plan).toBe("plan cached") - const cacheRaw = await fs.readFile(path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE), "utf8") - const cache = JSON.parse(cacheRaw) as { fetchedAt?: number; prompts?: { orchestrator?: string; plan?: string } } - expect(cache.fetchedAt).toBe(2) - expect(cache.prompts?.orchestrator).toBe("orch cached") - expect(cache.prompts?.plan).toBe("plan cached") - - const metaRaw = await fs.readFile(path.join(cacheDir, CODEX_PROMPTS_CACHE_META_FILE), "utf8") - const meta = JSON.parse(metaRaw) as { - lastChecked?: number - etags?: { orchestrator?: string; plan?: string } - urls?: { orchestrator?: string; plan?: string } - } - expect(meta.lastChecked).toBe(2) - expect(meta.urls?.orchestrator).toBe(CODEX_ORCHESTRATOR_PROMPT_URL) - expect(meta.urls?.plan).toBe(CODEX_PLAN_PROMPT_URL) - expect(meta.etags?.orchestrator).toBe('W/"orch-prev"') - expect(meta.etags?.plan).toBe('W/"plan-prev"') - }) - - it("deduplicates concurrent refresh calls for same cache dir", async () => { - const cacheDir = await makeCacheDir() - const resolvers = new Map void>() - let resolveStart: (() => void) | undefined - const orchestratorFetchStarted = new Promise((resolve) => { - resolveStart = resolve - }) - const fetchImpl = vi.fn(async (url: string | URL | Request) => { - const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url - if (endpoint === CODEX_ORCHESTRATOR_PROMPT_URL) { - resolveStart?.() - } - return await new Promise((resolve) => { - resolvers.set(endpoint, resolve) - }) - }) - - const first = refreshCachedCodexPrompts({ cacheDir, now: () => 9, fetchImpl, forceRefresh: false }) - await orchestratorFetchStarted - const second = refreshCachedCodexPrompts({ cacheDir, now: () => 9, fetchImpl, forceRefresh: false }) - - await vi.waitFor(() => { - expect(resolvers.size).toBe(2) - expect(fetchImpl).toHaveBeenCalledTimes(2) - }) - - resolvers.get(CODEX_ORCHESTRATOR_PROMPT_URL)?.(new Response("orch net", { status: 200 })) - resolvers.get(CODEX_PLAN_PROMPT_URL)?.(new Response("plan net", { status: 200 })) - - const [a, b] = await Promise.all([first, second]) - expect(a.orchestrator).toBe("orch net") - expect(b.orchestrator).toBe("orch net") - expect(a.plan).toBe("plan net") - expect(b.plan).toBe("plan net") - }) -}) diff --git a/test/config-file-loading.test.ts b/test/config-file-loading.test.ts index bd1526b..e1d3956 100644 --- a/test/config-file-loading.test.ts +++ b/test/config-file-loading.test.ts @@ -34,9 +34,8 @@ describe("config file loading", () => { headerSnapshots: true, headerSnapshotBodies: true, headerTransformDebug: true, - pidOffset: true, - collaborationProfile: true, - orchestratorSubagents: true + ultra: true, + pidOffset: true }, global: { reasoningSummaries: true, @@ -83,8 +82,7 @@ describe("config file loading", () => { expect(loaded.headerSnapshotBodies).toBe(true) expect(loaded.headerTransformDebug).toBe(true) expect(loaded.pidOffsetEnabled).toBe(true) - expect(loaded.collaborationProfileEnabled).toBe(true) - expect(loaded.orchestratorSubagentsEnabled).toBe(true) + expect(loaded.ultraEnabled).toBe(true) expect(loaded.rotationStrategy).toBe("hybrid") expect(loaded.promptCacheKeyStrategy).toBe("project") expect(loaded.mode).toBe("codex") @@ -316,6 +314,7 @@ describe("config file loading", () => { expect(result.created).toBe(true) expect(raw).toContain("// Optional model-specific overrides.") expect(written).toEqual(DEFAULT_CODEX_CONFIG) + expect(DEFAULT_CODEX_CONFIG.runtime.ultra).toBe(false) }) it("enforces 0600 mode when overwriting codex config", async () => { diff --git a/test/config-getters.test.ts b/test/config-getters.test.ts index 763b8bc..ed18f42 100644 --- a/test/config-getters.test.ts +++ b/test/config-getters.test.ts @@ -3,16 +3,15 @@ import { describe, expect, it } from "vitest" import { buildResolvedBehaviorSettings, cloneBehaviorSettings, - getCollaborationProfileEnabled, getCodexCompactionOverrideEnabled, getCompatInputSanitizerEnabled, getCustomModels, getModelAliasSettings, - getOrchestratorSubagentsEnabled, getProactiveRefreshBufferMs, getProactiveRefreshEnabled, getReasoningSummaryOverride, getThinkingSummariesOverride, + getUltraEnabled, getRemapDeveloperMessagesToUserEnabled, resolveConfig } from "../lib/config" @@ -189,24 +188,11 @@ describe("config", () => { expect(getCodexCompactionOverrideEnabled({ mode: "codex" })).toBe(true) }) - it("defaults collaboration gates to codex-on, native-off", () => { - expect(getCollaborationProfileEnabled({ mode: "native" })).toBe(false) - expect(getCollaborationProfileEnabled({ mode: "codex" })).toBe(true) - expect(getOrchestratorSubagentsEnabled({ mode: "native" })).toBe(false) - expect(getOrchestratorSubagentsEnabled({ mode: "codex" })).toBe(true) - }) - - it("allows overriding collaboration gates in any mode", () => { - expect(getCollaborationProfileEnabled({ mode: "native", collaborationProfileEnabled: true })).toBe(true) - expect(getCollaborationProfileEnabled({ mode: "codex", collaborationProfileEnabled: false })).toBe(false) - expect(getCollaborationProfileEnabled({ mode: "codex", collaborationProfileEnabled: true })).toBe(true) - expect( - getOrchestratorSubagentsEnabled({ - mode: "native", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true - }) - ).toBe(true) + it("keeps the Ultra WIP disabled unless explicitly enabled", () => { + expect(getUltraEnabled({})).toBe(false) + expect(getUltraEnabled({ mode: "codex" })).toBe(false) + expect(getUltraEnabled({ ultraEnabled: false })).toBe(false) + expect(getUltraEnabled({ ultraEnabled: true })).toBe(true) }) it("allows enabling codex compaction override in native mode", () => { diff --git a/test/config-loading-resolve.test.ts b/test/config-loading-resolve.test.ts index 2bbeb52..27109e7 100644 --- a/test/config-loading-resolve.test.ts +++ b/test/config-loading-resolve.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest" import { getBehaviorSettings, - getCollaborationProfileEnabled, getCodexCompactionOverrideEnabled, getCompatInputSanitizerEnabled, getCustomModels, @@ -12,7 +11,6 @@ import { getShareableDebugEnabled, getHeaderTransformDebugEnabled, getMode, - getOrchestratorSubagentsEnabled, getPersonality, getPidOffsetEnabled, getPromptCacheKeyStrategy, @@ -21,6 +19,7 @@ import { getRemapDeveloperMessagesToUserEnabled, getRotationStrategy, getSpoofMode, + getUltraEnabled, resolveConfig } from "../lib/config" @@ -200,40 +199,21 @@ describe("config loading", () => { expect(getShareableDebugEnabled(cfg)).toBe(true) }) - it("parses collaboration profile gate from env", () => { + it("parses the Ultra WIP gate from env and defaults it off", () => { + const defaults = resolveConfig({ env: {} }) const enabled = resolveConfig({ env: { - OPENCODE_OPENAI_MULTI_MODE: "codex", - OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "1" - } - }) - const disabled = resolveConfig({ - env: { - OPENCODE_OPENAI_MULTI_MODE: "native", - OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "1" - } - }) - expect(getCollaborationProfileEnabled(enabled)).toBe(true) - expect(getCollaborationProfileEnabled(disabled)).toBe(true) - }) - - it("parses orchestrator subagent gate from env", () => { - const enabled = resolveConfig({ - env: { - OPENCODE_OPENAI_MULTI_MODE: "codex", - OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "1", - OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS: "1" + OPENCODE_OPENAI_MULTI_ULTRA: "1" } }) const disabled = resolveConfig({ env: { - OPENCODE_OPENAI_MULTI_MODE: "codex", - OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "0", - OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS: "0" + OPENCODE_OPENAI_MULTI_ULTRA: "0" } }) - expect(getOrchestratorSubagentsEnabled(enabled)).toBe(true) - expect(getOrchestratorSubagentsEnabled(disabled)).toBe(false) + expect(getUltraEnabled(defaults)).toBe(false) + expect(getUltraEnabled(enabled)).toBe(true) + expect(getUltraEnabled(disabled)).toBe(false) }) it("reads personality + behavior settings from file config", () => { diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index a550f39..3a3ad57 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -12,7 +12,8 @@ describe("config validation", () => { const result = validateConfigFileObject({ runtime: { promptCacheKeyStrategy: "bad", - shareableDebug: "yes" + shareableDebug: "yes", + ultra: "yes" }, global: { reasoningMode: "PRO", @@ -25,6 +26,7 @@ describe("config validation", () => { expect.arrayContaining([ expect.stringContaining("runtime.promptCacheKeyStrategy"), expect.stringContaining("runtime.shareableDebug"), + expect.stringContaining("runtime.ultra"), expect.stringContaining("global.serviceTier") ]) ) @@ -57,6 +59,7 @@ describe("config validation", () => { it("normalizes canonical config fields and custom model aliases", () => { const parsed = parseConfigFileObject({ + runtime: { ultra: true }, global: { reasoningMode: "PRO", textVerbosity: "HIGH", @@ -93,6 +96,7 @@ describe("config validation", () => { include: ["file_search_call.results"], parallelToolCalls: false }) + expect(parsed.ultraEnabled).toBe(true) expect(parsed.modelAliases).toEqual({ fast: false, extendedContext: true, pro: true }) expect(parsed.customModels?.["openai/my-fast-codex"]).toEqual({ targetModel: "gpt-5.3-codex", diff --git a/test/helpers/codex-in-vivo.ts b/test/helpers/codex-in-vivo.ts index 1e6c7a1..2baa2a2 100644 --- a/test/helpers/codex-in-vivo.ts +++ b/test/helpers/codex-in-vivo.ts @@ -4,8 +4,6 @@ import path from "node:path" import { fileURLToPath } from "node:url" import { CodexAuthPlugin } from "../../lib/codex-native" -import { refreshCachedCodexPrompts } from "../../lib/codex-prompts-cache" -import { setCodexPlanModeInstructions } from "../../lib/codex-native/collaboration" import { defaultAuthPath } from "../../lib/paths" type InVivoProbeInput = { @@ -14,8 +12,6 @@ type InVivoProbeInput = { personalityText: string modelSlug?: string agent?: string - collaborationProfileEnabled?: boolean - orchestratorSubagentsEnabled?: boolean stripModelOptionsBeforeParams?: boolean modelInstructionsFallback?: string omitModelIdentityBeforeParams?: boolean @@ -113,31 +109,11 @@ export async function runCodexInVivoInstructionProbe(input: InVivoProbeInput): P let outboundUrl: string | undefined let outboundOriginator: string | undefined let outboundUserAgent: string | undefined - const mockedPlanPrompt = [ - "# Plan Mode (Conversational)", - "", - "You may explore and execute **non-mutating** actions that improve the plan.", - "Before asking the user any question, perform at least one targeted non-mutating exploration pass.", - "Use the `request_user_input` tool only for decisions that materially change the plan." - ].join("\n") - const mockedOrchestratorPrompt = [ - "# Sub-agents", - "", - "When subagents are available, delegate independent work in parallel, coordinate with wait/send_input, and synthesize." - ].join("\n") - const originalFetch = globalThis.fetch globalThis.fetch = async (requestInput: RequestInfo | URL, init?: RequestInit) => { const request = requestInput instanceof Request ? requestInput : new Request(requestInput, init) const url = request.url - if (url.includes("/templates/collaboration_mode/plan.md")) { - return new Response(mockedPlanPrompt, { status: 200, headers: { "content-type": "text/plain" } }) - } - if (url.includes("/templates/agents/orchestrator.md")) { - return new Response(mockedOrchestratorPrompt, { status: 200, headers: { "content-type": "text/plain" } }) - } - if (url.includes("/backend-api/codex/models")) { return new Response( JSON.stringify({ @@ -181,14 +157,9 @@ export async function runCodexInVivoInstructionProbe(input: InVivoProbeInput): P } try { - const prompts = await refreshCachedCodexPrompts({ forceRefresh: true }) - setCodexPlanModeInstructions(prompts.plan) - const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex", - behaviorSettings: { global: { personality: input.personalityKey } }, - collaborationProfileEnabled: input.collaborationProfileEnabled, - orchestratorSubagentsEnabled: input.orchestratorSubagentsEnabled + behaviorSettings: { global: { personality: input.personalityKey } } }) const provider = { diff --git a/test/installer-cli.test.ts b/test/installer-cli.test.ts index 6a6c92d..d4a45ca 100644 --- a/test/installer-cli.test.ts +++ b/test/installer-cli.test.ts @@ -46,8 +46,7 @@ describe("installer cli", () => { expect(output).toContain("Codex config:") expect(output).toContain("/create-personality synchronized: created") expect(output).toContain("personality-builder skill synchronized: created") - expect(output).toContain("Codex prompts cache synchronized: yes") - expect(output).toContain("Orchestrator agent visible in current mode (native, collaboration=off): no") + expect(output).toContain("Legacy orchestrator artifacts removed: 0") const config = JSON.parse(await fs.readFile(configPath, "utf8")) as { plugin: string[] } expect(config.plugin).toContain("@iam-brain/opencode-codex-auth@latest") @@ -67,11 +66,6 @@ describe("installer cli", () => { "utf8" ) expect(skillFile).toContain("name: personality-builder") - - await expect(fs.access(path.join(root, "opencode", "agents", "orchestrator.md"))).rejects.toThrow() - await expect( - fs.access(path.join(root, "opencode", "agents", "orchestrator.md.disabled")) - ).resolves.toBeUndefined() } finally { if (previousXdg === undefined) { delete process.env.XDG_CONFIG_HOME @@ -129,39 +123,6 @@ describe("installer cli", () => { expect(capture.err.join("\n")).toContain("Unexpected argument: extra-arg") }) - it("shows orchestrator agent in codex mode", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-installer-codex-")) - const configPath = path.join(root, "opencode.json") - const capture = captureIo() - const previousXdg = process.env.XDG_CONFIG_HOME - const previousMode = process.env.OPENCODE_OPENAI_MULTI_MODE - process.env.XDG_CONFIG_HOME = root - process.env.OPENCODE_OPENAI_MULTI_MODE = "codex" - - try { - const code = await runInstallerCli(["--config", configPath], capture.io) - expect(code).toBe(0) - expect(capture.out.join("\n")).toContain( - "Orchestrator agent visible in current mode (codex, collaboration=on): yes" - ) - expect(capture.out.join("\n")).toContain("Codex prompts cache synchronized: yes") - await expect(fs.access(path.join(root, "opencode", "agents", "orchestrator.md"))).resolves.toBeUndefined() - await expect(fs.access(path.join(root, "opencode", "agents", "orchestrator.md.disabled"))).rejects.toThrow() - } finally { - if (previousXdg === undefined) { - delete process.env.XDG_CONFIG_HOME - } else { - process.env.XDG_CONFIG_HOME = previousXdg - } - - if (previousMode === undefined) { - delete process.env.OPENCODE_OPENAI_MULTI_MODE - } else { - process.env.OPENCODE_OPENAI_MULTI_MODE = previousMode - } - } - }) - it("preserves customized command and skill files on rerun", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-installer-custom-")) const configPath = path.join(root, "opencode.json") diff --git a/test/instruction-utils.test.ts b/test/instruction-utils.test.ts new file mode 100644 index 0000000..e851711 --- /dev/null +++ b/test/instruction-utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" + +import { + hasCodexToolNameMarkers, + mergeInstructions, + replaceCodexToolCallsForOpenCode, + resolveHookAgentName +} from "../lib/codex-native/instruction-utils" + +describe("Codex instruction utilities", () => { + it("merges instructions idempotently", () => { + const merged = mergeInstructions("base", "extra") + expect(merged).toBe("base\n\nextra") + expect(mergeInstructions(merged, "extra")).toBe(merged) + }) + + it("adapts Codex tool names without changing ordinary instructions", () => { + const codexInstructions = "Use spawn_agent and send_input to coordinate workers." + expect(hasCodexToolNameMarkers(codexInstructions)).toBe(true) + expect(replaceCodexToolCallsForOpenCode(codexInstructions)).toBe("Use task and task to coordinate workers.") + expect(replaceCodexToolCallsForOpenCode("Use available tools.")).toBe("Use available tools.") + }) + + it("resolves string and object agent names", () => { + expect(resolveHookAgentName("build")).toBe("build") + expect(resolveHookAgentName({ name: "plan" })).toBe("plan") + expect(resolveHookAgentName({ agent: "custom" })).toBe("custom") + }) +}) diff --git a/test/legacy-orchestrator-cleanup.test.ts b/test/legacy-orchestrator-cleanup.test.ts new file mode 100644 index 0000000..7d536c9 --- /dev/null +++ b/test/legacy-orchestrator-cleanup.test.ts @@ -0,0 +1,44 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { describe, expect, it } from "vitest" + +import { removeLegacyOrchestratorArtifacts } from "../lib/legacy-orchestrator-cleanup" + +describe("legacy orchestrator cleanup", () => { + it("removes plugin-managed enabled and disabled agent files", async () => { + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), "legacy-orchestrator-cleanup-")) + const managed = [path.join(agentsDir, "orchestrator.md"), path.join(agentsDir, "orchestrator.md.disabled")] + for (const filePath of managed) { + await fs.writeFile( + filePath, + "---\ndescription: Codex-style orchestration profile for parallel delegation and synthesis.\n---\n", + "utf8" + ) + } + + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), "legacy-orchestrator-cache-cleanup-")) + const cacheFiles = [ + path.join(cacheDir, "codex-prompts-cache.json"), + path.join(cacheDir, "codex-prompts-cache-meta.json") + ] + await Promise.all(cacheFiles.map((filePath) => fs.writeFile(filePath, "{}\n", "utf8"))) + + const result = await removeLegacyOrchestratorArtifacts({ agentsDir, cacheDir }) + + expect(result.removed).toEqual([...managed, ...cacheFiles]) + await Promise.all([...managed, ...cacheFiles].map((filePath) => expect(fs.access(filePath)).rejects.toThrow())) + }) + + it("preserves user-authored orchestrator files", async () => { + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), "legacy-orchestrator-cleanup-user-")) + const filePath = path.join(agentsDir, "orchestrator.md") + await fs.writeFile(filePath, "---\ndescription: My orchestrator\n---\nCustom instructions\n", "utf8") + + const result = await removeLegacyOrchestratorArtifacts({ agentsDir, cacheDir: agentsDir }) + + expect(result).toEqual({ removed: [], preserved: [filePath] }) + await expect(fs.readFile(filePath, "utf8")).resolves.toContain("Custom instructions") + }) +}) diff --git a/test/model-catalog.provider-models.test.ts b/test/model-catalog.provider-models.test.ts index a7b97e3..47eae54 100644 --- a/test/model-catalog.provider-models.test.ts +++ b/test/model-catalog.provider-models.test.ts @@ -150,7 +150,8 @@ describe("model catalog provider model mapping", () => { applyCodexCatalogToProviderModels({ providerModels, - catalogModels + catalogModels, + ultraEnabled: true }) expect(providerModels["gpt-5.4-codex"]).toBeDefined() @@ -183,6 +184,29 @@ describe("model catalog provider model mapping", () => { }) }) + it("hides the Ultra WIP variant and maps an Ultra catalog default to Max unless enabled", () => { + const model = { + slug: "gpt-5.6-sol", + context_window: 272000, + default_reasoning_level: "ultra", + supported_reasoning_levels: [{ effort: "max" }, { effort: "ultra" }], + multi_agent_version: "v2", + visibility: "list", + supported_in_api: true + } + const disabledModels: Record> = {} + applyCodexCatalogToProviderModels({ providerModels: disabledModels, catalogModels: [model] }) + expect(disabledModels[model.slug].variants).toEqual({ max: { reasoningEffort: "max" } }) + expect(disabledModels[model.slug].codexRuntimeDefaults).toMatchObject({ + defaultReasoningEffort: "max", + supportedReasoningEfforts: ["max"] + }) + + const enabledModels: Record> = {} + applyCodexCatalogToProviderModels({ providerModels: enabledModels, catalogModels: [model], ultraEnabled: true }) + expect(enabledModels[model.slug].variants).toMatchObject({ ultra: { reasoningEffort: "ultra" } }) + }) + it("creates new catalog-only provider entries without cross-slug inheritance", () => { const providerModels: Record> = { "gpt-5.3-codex": { diff --git a/test/openai-loader-fetch.prompt-cache-key.catalog-refresh.test.ts b/test/openai-loader-fetch.prompt-cache-key.catalog-refresh.test.ts index 3c06d84..2893156 100644 --- a/test/openai-loader-fetch.prompt-cache-key.catalog-refresh.test.ts +++ b/test/openai-loader-fetch.prompt-cache-key.catalog-refresh.test.ts @@ -33,7 +33,6 @@ describe("openai loader fetch prompt cache key (catalog refresh)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -102,7 +101,6 @@ describe("openai loader fetch prompt cache key (catalog refresh)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -174,7 +172,6 @@ describe("openai loader fetch prompt cache key (catalog refresh)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -260,7 +257,6 @@ describe("openai loader fetch prompt cache key (catalog refresh)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -337,7 +333,6 @@ describe("openai loader fetch prompt cache key (catalog refresh)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} diff --git a/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts b/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts index 3b0ba4d..aa76ae2 100644 --- a/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts +++ b/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts @@ -55,7 +55,6 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async (stage, _request, meta) => { if (stage === "outbound-attempt") { @@ -136,7 +135,6 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -187,7 +185,7 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { }) }) - it("does not mutate shared affinity maps for subagent-marked requests", async () => { + it("does not mutate shared affinity maps for Ultra child requests", async () => { vi.resetModules() const auth = { @@ -230,13 +228,12 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { const handler = createOpenAIFetchHandler({ authMode: "native", spoofMode: "native", + ultraEnabled: true, remapDeveloperMessagesToUserEnabled: false, quietMode: true, pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", - internalCollaborationAgentHeader: "x-openai-subagent", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -257,7 +254,16 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { method: "POST", headers: { "content-type": "application/json", - "x-openai-subagent": "plan", + "x-opencode-ultra-state": JSON.stringify({ + selected: true, + logicalEffort: "ultra", + wireEffort: "max", + eligible: true, + delegationPolicy: "explicit_request_only", + agentRole: "child", + agentReason: "conservative_fallback", + reason: "eligible" + }), session_id: "ses_subagent" }, body: JSON.stringify({ model: "gpt-5.3-codex", input: "hi" }) @@ -277,7 +283,7 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { expect(persistSessionAffinityState).not.toHaveBeenCalled() }) - it("ignores spoofed x-openai-subagent headers without internal collaboration markers", async () => { + it("strips the removed legacy x-openai-subagent header", async () => { vi.resetModules() const auth = { @@ -312,12 +318,12 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { const handler = createOpenAIFetchHandler({ authMode: "native", spoofMode: "native", + ultraEnabled: false, remapDeveloperMessagesToUserEnabled: false, quietMode: true, pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -400,7 +406,6 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { pidOffsetEnabled: false, headerTransformDebug: true, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async (stage, _request, meta) => { if (stage === "outbound-attempt") { diff --git a/test/openai-loader-fetch.prompt-cache-key.header-forwarding.test.ts b/test/openai-loader-fetch.prompt-cache-key.header-forwarding.test.ts index 212f9b1..0d9a104 100644 --- a/test/openai-loader-fetch.prompt-cache-key.header-forwarding.test.ts +++ b/test/openai-loader-fetch.prompt-cache-key.header-forwarding.test.ts @@ -42,7 +42,6 @@ describe("openai loader fetch prompt cache key (header forwarding)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -112,7 +111,6 @@ describe("openai loader fetch prompt cache key (header forwarding)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -182,7 +180,6 @@ describe("openai loader fetch prompt cache key (header forwarding)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -248,7 +245,6 @@ describe("openai loader fetch prompt cache key (header forwarding)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -318,7 +314,6 @@ describe("openai loader fetch prompt cache key (header forwarding)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} diff --git a/test/openai-loader-fetch.prompt-cache-key.project-and-quota.test.ts b/test/openai-loader-fetch.prompt-cache-key.project-and-quota.test.ts index 60fff08..864147c 100644 --- a/test/openai-loader-fetch.prompt-cache-key.project-and-quota.test.ts +++ b/test/openai-loader-fetch.prompt-cache-key.project-and-quota.test.ts @@ -68,7 +68,6 @@ describe("openai loader fetch prompt cache key (project + quota)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -189,7 +188,6 @@ describe("openai loader fetch prompt cache key (project + quota)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -286,7 +284,6 @@ describe("openai loader fetch prompt cache key (project + quota)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -361,7 +358,6 @@ describe("openai loader fetch prompt cache key (project + quota)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} diff --git a/test/openai-loader-fetch.prompt-cache-key.quota-retries.test.ts b/test/openai-loader-fetch.prompt-cache-key.quota-retries.test.ts index cb32ac0..bb46a6d 100644 --- a/test/openai-loader-fetch.prompt-cache-key.quota-retries.test.ts +++ b/test/openai-loader-fetch.prompt-cache-key.quota-retries.test.ts @@ -54,7 +54,6 @@ describe("openai loader fetch prompt cache key (quota retries)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} @@ -166,7 +165,6 @@ describe("openai loader fetch prompt cache key (quota retries)", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: async () => {}, captureResponse: async () => {} diff --git a/test/openai-loader-fetch.shareable-debug.test.ts b/test/openai-loader-fetch.shareable-debug.test.ts index 60fc421..6af7e18 100644 --- a/test/openai-loader-fetch.shareable-debug.test.ts +++ b/test/openai-loader-fetch.shareable-debug.test.ts @@ -75,7 +75,6 @@ describe("openai loader shareable debug wiring", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: vi.fn(async () => {}), captureResponse: vi.fn(async () => {}) @@ -172,7 +171,6 @@ describe("openai loader shareable debug wiring", () => { configuredRotationStrategy: "round_robin", headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: vi.fn(async () => {}), captureResponse: vi.fn(async () => {}) @@ -266,7 +264,6 @@ describe("openai loader shareable debug wiring", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: vi.fn(async () => {}), captureResponse: vi.fn(async () => {}) @@ -351,7 +348,6 @@ describe("openai loader shareable debug wiring", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: vi.fn(async () => {}), captureResponse: vi.fn(async () => {}) @@ -440,7 +436,6 @@ describe("openai loader shareable debug wiring", () => { pidOffsetEnabled: false, headerTransformDebug: false, compatInputSanitizerEnabled: false, - internalCollaborationModeHeader: "x-opencode-collaboration-mode-kind", requestSnapshots: { captureRequest: vi.fn(async () => {}), captureResponse: vi.fn(async () => {}) diff --git a/test/orchestrator-agent.test.ts b/test/orchestrator-agent.test.ts deleted file mode 100644 index 6b3c712..0000000 --- a/test/orchestrator-agent.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { resetStubbedGlobals, stubGlobalForTest } from "./helpers/mock-policy" - -import { afterEach, describe, expect, it, vi } from "vitest" - -import { - CODEX_ORCHESTRATOR_AGENT_FILE, - CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED, - installOrchestratorAgent, - reconcileOrchestratorAgentVisibility -} from "../lib/orchestrator-agent" -import { CODEX_PROMPTS_CACHE_FILE, CODEX_PROMPTS_CACHE_META_FILE } from "../lib/codex-prompts-cache" - -describe("orchestrator agent installer", () => { - afterEach(() => { - resetStubbedGlobals() - }) - - it("downloads upstream orchestrator prompt and prepends local frontmatter header", async () => { - stubGlobalForTest( - "fetch", - vi.fn( - async () => - new Response( - "You are Codex, a coding agent based on GPT-5.\n\n# Sub-agents\nIf `spawn_agent` is unavailable or fails, ignore this section and proceed solo.", - { - status: 200, - headers: { "content-type": "text/plain" } - } - ) - ) - ) - - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-upstream-")) - const agentsDir = path.join(root, "agents") - const cacheDir = path.join(root, "cache") - - const first = await installOrchestratorAgent({ agentsDir, cacheDir }) - expect(first.created).toBe(true) - const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) - const firstContent = await fs.readFile(filePath, "utf8") - expect(firstContent).toContain( - "description: Codex-style orchestration profile for parallel delegation and synthesis." - ) - expect(firstContent).toContain("mode: primary") - expect(firstContent).toContain("You are Codex, a coding agent based on GPT-5.") - expect(firstContent).toContain("If `task` is unavailable or fails, ignore this section and proceed solo.") - expect(firstContent).not.toContain("spawn_agent") - expect(firstContent).toContain("task") - - const cacheRaw = await fs.readFile(path.join(cacheDir, CODEX_PROMPTS_CACHE_FILE), "utf8") - const cache = JSON.parse(cacheRaw) as { - prompts?: { orchestrator?: string; plan?: string } - } - expect(cache.prompts?.orchestrator).toContain("You are Codex, a coding agent based on GPT-5.") - - const metaRaw = await fs.readFile(path.join(cacheDir, CODEX_PROMPTS_CACHE_META_FILE), "utf8") - const meta = JSON.parse(metaRaw) as { urls?: { orchestrator?: string; plan?: string; build?: string } } - expect(meta.urls?.orchestrator).toContain("templates/agents/orchestrator.md") - expect(meta.urls?.plan).toContain("templates/collaboration_mode/plan.md") - }) - - it("writes orchestrator agent template and preserves existing content by default", async () => { - stubGlobalForTest( - "fetch", - vi.fn( - async () => - new Response("You are Codex, a coding agent based on GPT-5.\n\n# Sub-agents", { - status: 200, - headers: { "content-type": "text/plain" } - }) - ) - ) - - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-")) - const agentsDir = path.join(root, "agents") - - const first = await installOrchestratorAgent({ agentsDir, cacheDir: path.join(root, "cache") }) - expect(first.created).toBe(true) - const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) - const firstContent = await fs.readFile(filePath, "utf8") - expect(firstContent).toContain("mode: primary") - expect(firstContent).toContain("You are Codex, a coding agent based on GPT-5.") - - await fs.writeFile(filePath, "custom orchestrator", "utf8") - const second = await installOrchestratorAgent({ agentsDir, cacheDir: path.join(root, "cache") }) - expect(second.created).toBe(false) - expect(second.updated).toBe(false) - expect(await fs.readFile(filePath, "utf8")).toBe("custom orchestrator") - }) - - it("updates existing orchestrator agent when forced", async () => { - stubGlobalForTest( - "fetch", - vi.fn( - async () => - new Response("You are Codex, a coding agent based on GPT-5.\n\n# Sub-agents", { - status: 200, - headers: { "content-type": "text/plain" } - }) - ) - ) - - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-force-")) - const agentsDir = path.join(root, "agents") - const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) - await fs.mkdir(agentsDir, { recursive: true }) - await fs.writeFile(filePath, "stale orchestrator", "utf8") - - const result = await installOrchestratorAgent({ agentsDir, cacheDir: path.join(root, "cache"), force: true }) - expect(result.created).toBe(false) - expect(result.updated).toBe(true) - - const content = await fs.readFile(filePath, "utf8") - expect(content).toContain("mode: primary") - expect(content).toContain("You are Codex, a coding agent based on GPT-5.") - }) - - it("toggles visibility by renaming enabled/disabled file variants", async () => { - stubGlobalForTest( - "fetch", - vi.fn( - async () => - new Response("You are Codex, a coding agent based on GPT-5.\n\n# Sub-agents", { - status: 200, - headers: { "content-type": "text/plain" } - }) - ) - ) - - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-toggle-")) - const agentsDir = path.join(root, "agents") - - const hidden = await reconcileOrchestratorAgentVisibility({ - agentsDir, - cacheDir: path.join(root, "cache"), - visible: false - }) - expect(hidden.visible).toBe(false) - expect(hidden.filePath).toBe(path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED)) - - const hiddenContent = await fs.readFile(hidden.filePath, "utf8") - expect(hiddenContent).toContain("mode: primary") - - const visible = await reconcileOrchestratorAgentVisibility({ - agentsDir, - cacheDir: path.join(root, "cache"), - visible: true - }) - expect(visible.visible).toBe(true) - expect(visible.filePath).toBe(path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE)) - - await expect(fs.access(path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED))).rejects.toMatchObject({ - code: "ENOENT" - }) - expect(await fs.readFile(visible.filePath, "utf8")).toContain("You are Codex, a coding agent based on GPT-5.") - }) - - it("applies force refresh while moving disabled->enabled and enabled->disabled", async () => { - stubGlobalForTest( - "fetch", - vi.fn( - async () => - new Response("You are Codex, a coding agent based on GPT-5.\n\n# Sub-agents\nupstream", { - status: 200, - headers: { "content-type": "text/plain" } - }) - ) - ) - - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-force-toggle-")) - const agentsDir = path.join(root, "agents") - const cacheDir = path.join(root, "cache") - const enabledPath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) - const disabledPath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED) - - await fs.mkdir(agentsDir, { recursive: true }) - await fs.writeFile(disabledPath, "stale disabled", "utf8") - const visible = await reconcileOrchestratorAgentVisibility({ agentsDir, cacheDir, visible: true, force: true }) - expect(visible.moved).toBe(true) - expect(await fs.readFile(enabledPath, "utf8")).toContain("You are Codex, a coding agent based on GPT-5.") - - await fs.writeFile(enabledPath, "stale enabled", "utf8") - const hidden = await reconcileOrchestratorAgentVisibility({ agentsDir, cacheDir, visible: false, force: true }) - expect(hidden.moved).toBe(true) - expect(await fs.readFile(disabledPath, "utf8")).toContain("You are Codex, a coding agent based on GPT-5.") - }) - - it("falls back to bundled full orchestrator prompt when upstream fetch fails", async () => { - stubGlobalForTest( - "fetch", - vi.fn(async () => { - throw new Error("network unavailable") - }) - ) - - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-fallback-")) - const agentsDir = path.join(root, "agents") - - const first = await installOrchestratorAgent({ agentsDir, cacheDir: path.join(root, "cache") }) - expect(first.created).toBe(true) - const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) - const firstContent = await fs.readFile(filePath, "utf8") - expect(firstContent).toContain( - "description: Codex-style orchestration profile for parallel delegation and synthesis." - ) - expect(firstContent).toContain("You are Codex, a coding agent based on GPT-5.") - expect(firstContent).toContain("If `task` is unavailable or fails, ignore this section and proceed solo.") - expect(firstContent).not.toContain("spawn_agent") - expect(firstContent).toContain("task") - }) -}) diff --git a/test/ultra.test.ts b/test/ultra.test.ts index 2aa4157..27316e5 100644 --- a/test/ultra.test.ts +++ b/test/ultra.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import type { CodexModelInfo } from "../lib/model-catalog.js" +import { CodexAuthPlugin } from "../lib/codex-native.js" import { handleChatParamsHook } from "../lib/codex-native/chat-hooks.js" import { transformOutboundRequestPayload } from "../lib/codex-native/request-transform-payload.js" import { @@ -43,8 +44,7 @@ describe("GPT-5.6 Ultra contract", () => { output, lastCatalogModels: [eligibleModel()], spoofMode: "codex", - collaborationProfileEnabled: false, - orchestratorSubagentsEnabled: false, + ultraEnabled: true, resolveAgentExecution: async () => { calls += 1 return { role: "root", reason: "session_root" } @@ -92,8 +92,7 @@ describe("GPT-5.6 Ultra contract", () => { output, lastCatalogModels: [eligibleModel()], spoofMode: "codex", - collaborationProfileEnabled: false, - orchestratorSubagentsEnabled: false, + ultraEnabled: true, agentExecution: { role: "root", reason: "session_root", agentName: "build" } }) @@ -125,8 +124,7 @@ describe("GPT-5.6 Ultra contract", () => { output: nativeOutput, lastCatalogModels: [eligibleModel()], spoofMode: "native", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true + ultraEnabled: true }) expect(nativeOutput.options.instructions).toBeUndefined() @@ -147,8 +145,7 @@ describe("GPT-5.6 Ultra contract", () => { output: childOutput, lastCatalogModels: [eligibleModel()], spoofMode: "codex", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true, + ultraEnabled: true, agentExecution: { role: "child", reason: "session_parent", agentName: "general" } }) expect(childOutput.options.instructions).toContain(ULTRA_EXPLICIT_ONLY_INSTRUCTIONS) @@ -176,8 +173,7 @@ describe("GPT-5.6 Ultra contract", () => { output, lastCatalogModels: [eligibleModel()], spoofMode: "codex", - collaborationProfileEnabled: true, - orchestratorSubagentsEnabled: true, + ultraEnabled: true, agentExecution: { role: "auxiliary", reason: "builtin_auxiliary", agentName } }) @@ -200,6 +196,7 @@ describe("GPT-5.6 Ultra contract", () => { remapDeveloperMessagesToUserEnabled: false, compatInputSanitizerEnabled: false, promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, catalogModels: [eligibleModel()] }) @@ -207,6 +204,94 @@ describe("GPT-5.6 Ultra contract", () => { expect(transformed.ultra).toMatchObject({ logicalEffort: "ultra", wireEffort: "max" }) }) + it("keeps the Ultra WIP hidden and policy-free when the flag is disabled", async () => { + const output = chatOutput() + const result = await handleChatParamsHook({ + hookInput: { + model: { + id: "gpt-5.6-sol", + providerID: "openai", + options: { + codexCatalogModel: eligibleModel(), + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + }, + agent: "build", + message: {} + }, + output, + lastCatalogModels: [eligibleModel()], + spoofMode: "codex", + ultraEnabled: false, + agentExecution: { role: "root", reason: "session_root", agentName: "build" } + }) + + expect(output.options.instructions).toBeUndefined() + expect(output.options.reasoningEffort).toBe("max") + expect(result.ultra).toBeUndefined() + + const transformed = await transformOutboundRequestPayload({ + request: new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.6-sol", + reasoning: { effort: "max" }, + instructions: `base\n\n${ULTRA_PROACTIVE_INSTRUCTIONS}` + }) + }), + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + ultraEnabled: false, + catalogModels: [eligibleModel()] + }) + expect(JSON.parse(await transformed.request.text()).instructions).toBe("base") + expect(transformed.ultra).toBeUndefined() + }) + + it("does not rewrite ordinary instructions while Ultra is disabled", async () => { + const instructions = " preserve leading space\n\n\nkeep the intentional gap " + const transformed = await transformOutboundRequestPayload({ + request: new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.6-sol", + reasoning: { effort: "max" }, + instructions + }) + }), + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + ultraEnabled: false, + catalogModels: [eligibleModel()] + }) + + expect(JSON.parse(await transformed.request.text()).instructions).toBe(instructions) + }) + + it("removes pre-existing provider Ultra variants while the WIP flag is disabled", async () => { + const hooks = await CodexAuthPlugin({} as never) + const provider = { + models: { + "gpt-5.6-sol": { + variants: { + max: { reasoningEffort: "max" }, + ultra: { reasoningEffort: "ultra" } + } + } + } + } + + await hooks.auth?.loader?.(async () => ({ type: "api", key: "test" }) as never, provider as never) + + expect(provider.models["gpt-5.6-sol"].variants).toEqual({ max: { reasoningEffort: "max" } }) + }) + it("keeps explicit Max separate from Ultra and resolves custom targets", async () => { const maxRequest = new Request("https://chatgpt.com/backend-api/codex/responses", { method: "POST", @@ -219,6 +304,7 @@ describe("GPT-5.6 Ultra contract", () => { remapDeveloperMessagesToUserEnabled: false, compatInputSanitizerEnabled: false, promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, catalogModels: [eligibleModel()] }) expect(max.ultra).toBeUndefined() @@ -234,6 +320,7 @@ describe("GPT-5.6 Ultra contract", () => { remapDeveloperMessagesToUserEnabled: false, compatInputSanitizerEnabled: false, promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, catalogModels: [eligibleModel()], customModels: { "my-sol": { targetModel: "gpt-5.6-sol" } } }) @@ -253,6 +340,7 @@ describe("GPT-5.6 Ultra contract", () => { remapDeveloperMessagesToUserEnabled: false, compatInputSanitizerEnabled: false, promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, catalogModels: [eligibleModel()], ultraState: resolveUltraSelection({ reasoningEffort: "ultra", model: eligibleModel() }) }) @@ -261,6 +349,137 @@ describe("GPT-5.6 Ultra contract", () => { expect(JSON.parse(await transformed.request.text()).reasoning.effort).toBe("max") }) + it("removes delegation overlays when account rotation cannot prove Ultra eligibility", async () => { + const request = new Request("https://chatgpt.com/backend-api/codex/responses", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.6-sol", + reasoning: { effort: "max" }, + instructions: `catalog instructions\n\n${ULTRA_PROACTIVE_INSTRUCTIONS}\n\nplan instructions` + }) + }) + const transformed = await transformOutboundRequestPayload({ + request, + selectedModelSlug: "gpt-5.6-sol", + stripReasoningReplayEnabled: false, + remapDeveloperMessagesToUserEnabled: false, + compatInputSanitizerEnabled: false, + promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, + catalogModels: [eligibleModel({ multi_agent_version: "v1" })], + ultraState: resolveUltraSelection({ + reasoningEffort: "ultra", + model: eligibleModel(), + agentExecution: { role: "root", reason: "session_root" } + }) + }) + + const payload = JSON.parse(await transformed.request.text()) + expect(payload.instructions).toBe("catalog instructions\n\nplan instructions") + expect(transformed.ultra).toMatchObject({ eligible: false, delegationPolicy: "explicit_request_only" }) + }) + + it("correlates Ultra state by message when same-session hooks interleave", async () => { + const hooks = await CodexAuthPlugin({} as never, { mode: "codex", ultraEnabled: true }) + const model = { + id: "gpt-5.6-sol", + providerID: "openai", + capabilities: { toolcall: true }, + options: { + codexCatalogModel: eligibleModel(), + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + } + const ultraOutput = chatOutput() + const maxOutput = chatOutput() + maxOutput.options.reasoningEffort = "max" + + await hooks["chat.params"]?.( + { + sessionID: "shared-session", + agent: "build", + provider: {}, + message: { id: "ultra-message" }, + model + } as never, + ultraOutput as never + ) + await hooks["chat.params"]?.( + { + sessionID: "shared-session", + agent: "build", + provider: {}, + message: { id: "max-message" }, + model + } as never, + maxOutput as never + ) + + const maxHeaders = { headers: {} as Record } + await hooks["chat.headers"]?.( + { + sessionID: "shared-session", + agent: "build", + provider: {}, + message: { id: "max-message" }, + model + } as never, + maxHeaders as never + ) + const ultraHeaders = { headers: {} as Record } + await hooks["chat.headers"]?.( + { + sessionID: "shared-session", + agent: "build", + provider: {}, + message: { id: "ultra-message" }, + model + } as never, + ultraHeaders as never + ) + + expect(maxHeaders.headers["x-opencode-ultra-state"]).toBeUndefined() + expect(JSON.parse(String(ultraHeaders.headers["x-opencode-ultra-state"]))).toMatchObject({ + logicalEffort: "ultra", + delegationPolicy: "proactive" + }) + }) + + it.each([ + { name: "duplicate", message: { id: "duplicate-message" } }, + { name: "missing", message: {} } + ])("fails closed when $name message IDs make hook correlation ambiguous", async ({ message }) => { + const hooks = await CodexAuthPlugin({} as never, { mode: "codex", ultraEnabled: true }) + const model = { + id: "gpt-5.6-sol", + providerID: "openai", + capabilities: { toolcall: true }, + options: { + codexCatalogModel: eligibleModel(), + codexRuntimeDefaults: { defaultReasoningEffort: "ultra" } + } + } + const ultraOutput = chatOutput() + const maxOutput = chatOutput() + maxOutput.options.reasoningEffort = "max" + const hookInput = { + sessionID: "ambiguous-session", + agent: "build", + provider: {}, + message, + model + } + + await hooks["chat.params"]?.(hookInput as never, ultraOutput as never) + await hooks["chat.params"]?.(hookInput as never, maxOutput as never) + + for (let index = 0; index < 2; index += 1) { + const headers = { headers: {} as Record } + await hooks["chat.headers"]?.(hookInput as never, headers as never) + expect(headers.headers["x-opencode-ultra-state"]).toBeUndefined() + } + }) + it("retains explicit-only child policy when collaboration headers are unavailable", async () => { const request = new Request("https://chatgpt.com/backend-api/codex/responses", { method: "POST", @@ -273,6 +492,7 @@ describe("GPT-5.6 Ultra contract", () => { remapDeveloperMessagesToUserEnabled: false, compatInputSanitizerEnabled: false, promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, catalogModels: [eligibleModel()], ultraState: resolveUltraSelection({ reasoningEffort: "ultra", model: eligibleModel(), childTask: true }) }) @@ -297,6 +517,7 @@ describe("GPT-5.6 Ultra contract", () => { remapDeveloperMessagesToUserEnabled: false, compatInputSanitizerEnabled: false, promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, catalogModels: [eligibleModel()], ultraState: state }) @@ -316,6 +537,7 @@ describe("GPT-5.6 Ultra contract", () => { remapDeveloperMessagesToUserEnabled: false, compatInputSanitizerEnabled: false, promptCacheKeyOverrideEnabled: false, + ultraEnabled: true, catalogModels: [eligibleModel({ multi_agent_version: undefined })] }) expect(JSON.parse(await transformed.request.text()).reasoning.effort).toBe("max") diff --git a/test/upstream-watch-config.test.ts b/test/upstream-watch-config.test.ts index 024f90d..8051c4e 100644 --- a/test/upstream-watch-config.test.ts +++ b/test/upstream-watch-config.test.ts @@ -28,9 +28,12 @@ describe("upstream watch coverage", () => { expect(tracked.has("packages/opencode/src/provider/error.ts")).toBe(true) expect(tracked.has("packages/opencode/src/session/message-v2.ts")).toBe(true) expect(tracked.has("codex-rs/models-manager/models.json")).toBe(true) - expect(tracked.has("codex-rs/core/src/auth.rs")).toBe(true) + expect(tracked.has("codex-rs/login/src/auth/manager.rs")).toBe(true) + expect(tracked.has("codex-rs/login/src/server.rs")).toBe(true) expect(tracked.has("codex-rs/core/src/client.rs")).toBe(true) - expect(tracked.has("codex-rs/core/src/codex.rs")).toBe(true) + expect(tracked.has("codex-rs/core/src/session/multi_agents.rs")).toBe(true) + expect(tracked.has("codex-rs/core/src/codex_thread.rs")).toBe(true) + expect(tracked.has("codex-rs/core/src/codex_delegate.rs")).toBe(true) expect(tracked.has("codex-rs/core/src/compact.rs")).toBe(true) }) }) From bec6cb2e3e97f3b14aac7d9879dfce706d562f71 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 10 Jul 2026 22:14:21 -0400 Subject: [PATCH 6/6] docs: prepare 1.9.0 release notes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d412a2d..dd61412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## Unreleased +## 1.9.0 - 2026-07-10 + - Refactored config precedence to keep explicit runtime mode authoritative and treat spoof mode as compatibility fallback. - Hardened merged auth state active-account selection to avoid disabled active identity carry-over. - Simplified request payload transform wrappers to route through one shared aggregate transform pipeline.